RAG 的召回阶段解决一个问题:

面对用户问题,先从知识源中找出一批可能相关的候选内容。

召回追求的是“不要漏掉答案”。它通常返回较多候选,再交给 Reranker 精排:

flowchart LR
    A["用户问题"] --> B["召回 Top-N"]
    B --> C["重排序 Top-K"]
    C --> D["构造上下文"]
    D --> E["LLM 生成答案"]

召回不等于向量检索。关键词、数据库查询、图遍历和 API 调用,只要能取得外部资料,都可以成为 RAG 的召回方式。

关键词召回

关键词召回通过倒排索引查找包含相同词项的文档。常见实现包括全文搜索和 BM25。

BM25 会综合考虑:

  • 查询词是否出现在文档中;
  • 查询词在文档中出现的次数;
  • 查询词在整个语料库中是否罕见;
  • 文档长度。

它特别适合:

  • 错误码、产品型号和版本号;
  • API、类名、函数名和配置项;
  • 人名、文件名和专有名词;
  • 用户明确要求匹配某个词;
  • Embedding 模型没有见过的新词。

例如查询:

ORA-00942

精确匹配通常比语义相似更可靠。

关键词召回的主要问题是词汇不匹配。用户问“模型为什么越聊越慢”,文档只写“长上下文增加推理开销”时,BM25 可能无法建立联系。

稠密向量召回

稠密向量召回使用 Embedding 模型分别编码问题和文档:

系统再根据点积、余弦相似度或欧氏距离寻找最近的文档向量。

它特别适合:

  • 用户和文档表达方式不同;
  • 概念解释和自然语言问答;
  • 同义词、改写和跨语言检索;
  • 语料规模较大,需要语义搜索。

它的主要问题是精确信息可能被弱化。错误码、数字、否定关系和只有一两个字符差异的型号,不一定能被稳定区分。

向量召回还依赖:

  • Embedding 模型;
  • 文档切分方式;
  • query/document 输入模板;
  • 向量索引;
  • 相似度函数;
  • top_k

这些配置共同决定召回质量,不能只看 Embedding 模型排行榜。

具体数据库的工程取舍见 向量数据库

工程实现:LanceDB 向量距离

LanceDB 0.36.0 的普通浮点向量查询支持三种 Metric:

table.search(query_vector).metric("l2")
table.search(query_vector).metric("cosine")
table.search(query_vector).metric("dot")

不指定时默认使用 l2。查询结果保存在 _distance 字段中,三种度量都是数值越小越相似。

MetricLanceDB 返回的距离是否受向量长度影响常见用途
l2坐标差异本身有意义的向量
cosine文本 Embedding,比较向量方向
dot模型明确使用内积训练时

L2

LanceDB 的 _distance 返回平方欧氏距离:

距离为 0 表示向量完全相同,向量模长会影响结果。

Cosine

Cosine 只比较方向,不关心向量整体放大或缩小:

常见结果范围是

  • 0:方向相同;
  • 1:正交;
  • 2:方向相反。

文本 Embedding 通常优先使用 Cosine。余弦相似度可以从返回值换算:

similarity = 1 - row["_distance"]

Dot

Dot 使用向量内积。LanceDB 把它转换成距离:

因此 _distance 越小,点积越大。原始点积可以换算:

dot_product = 1 - row["_distance"]

归一化向量之间的关系

如果 Query 和 Document 都已做 L2 归一化:

那么:

L2 也满足:

所以三者对单位向量通常产生相同排序,但 _distance 的数值尺度不同,不能混用同一个阈值。

当前 Qwen/Qwen3-Embedding-8B 服务实测输出的向量范数约为 1.0。本项目仍显式使用:

.metric("cosine")

这样能直接表达“按文本语义方向比较”的意图。

实际数值对照

使用 Query [1, 0] 比较四个二维向量,LanceDB 0.36.0 实际返回:

文档向量l2cosinedot
[1, 0]0.000.000.00
[0.5, 0]0.250.000.50
[0, 1]2.001.001.00
[-1, 0]4.002.002.00

对于文本 RAG:

  1. 遵循 Embedding 模型说明;
  2. 没有特殊要求时使用 cosine
  3. 模型明确按内积训练时使用 dot
  4. 只有向量的绝对坐标差异有意义时才使用 l2

建索引和查询必须使用相同的 Metric。切换 Metric 后,原有距离阈值需要重新评测。

稀疏向量召回

稀疏向量仍然使用“词项及其权重”表示文本,但权重可以由模型学习。SPLADE 是常见代表。

它介于传统关键词和稠密向量之间:

  • 保留词项级匹配,结果更容易解释;
  • 可以扩展出原文没有出现、但语义相关的词;
  • 能继续使用倒排索引;
  • 向量维度很高,但大部分位置为零。

稀疏向量适合希望保留关键词检索特性,又需要一定语义扩展的场景。代价是建索引和推理比 BM25 更复杂。

混合召回

混合召回同时运行多种 Retriever,最常见的是:

flowchart LR
    Q["用户问题"] --> B["BM25"]
    Q --> D["Dense Embedding"]
    B --> M["合并结果"]
    D --> M

BM25 擅长精确词项,稠密向量擅长语义匹配,两者通常可以互补。

为什么不能直接相加分数?

不同 Retriever 的分数不在同一尺度上:

BM25 score:   12.7
cosine score:  0.83

直接相加没有稳定含义。常见做法是 Reciprocal Rank Fusion(RRF),只使用各结果在列表中的名次:

  • 是 Retriever 集合;
  • 是文档在某个结果列表中的名次;
  • 是用于减弱头部名次差异的常数。

RRF 不需要校准不同 Retriever 的原始分数,适合作为混合召回的起点。

如果业务中一类召回明显更重要,也可以对结果做加权融合,但权重应通过评测确定。

元数据过滤

元数据过滤不是独立的语义召回算法,但通常必须和其他召回方式一起使用。

常见过滤条件包括:

  • 租户和访问权限;
  • 产品、项目或文档类型;
  • 时间范围;
  • 语言;
  • 地区;
  • 文档版本;
  • 数据状态。

例如:

query = "如何配置日志?"
filters = {
  "product": "vLLM",
  "version": "0.10",
  "language": "zh",
}

能用确定条件表达的约束,应优先使用过滤,而不是期待 Embedding 模型从语义中猜出正确范围。

权限过滤必须在返回结果前由检索系统执行,不能只在 Prompt 中要求 LLM 忽略无权访问的内容。

多查询召回

用户问题可能过短、含糊或表达不利于检索。多查询召回先生成若干不同写法,再分别检索并合并结果:

flowchart LR
    Q["模型为什么越聊越慢?"]
    Q --> Q1["长上下文对推理延迟的影响"]
    Q --> Q2["对话 token 增加导致 decode 变慢"]
    Q --> Q3["KV Cache 随上下文增长的开销"]

它可以提高召回率,但会:

  • 增加检索次数;
  • 引入 LLM 改写延迟;
  • 召回更多噪声;
  • 让错误改写偏离原问题。

只有单次查询确实存在明显漏召回时,才需要增加多查询。

HyDE 召回

HyDE(Hypothetical Document Embeddings)先让 LLM 生成一段“假想答案”,再对假想答案计算 Embedding 并检索文档:

flowchart LR
    A["用户问题"] --> B["LLM 生成假想答案"]
    B --> C["计算假想答案向量"]
    C --> D["向量召回"]

它的直觉是:答案式文本与知识库中的答案段落,在表达形式上通常比“问题与答案”更接近。

HyDE 适合 query 很短、问题和答案表达差异较大的场景。它不保证生成的假想答案正确;假想答案只用于搜索,最终回答仍必须依据检索到的原文。

父子块召回

小 chunk 容易精确匹配,但上下文不足;大 chunk 信息完整,但向量容易混入多个主题。

父子块召回同时保留两种粒度:

flowchart TD
    P["父块:完整章节或较长段落"]
    P --> A["子块 A"]
    P --> B["子块 B"]
    P --> C["子块 C"]

检索时搜索较小的子块,命中后返回对应父块,或返回子块周围的相邻内容。

这种方式适合:

  • 文档章节较长;
  • 答案需要上下文才能理解;
  • 小块召回准确,但直接交给 LLM 信息不完整。

它不需要新的检索模型,主要依赖切分时保存父子关系。

多向量召回

普通稠密召回用一个向量代表整个 chunk。多向量召回则为一段内容保留多个向量,例如:

  • 每个 token 一个向量;
  • 标题、摘要和正文分别生成向量;
  • 为同一文档生成多个角度的问题或摘要;
  • 文本和图片分别生成向量。

ColBERT 一类模型会保留 token 级表示,并在查询时进行 late interaction。它比单向量保留更多局部信息,通常能提高细粒度匹配能力。

代价也很直接:

  • 索引更大;
  • 查询计算更多;
  • 服务和向量数据库需要支持对应的多向量检索方式。

只有单向量召回在细节匹配上出现可测量的瓶颈时,才值得增加这部分复杂度。

结构化召回

订单、库存、监控指标和权限数据具有明确结构,通常更适合 SQL、搜索条件或 API,而不是向量搜索。

flowchart LR
    A["用户问题"] --> B["识别查询意图和参数"]
    B --> C["执行受控 SQL 或 API"]
    C --> D["返回结构化结果"]
    D --> E["LLM 解释结果"]

例如“昨天退款金额最高的三个地区”应该由数据库聚合计算,而不是把订单记录切块后做相似度搜索。

结构化召回必须限制可查询的数据范围,并对生成的查询做校验。涉及生产数据库时,优先使用只读账号、预定义查询或受控查询接口。

图召回

知识图谱将知识表示为实体和关系:

flowchart LR
    A["服务 A"] -->|"DEPENDS_ON"| B["服务 B"]
    B -->|"OWNED_BY"| C["团队 C"]

图召回适合:

  • 多跳关系;
  • 依赖、归属和引用链;
  • 需要从多个实体汇总证据;
  • 问题本身包含明确关系约束。

向量搜索可以帮助找到图中的入口实体,随后再沿关系遍历。图召回不是普通问答的默认方案;只有语料中确实存在重要且稳定的关系结构时,构图成本才有价值。

层级或树召回

对于财报、标准、教材和技术手册,可以按目录、标题和摘要建立层级索引:

flowchart TD
    D["文档"] --> C1["第一章"]
    D --> C2["第二章"]
    C1 --> S11["1.1"]
    C1 --> S12["1.2"]

查询时先选择相关章节,再读取更细的节点或对应原文。它适合:

  • 长文档具有清晰结构;
  • 问题需要先判断“去哪个章节找”;
  • 需要保留章节和页码信息;
  • 单纯相似度容易召回局部相关、整体无关的段落。

层级召回可以用规则、Embedding 或 LLM 选择节点。使用 LLM 逐层推理会增加成本和延迟,不应默认开启。

召回与 Reranker 的边界

召回器需要快速扫描大量候选,优先保证相关内容能进入结果集。Reranker 只处理召回出的少量候选,使用更贵的模型判断相关性并重新排序。

flowchart LR
    A["知识库 100 万条"] --> B["Retriever 召回 50 条"]
    B --> C["Reranker 保留 5 条"]
    C --> D["LLM 生成答案"]

Reranker 无法找回召回阶段漏掉的文档。因此,排查系统时应分别观察:

  • 相关文档是否进入召回 top-N;
  • 进入后是否被 Reranker 排到前面。

不要只看最终答案来猜问题出在哪一层。

如何选择?

数据或问题特征优先尝试的召回方式
自然语言问答、同义改写稠密向量召回
错误码、型号、API 和专有名词BM25 或全文搜索
同时存在语义问题和精确词项BM25 + 稠密向量混合召回
明确的时间、权限和产品范围元数据过滤
小块命中但上下文不完整父子块召回
问题过短或表达不利于检索多查询或 HyDE
订单、指标和库存SQL 或 API
多跳实体关系图召回
有清晰目录的长文档层级或树召回
单向量丢失细粒度信息多向量召回

一个通用文本知识库可以从下面的最小方案开始:

flowchart LR
    A["Metadata Filter"] --> B["BM25 + Dense Retrieval"]
    B --> C["RRF 合并"]
    C --> D["Reranker"]

如果单独使用稠密向量已经满足召回指标,就不必为了“架构完整”增加其他 Retriever。新的召回方式只应在评测暴露出明确漏召回模式后加入。

如何评测召回?

建立由真实问题和相关文档组成的评测集:

(query, relevant_chunk_ids)

至少观察:

  • Recall@k:相关文档是否进入前 k 条;
  • MRR:第一个相关文档出现得是否足够靠前;
  • nDCG@k:存在多级相关性时,整体排序是否合理;
  • P95 延迟;
  • 单次查询成本。

调优顺序可以保持简单:

  1. 先检查相关 chunk 是否存在且切分合理;
  2. 比较 BM25 和稠密向量各自漏掉什么;
  3. 必要时增加混合召回;
  4. 再调整 top_k 和 Reranker;
  5. 只有特定问题仍然失败时,再尝试查询扩展、图或多向量。

召回方式不是越多越好。能够用最少组件稳定找回证据,才是合适的方案。