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 字段中,三种度量都是数值越小越相似。
| Metric | LanceDB 返回的距离 | 是否受向量长度影响 | 常见用途 |
|---|---|---|---|
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 实际返回:
| 文档向量 | l2 | cosine | dot |
|---|---|---|---|
[1, 0] | 0.00 | 0.00 | 0.00 |
[0.5, 0] | 0.25 | 0.00 | 0.50 |
[0, 1] | 2.00 | 1.00 | 1.00 |
[-1, 0] | 4.00 | 2.00 | 2.00 |
对于文本 RAG:
- 遵循 Embedding 模型说明;
- 没有特殊要求时使用
cosine; - 模型明确按内积训练时使用
dot; - 只有向量的绝对坐标差异有意义时才使用
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 延迟;
- 单次查询成本。
调优顺序可以保持简单:
- 先检查相关 chunk 是否存在且切分合理;
- 比较 BM25 和稠密向量各自漏掉什么;
- 必要时增加混合召回;
- 再调整
top_k和 Reranker; - 只有特定问题仍然失败时,再尝试查询扩展、图或多向量。
召回方式不是越多越好。能够用最少组件稳定找回证据,才是合适的方案。