首页 / 文章

通过Qdrant的多向量技术与句子隔离功能,将RAG的标记使用量减少67%。

一种基于法律信息检索的RAG处理流程,它在Qdrant内部利用ColBERT重新排序,筛选出匹配的句子,并以一定的延迟为代价实现67.1%的标记数量削减。

发布者:REACTAPP.TOP阅读约 8 分钟1952 词

来源: How Qdrant Reduced RAG Token Costs by 67% with Native ColBERT Reranking

令牌费用问题已成为那些使用检索增强生成技术的团队的首要担忧。各大企业都在寻找降低消耗的方法,许多团队发现,未经优化的大型语言模型处理流程所花费的成本,可能超过其本应节省的工程时间。法律文件审查是个很好的测试场景,因为这些文件篇幅长、所需答案简短且数量庞大。

没人需要的页面

想象一下,在一起合同纠纷的调查进行到第三周时,一名律师面前摆着厚厚的主服务协议和企业备案文件需要处理。问题很具体:当第三方声称存在知识产权侵权时,赔偿上限是多少?答案就在一份40页的合同中的某一句里。

标准的RAG系统会返回排名靠前的三条文本片段,每条长度在500到1,000个标记之间,这些内容大多是陈述、定义、付款条款以及管辖法律条款,与知识产权主张毫无关联。唯一有用的句子位于中间位置。这不仅让读者感到困扰,大规模应用时还会带来高昂成本:在诉讼审查中处理数千份文件时,大型语言模型服务提供商不得不处理大量冗余的标准化内容。

由此产生两个问题:所检索的文本中究竟有多少是真正必要的?是否可以在不依赖另一项复杂的重排序服务的情况下直接提取出那句关键的句子?

这种浪费现象并非法律领域所特有。任何由大量模板化文档构成的集合,比如保险单、供应商合同或合规手册,都具有相同的结构:简短的答案被包裹在数页的文本中,而每次检索都会将这些文本一并纳入提示词中。成本取决于被检索文本块的大小,而非答案本身的长度,因此关键在于控制每个文本块中有多少内容能够传递给模型。

为什么要在向量数据库中进行重排序

常见的两阶段处理流程是先进行快速的密集搜索,选出大约前100个候选项,然后再向Cohere、SageMaker或自定义容器等外部重排序工具发起第二次网络请求以对它们进行排序。虽然这种方法可行,但存在三个缺点。

  • 需要向两个不同的供应商发起两次网络请求。
  • 如果重排序工具出现故障,整个搜索过程也会失败。
  • 该方案涵盖向量存储及重排序调用功能。
  • Qdrant提供了另一种选择。从1.10版本开始,它支持带有MAX_SIM比较器的原生多向量字段,这一比较器正是ColBERT风格延迟交互机制的底层实现。令牌到令牌的比较会在单个查询中在数据库内部完成,因此无需二次处理即可获得独立重排序器的延迟交互精度。结合句子隔离技术,该配置使令牌成本降低了67%。

    内存设置则构成了完整的配置方案。

    • 密集索引上的二进制量化。密集向量会被压缩为每维一个比特,这些小型向量会保留在RAM中(always_ram=True),从而实现极快的候选项预取功能。
    • 磁盘上的ColBERT矩阵。庞大的标记矩阵存储在磁盘上(on_disk=True),仅在重新评分时为排名靠前的候选集加载,大约50到100个片段,从而降低RAM使用成本。

    处理流程

    该设计包含六个步骤,仅需一次数据库查询,且不依赖外部重新排序API。

    1. 按章节分割协议内容,确保没有某个片段将法律条款一分为二。
    2. 对每个片段进行两次嵌入处理:一次生成标准密集向量用于快速筛选,另一次生成ColBERT多向量矩阵用于标记级重新评分。
    3. 使用二进制量化后的密集向量预加载排名前N的候选项。
    4. 在Qdrant中使用ColBERT MAX_SIM功能对这些候选项重新评分。
    5. 选取排名靠前的片段,并在本地将其中的各个句子与ColBERT查询向量进行对比评分。
    6. 仅将匹配的句子发送给大语言模型。

    该基准测试的定价假设为每百万个输入标记3.00美元,这一标准与中等配置的商业模型相符。

    数据集结构

    下面的配置脚本同时设置了标准的密集嵌入模型和ColBERT多向量模型,同时还在CPU上本地加载FastEmbed模型。

    from qdrant_client import QdrantClient, models
    # ADDED: Load FastEmbed models locally on CPU
    from fastembed import TextEmbedding, LateInteractionTextEmbedding
    COLLECTION_NAME = "legal_discovery"
    DENSE_DIM = 384  # BAAI/bge-small-en-v1.5
    COLBERT_DIM = 128  # colbert-ir/colbertv2.0
    # ADDED: Instantiate the vector models
    dense_model = TextEmbedding("BAAI/bge-small-en-v1.5")
    colbert_model = LateInteractionTextEmbedding("colbert-ir/colbertv2.0")
    client = QdrantClient("<http://localhost:6333>")
    client.create_collection(
        collection_name=COLLECTION_NAME,
        vectors_config={
            "dense": models.VectorParams(
                size=DENSE_DIM,
                distance=models.Distance.COSINE,
                quantization_config=models.BinaryQuantization(
                    binary=models.BinaryQuantizationConfig(always_ram=True),
                ),
            ),
            "colbert": models.VectorParams(
                size=COLBERT_DIM,
                distance=models.Distance.COSINE,
                multivector_config=models.MultiVectorConfig(
                    comparator=models.MultiVectorComparator.MAX_SIM
                ),
                on_disk=True,
                hnsw_config=models.HnswConfigDiff(m=0),
            ),
        },
    )
    

    在ColBERT的向量参数中,有一个设置尤为突出:HNSW配置中的m值被设置为0。由于ColBERT仅用于对预过滤后的结果集重新评分,因此无需使用HNSW图。跳过该图可以节省大量的索引构建时间和存储空间。

    预取与重新评分仅需一次往返

    Qdrant的通用查询API能够让第一阶段的预取操作与第二阶段的重新评分操作作为单一操作执行。无需进行查询、ID解析或再次调用重排器,整个流程都在一次数据库往返中完成。

    # ADDED: Generate query embeddings (ColBERT uses query_embed to add prefix padding)
    dense_query = next(dense_model.query_embed(query)).tolist()
    colbert_query = next(colbert_model.query_embed(query)).tolist()
    # Run the two-stage query in one network round-trip
    results = client.query_points(
        collection_name=COLLECTION_NAME,
        prefetch=models.Prefetch(
            query=dense_query,
            using="dense",
            limit=prefetch_limit,
            params=models.SearchParams(
                quantization=models.QuantizationSearchParams(rescore=False),
            ),
        ),
        query=colbert_query,
        using="colbert",
        limit=top_k,
        with_payload=True,
    )
    

    prefetch块中的rescore=False标志值得注意。在二进制量化模式下,Qdrant通常会先获取带有量化向量的候选项,然后再加载全精度的float32密集向量来进行重新评分。由于ColBERT阶段本身就能实现更精确的评分,关闭密集向量重新评分功能可以避免从磁盘读取全精度向量,从而降低数据库的负载。

    从分块到句子

    MAX_SIM为每个分块提供一个综合得分,但不会显示是哪些标记影响了该得分。为了仅提取相关文本,最后会进行一次本地处理,将得分最高的分块拆分成句子,并分别为每个句子计算ColBERT的后期交互得分。由于这一过程仅在文本量较小的最后几个分块上运行,因此不会出现数据库扩展瓶颈,且在CPU上实现句子级精度成本较低。

    import re
    import numpy as np
    # ADDED: Basic sentence splitter regex
    SENTENCE_SPLIT = re.compile(r"(?<=[.;])\s+(?=[A-Z])")
    def max_sim(query_vecs: np.ndarray, doc_vecs: np.ndarray) -> float:
        # Compute token-to-token similarity matrix
        sims = query_vecs @ doc_vecs.T  # (num_query_tokens, num_doc_tokens)
        # Sum the maximum similarity scores along the document axis
        return float(sims.max(axis=1).sum())
    def isolate_sentences(chunk_text: str, query_vecs: np.ndarray, colbert_model, top_n: int = 1):
        # ADDED: Split chunk text into candidate sentences
        sentences = [s.strip() for s in SENTENCE_SPLIT.split(chunk_text) if len(s.strip()) > 15]
        if not sentences:
            return [(chunk_text, 0.0)]
        # Embed each sentence locally using ColBERT
        sentence_vecs = list(colbert_model.embed(sentences))
        scored = [(sentences[i], max_sim(query_vecs, sentence_vecs[i])) for i in range(len(sentences))]
        scored.sort(key=lambda pair: pair[1], reverse=True)
        return scored[:top_n]
    

    本地计算速度很快,因为它只需处理最顶层的三到五个数据块。一旦确定了最佳句子,每个数据块中的其余部分就会被舍弃,随后再组装提示词内容。

    def build_optimized_prompt(query: str, chunk_texts: list[str], colbert_model) -> str:
        query_vecs = next(colbert_model.query_embed(query))
        context_parts = []
    for i, text in enumerate(chunk_texts):
            top_sentences = isolate_sentences(text, query_vecs, colbert_model, top_n=1)
            isolated_text = " ".join(s for s, _ in top_sentences)
            context_parts.append(f"[Source Chunk {i+1}]: {isolated_text}")
        context_str = "\n\n".join(context_parts)
        return f"Context:\n{context_str}\n\nQuestion: {query}\nAnswer:"
    

    分块大小与真实数值

    仅从令牌数量减少这一指标来看确实很有吸引力。理论上,将十到十二个法律条款打包进一个2,000字的块中,再应用句子隔离技术,其减少比例可超过85%。但在实际的法律系统中,这种方法会损害检索精度。过大的分块会在密集的向量空间中淹没特定条款的信号,比如第三方知识产权赔偿限额这类条款,使其被周围的冗余文本所掩盖。针对这些混杂了多个条款的分块进行查询时,往往会出现返回保修条款等无关内容的情况,因为密集检索模型无法准确定位目标内容。

    解决方案是为每个数据块设定严格的限制,即每个块仅包含一个独立的条款,且条款长度要合理。按照这一方案,测试结果显示提示词数量确实减少了67.1%。通过对赔偿责任和知识产权归属相关查询的手动检查,确认找到了正确的条款,尽管实际降幅从理论上的87.3%降到了67.1%。清晰的板块边界比更高的百分比更为重要。

    延迟与效率的权衡

    二进制量化原本让人认为两阶段处理流程在速度上会优于普通的密集搜索,但实际上并非如此。在多次测试中,无论使用何种机器或缓存状态如何,两阶段搜索的速度始终比普通搜索慢两到四倍:分别为39.5毫秒和20.4毫秒。原因很简单:普通密集搜索只需进行一次嵌入查找,而两阶段搜索则需要同时运行查询编码器(包括密集编码器和ColBERT),并在数据库中构建令牌比较矩阵。单独进行的普通密集搜索平均耗时为7.4毫秒。

    将预取候选项数量从10个增加到500个,对延迟几乎没有任何影响,延迟值仍保持在同一区间。造成额外时间消耗的根源在于两阶段结构本身,而非预取窗口的大小。

    从整体来看,端到端RAG的延迟主要受LLM生成速度的影响,而非数据库查询。每次查询节省400多个标记,就能将后续处理和生成时间缩短数百毫秒,从而使整个应用程序的运行速度显著提升。

    节省的成本价值

    如果每百万输入标记的成本为3.00美元,那么将提示词数量减少67.1%就能带来可观的节省。对于需要进行500,000次查询的诉讼调查任务,每处理一起案件就能节省600多美元。更短的提示词还能减少“中间丢失”现象,即模型忽略掉隐藏在长上下文中的相关文本。

    采用之前需要检查的事项

    有三个指标决定该设计是否适用于特定的工作负载。首先是手工检查的查询集上的答案正确率,因为如果缺少正确的子句,节省的标记数就毫无意义。其次是包括生成过程在内的整个请求的延迟,因为与模型调用相比,额外的数据库处理时间微不足道。第三是在句子隔离前后每个提示的标记数,需按照当前计费标准进行测量。某种文档类型的测试结果可能无法直接应用于另一种类型,因此应在目标语料库上重复进行基准测试。

    实际应用中的经验

    • 将重新排序操作留在数据库内部完成。外部重新排序API会增加故障点和网络往返开销,而内置的MAX_SIM操作符则能一次性完成这项工作。
    • 对于仅需要重新评分的字段,关闭 HNSW 功能。在多向量配置中将 m 设为 0 可以避免耗时的索引步骤,因为该步骤对 ColBERT 矩阵的检索效果并无提升。
    • 避免使用过大的分块尺寸。虽然过大的分块尺寸在理论上能显著减少标记数量,但会削弱语义表达并降低准确性。
    • 使用真正的句子分词器。演示时使用原始正则表达式即可,但实际代码应选用 NLTK 或 spaCy 等库,这类库能够正确处理 Inc.、Sec. 和 Exhibit A 等法律缩写,而不会错误地分割句子。

    通过原生多向量比较结合本地句子级评分,可以构建一个既精确又节省成本的流程,准确返回用户所需的条款,同时避免每月的 API 费用激增。

    相关阅读

  • 仅花五美元就在真实 SDK 上的 Claude Code 中测试 GLM 5.2 —— 一项耗资五美元的 OpenRouter 实验:将 Claude Code 路由到 GLM 5.2,对 React Native SDK 进行审计与修改,每项任务的成本以及节省空间的极限。
  • Headroom:通过可逆上下文压缩降低智能体令牌成本 —— Headroom 如何在日志、RAG 数据块和工具输出传送到模型之前对其进行压缩,以及 CacheAligner、ContentRouter、CCR、代理模式等相关技术及其权衡。