长文档 RAG 重排:ReRank 与递归检索让长文检索更准

为什么长文档检索更难

长文档(技术手册、论文、法律合同、产品白皮书)和多文档语料给 RAG 带来三类典型问题。

第一,chunk 切分的两难。文档过长必须切块,但切块粒度直接决定召回质量。切得太细(如每 128 个 token),语义被切碎,一个完整论点被拆到两个块里,检索时只命中半个;切得太粗(如每 2000 个 token),单个块包含太多主题,向量表示变得模糊,且会把噪声段落一并喂给生成模型,浪费上下文窗口。

第二,召回噪声。向量检索是近似最近邻搜索,擅长语义匹配却会用「近义干扰」把无关但语义相近的块排到前面。在长文档里,同一术语反复出现,召回的前 k 个块可能集中来自同一小节,漏掉真正相关的其他章节。

第三,上下文窗口浪费。如果为了「宁可多召回不可漏」而把大量块塞进提示词,token 成本上升,且模型在长上下文中容易 lost in the middle,越靠后的内容被关注得越少。

举一个具体场景:一份 200 页的产品手册,用户问「如何重置管理员密码」。如果按 512 个 token 切块,答案可能分布在「账户管理」章的「密码策略」小节里;而向量召回可能因为「重置」「密码」等词在「安全须知」章也高频出现,把不相关块顶到前面。这正是长文档检索需要重排与递归结构的原因。

两阶段检索正是为缓解这些矛盾而生:先宽后窄,召回阶段保证覆盖,重排阶段保证精度。

两阶段检索的整体结构

一个典型的两阶段检索流程如下。

第一阶段 召回(retrieval):用成本低、可并行的方式从海量块里快速筛出候选集,例如 top 100。常用向量检索、BM25 关键字检索,或二者混合。

第二阶段 重排(rerank):对召回的候选集用更强的模型逐一打分,重新排序后只保留 top 5 到 top 10 喂给生成模型。重排模型单次只看一对「查询与文档」,计算量大但精度高。

这样的分工让系统既快又准:召回用便宜的近似方法保覆盖,重排用昂贵的精排模型提精度。

召回阶段:向量检索与 BM25 混合

向量检索把查询和文档都编码成稠密向量,用余弦相似度找近邻,擅长语义泛化。BM25 是基于词频的统计检索,擅长精确关键字(专有名词、错误码、版本号)。二者互补。

混合检索一般把两路分数归一化后融合,最常用的是倒数排名融合(Reciprocal Rank Fusion,RRF)。RRF 不依赖分数绝对值,只依赖排名:对每个文档,分数等于各路中 1/(k+rank) 之和,k 常取 60。这样即使向量相似度与 BM25 分数量纲不同,也能稳健合并。

下面给出一段不依赖外部框架的混合召回骨架。

import math

def rrf_merge(vector_ranked, bm25_ranked, k=60, top_n=100):
    scores = {}
    for ranked in (vector_ranked, bm25_ranked):
        for rank, doc_id in enumerate(ranked):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank + 1)
    ordered = sorted(scores.items(), key=lambda x: x[1], reverse=True)
    return [doc_id for doc_id, _ in ordered[:top_n]]


def run_example():
    # vector_ranked / bm25_ranked 均为 doc_id 列表,按各自相关性降序排列
    candidates = rrf_merge(vector_ranked, bm25_ranked)
    return candidates

经验法则:当语料里专有名词、版本号、错误码较多时,混合检索比纯向量检索稳定得多;若语料高度口语化、近义表达丰富,则可以加重向量检索的权重。RRF 里的 k 也可作为调参旋钮,k 越大,排名靠前者的优势越被稀释。

召回阶段的目标是「宁可多召回不可漏」,所以候选集可以放得宽(top 50 到 top 200),把脏活留给重排。

重排阶段:Cross-Encoder ReRank

重排是本文重点。重排模型把「查询 + 文档」作为一对输入,输出一个相关性分数。主流实现是 Cross-Encoder:查询和文档拼到一起送进 Transformer,让二者 token 在自注意力里充分交互,因此判断相关性的能力明显强于只做向量内积的 Bi-Encoder。

常见的开源与商用重排器:

  • BGE-Reranker 系列(BAAI/FlagEmbedding):如 BAAI/bge-reranker-v2-m3,多语言、轻量、推理快,适合本地部署;bge-reranker-large、bge-reranker-base 更精确但更慢。
  • Cohere Rerank:托管 API,提供 rerank-english-v3.0、rerank-multilingual-v3.0、rerank-v3.5、rerank-v4.0-pro、rerank-v4.0-fast 等,单文档上下文 4096 token,自动分块处理超长文档。

Cross-Encoder 与 Bi-Encoder 的区别

Bi-Encoder(即普通 embedding 模型)分别把查询和文档编码成两个独立向量,检索时只算一次内积,可预计算、可建索引,因此适合海量候选的召回。但它把查询和文档压缩成了固定向量,交互信息在编码时就丢失了。

Cross-Encoder 不产出独立向量,而是把两者拼成一段输入一起过模型,能捕捉细粒度词对齐和否定、转折等语义。代价是每对查询与文档都要跑一次前向,无法预计算,所以只能用在召回之后的少量候选上。

一句话:Bi-Encoder 负责「快而粗」的召回,Cross-Encoder 负责「慢而精」的重排。

如何选型

  • 追求可私有化、低延迟、多语言:优先 BGE-Reranker-v2-m3,本地 GPU 即可跑,use_fp16=True 进一步加速。
  • 不想运维模型、语料含大量非英文或 JSON 半结构化数据:用 Cohere Rerank,按质量与延迟选 pro 或 fast。
  • 候选集很大(数千)时:先 BM25 加向量混合召回压到 top 100 以内,再重排,控制 Cross-Encoder 的算力开销。
  • 需要可解释或更强的列表级排序:可尝试 RankGPT 思路(用 LLM 做列表式重排),但成本最高,仅在关键场景使用。
  • 关注延迟预算:Cross-Encoder 对每条候选都要一次前向,top 100 候选在单卡上可能要几十到几百毫秒;若接口要求亚秒级响应,应把候选压到 top 30 以内,或开启批处理推理。

递归检索与父文档检索

重排解决「召回后排序」的问题,递归检索(Recursive Retrieval)与父文档检索(Parent Document Retrieval)解决「切块粒度」的矛盾。

核心思想:用小块去检索,用大块去生成。

具体做法:把文档切成两层。子块(child chunk)很细(如 256 个 token),用于向量检索和重排,匹配精度高;父块(parent chunk)是子块的上一级大块(如 1024 到 2048 个 token),每个子块记录自己属于哪个父块。检索时命中的是子块,但真正喂给生成模型的,是它所属的整个父块。这样模型拿到的是语义完整的上下文,而不是被切碎的半句话。

一种更结构化的递归检索是把文档建成树:先按大标题切块,再逐层细分,检索时从顶层向下钻取最相关的细粒度节点。LangChain 的 ParentDocumentRetriever 与 RecursiveRetriever 即实现了这类模式。

下面是不依赖框架的父文档检索骨架。

def parent_document_retrieve(child_hits, child_to_parent, parent_store, top_k=5):
    seen = set()
    result = []
    for child_id, score in child_hits:
        parent_id = child_to_parent[child_id]
        if parent_id in seen:
            continue
        seen.add(parent_id)
        result.append((parent_id, parent_store[parent_id], score))
        if len(result) >= top_k:
            break
    return result

注意:父块也不能无限大。若父块超过模型上下文,需要进一步摘要或上下文压缩。

可运行示例

示例一:用 BGE-Reranker 做重排

先安装依赖。

pip install -U FlagEmbedding

下面用 BGE-Reranker-v2-m3 对召回候选做重排。compute_score 接受「[查询, 文档]」对的列表,返回每个对的相关性分数。

from FlagEmbedding import FlagReranker

reranker = FlagReranker('BAAI/bge-reranker-v2-m3', use_fp16=True)

query = '模型量化后精度下降怎么缓解'
candidates = [
    '量化会把权重从 FP16 压到 INT4,可能损失精度。',
    '今天天气不错,适合户外跑步。',
    '使用 AWQ 或 GPTQ 校准集,可在量化同时保持大部分精度。',
]

pairs = [[query, doc] for doc in candidates]
scores = reranker.compute_score(pairs, normalize=True)

ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
for doc, s in ranked:
    print(f'{s:.4f}  {doc}')

在真实链路里,candidates 应来自上一节的混合召回结果,而不是手写的三条。重排后取 top 5 即可送入生成。

示例二:用 Cohere Rerank

import cohere

co = cohere.Client('你的_API_密钥')

query = '模型量化后精度下降怎么缓解'
candidates = [
    '量化会把权重从 FP16 压到 INT4,可能损失精度。',
    '今天天气不错,适合户外跑步。',
    '使用 AWQ 或 GPTQ 校准集,可在量化同时保持大部分精度。',
]

response = co.rerank(
    model='rerank-multilingual-v3.0',
    query=query,
    documents=candidates,
    top_n=2,
)
for r in response.results:
    print(f'{r.relevance_score:.4f}  {candidates[r.index]}')

Cohere Rerank 会自动把超过 4096 token 的单文档分块处理,适合长文档与多语言语料。

示例三:把三阶段串起来

一个最小可运行的完整链路:混合召回得到候选,再用重排器精排,最后取父块喂给生成模型。下面用结构串起流程,关键函数复用前面示例。

def rag_pipeline(query, corpus, vector_index, bm25, reranker):
    vector_ranked = vector_index.search(query, top_n=100)
    bm25_ranked = bm25.search(query, top_n=100)
    candidates = rrf_merge(vector_ranked, bm25_ranked, top_n=80)

    pairs = [[query, corpus[cid]] for cid in candidates]
    scores = reranker.compute_score(pairs, normalize=True)
    ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
    top_ids = [cid for cid, _ in ranked[:10]]

    context = parent_document_retrieve(top_ids, child_to_parent, parent_store)
    return context

上下文压缩:重排之后的兜底

即便重排只留 top 5,单个父块仍可能很长。上下文压缩(Context Compression)在送入生成前再做一步裁剪:用较小模型生成查询相关的摘要,或按相关性抽取句子,去掉与问题无关的铺垫与免责声明。常见做法是基于重排分数做句子级抽取,或调用 LLM 压缩器。这一步是可选的,但当上下文逼近窗口上限时非常值得做。

与多模态 RAG 的衔接

当文档同时包含文字与图片(如带图表的 PDF、截图、产品图),可把图片也编码进同一向量空间,或在重排阶段把图片的 OCR 文本与说明一起作为文档内容参与打分。重排模型若是多模态 Cross-Encoder,则可同时消费文本与图像,让「查一个图表」也能被精确排序。这部分属于进阶用法,可复用本文的两阶段与递归思路,仅把文档表示从纯文本扩展到图文联合表示。

常见陷阱与评估建议

实践中容易踩的几个坑。

坑一:重排候选集太小。如果召回只取 top 10 就直接重排,重排器再强也只能在这 10 个里挑,真正相关的文档若没进候选就彻底丢失。正确做法是召回阶段放宽到 top 50 至 top 200,再把重排当作「二次筛选」。

坑二:父块过大导致上下文溢出。递归检索用大块喂模型,但父块叠加后总量仍可能超过窗口。建议对父块总长度设上限,超出的做上下文压缩,或只保留重排分数最高的几个父块。

坑三:重排分数与生成无关。重排只看查询与文档的相关性,不保证答案一定在 top 块里。因此评估不能只看排序指标,要端到端看「最终回答」的质量。

评估建议用 RAGAS 做端到端度量。下面给出一个最小评估片段:用合成问题、上下文与答案计算 context_precision 与 faithfulness。

from ragas import evaluate
from ragas.metrics import context_precision, faithfulness
from datasets import Dataset

sample = Dataset.from_dict({
    "question": ["模型量化后精度下降怎么缓解"],
    "contexts": [["使用 AWQ 或 GPTQ 校准集,可在量化同时保持大部分精度。"]],
    "answer": ["可用 AWQ 或 GPTQ 配合校准集,在量化时保留大部分精度。"],
    "ground_truth": ["使用 AWQ 或 GPTQ 校准集缓解精度下降。"],
})

result = evaluate(sample, metrics=[context_precision, faithfulness])
print(result)

context_precision 衡量检索到的上下文里相关内容是否排在前面(直接受重排影响),faithfulness 衡量生成答案是否忠于上下文。把重排前后的分数做对比,就能量化这次改动到底有没有用。

小结

长文档与多文档 RAG 的核心矛盾是切块粒度、召回噪声和上下文浪费。两阶段检索给出清晰分工:召回阶段用向量加 BM25 混合保证覆盖,重排阶段用 Cross-Encoder 精排保证精度。递归检索与父文档检索用「小块检索、大块生成」弥合切块矛盾。落地时优先本地 BGE-Reranker-v2-m3 或托管 Cohere Rerank,配合 RRF 融合与上下文压缩,即可在可控成本下显著提升长文检索的准确率。另外,重排与递归检索对中文、英文及多语言语料均适用,可按语料选择对应模型。最后别忘了用 RAGAS 等工具做量化评估,用数据驱动地调参。

参考与延伸阅读

  1. BAAI / FlagEmbedding 仓库与 BGE-Reranker 模型列表(BGE-Reranker-v2-m3、bge-reranker-large、bge-reranker-base 等),Cross-Encoder 用于重排 embedding 召回结果。链接:https://github.com/FlagOpen/FlagEmbedding —— 已核验
  2. Cohere Rerank 官方文档,模型包括 rerank-english-v3.0、rerank-multilingual-v3.0、rerank-v3.5、rerank-v4.0-pro、rerank-v4.0-fast,单文档上下文 4096 token 并自动分块。链接:https://docs.cohere.com/docs/rerank —— 已核验
  3. RAGAS 官方文档,用于系统化评估 RAG 流水线(faithfulness、answer_relevancy、context_precision、context_recall 等指标)。链接:https://docs.ragas.io/en/stable/ —— 已核验
  4. RankGPT 论文:Weiwei Sun 等,Is ChatGPT Good at Search? Investigating Large Language Models as Re-Ranking Agents,EMNLP 2023(arXiv:2304.09542),提出用 LLM 做零样本列表式重排与排列蒸馏。链接:https://arxiv.org/abs/2304.09542 —— 已核验
  5. castorini / rank_llm 仓库,实现 RankGPT、RankVicuna、RankZephyr 等 LLM 重排器,支持 gpt-4o、gpt-4o-mini 等。链接:https://github.com/castorini/rank_llm —— 已核验

待核实项:本文示例中的 Cohere SDK 返回字段结构(如 response.results)以官方最新文档为准;不同版本 FlagEmbedding 的 compute_score 参数可能略有差异,请参照所安装版本的示例。

本文累计阅读