08 RAG 全链路
在 54 条岗位里,RAG(检索增强生成)出现了 27 次,仅次于 Agent。面试问 RAG 不会停在”先检索再生成”,一定会追问:文档怎么切?向量检索和关键词检索各有什么问题?召回不准怎么排查?你怎么证明加了 RAG 效果更好?
这篇按一条完整链路讲:文档解析、切片、向量化、索引、关键词检索、混合检索、重排、查询改写、生成、评测。每一环都讲原理、取舍和常见坑,最后用我项目里”知识库没带来可测量收益”的完整实验串起来。
怎么读:
前置:06 上下文工程。
第一部分 速记页
| 问题 | 一句话答案 |
|---|---|
| RAG 是什么 | 先从外部资料里检索相关内容,放进上下文,再让模型基于这些内容生成回答 |
| 解决什么 | 模型不知道私有资料和最新信息;减少编造;答案可以带出处 |
| 和微调怎么选 | 知识会变、要出处、要权限控制用 RAG;要改变输出风格或学会某种能力才考虑微调 |
| 离线流程 | 解析文档 → 清洗 → 切片 → 向量化 → 写入索引 |
| 在线流程 | 查询改写 → 检索(关键词 / 向量 / 混合)→ 重排 → 拼进提示词 → 生成 → 引用 |
| 切片大小 | 没有通用值;太小丢上下文,太大稀释相关性;按文档结构切,再用评测调 |
| embedding | 把文字变成一串数字(向量),意思相近的文字向量方向相近 |
| 相似度 | 常用余弦相似度;向量归一化后等于点积 |
| 向量检索的弱点 | 对精确词(库名、错误码、型号)不敏感,可能把”意思像”的排到”字面对”的前面 |
| BM25 的弱点 | 只看字面重合,换个说法就找不到 |
| 混合检索 | 两路各取前 N,用 RRF 按排名融合,不需要统一分数尺度 |
| 重排(rerank) | 用交叉编码器对少量候选逐个精细打分;更准但慢得多 |
| 近似最近邻索引 | HNSW 最常用,用图结构快速找近邻,牺牲一点召回换速度 |
| 召回不准怎么查 | 先分清是没召回、召回了排得靠后、还是排前面了但模型没用 |
| 检索指标 | Recall@k、MRR、nDCG;要按查询类型分开统计 |
| 生成指标 | 忠实度(有没有超出资料编造)、答案相关性、上下文相关性 |
| Agent 一定要向量库吗 | 不一定。Claude Code 早期用过 RAG 加本地向量库,后来换成让模型自己 grep、读文件;Cursor 2025-11 的实验则显示 grep 加语义检索比只用 grep 准确率平均高 12.5%,大代码库更明显 |
| 我项目的结论 | 混合检索离线最好(R@1 45/48),但留出集上知识库没带来可测量收益,瓶颈是覆盖不足和模型很少主动查 |
第二部分 易混对照
| 容易混的两个 | 区别 | 一句话记法 |
|---|---|---|
| RAG vs 微调 | RAG 在调用时给资料;微调改模型参数 | 开卷考试 vs 回去重新学 |
| RAG vs 长上下文 | 长上下文把全部资料塞进去;RAG 只放相关的一小部分 | 整本书 vs 翻到那几页 |
| 召回 vs 精排 | 召回从海量里快速捞出几十上百个候选;精排在少量候选里精细排序 | 海选 vs 决赛 |
| 双编码器 vs 交叉编码器 | 前者查询和文档分别编码,文档向量可以预先算好;后者把两者拼一起算,更准但每次都要算 | embedding 模型 vs rerank 模型 |
| 稠密检索 vs 稀疏检索 | 稠密用 embedding 向量,每一维都有值;稀疏按词,大部分维度为 0,BM25 是典型 | 按意思找 vs 按字找 |
| 精确最近邻 vs 近似最近邻 | 精确是和每个向量都比一遍;近似(HNSW、IVF)只比一部分,快但可能漏 | 全量比 vs 抽着比 |
| 余弦相似度 vs 欧氏距离 | 余弦看方向,欧氏看绝对距离;向量归一化后两者排序一致 | 归一化后等价 |
| Recall@k vs Precision@k | Recall 看正确答案有没有被找回;Precision 看找回的里面有多少是对的 | 找全 vs 找准 |
| 检索评测 vs 端到端评测 | 前者只测检索这一层;后者测加了 RAG 后最终任务有没有变好 | 我项目两个都做了,结论不同 |
| 忠实度 vs 正确性 | 忠实度是回答有没有超出给定资料;正确性是回答对不对 | 资料错了,忠实的回答也是错的 |
第三部分 面试口述稿
3.1 “讲一下 RAG 的完整流程”
分离线和在线两部分。离线是建库:把文档解析成文本,清洗掉页眉页脚这类噪声,按结构切成片段,每个片段用 embedding 模型转成向量,连同原文和元数据写进向量库,同时也可以建一份关键词索引。
在线是查询:用户问题先做必要的改写,比如补全对话里的指代;然后检索,一般关键词和向量两路都查,用 RRF 融合;候选多的话用重排模型精排,取前几条;把这些片段连同来源拼进提示词,要求模型只根据资料回答、标注出处、资料里没有就说不知道;最后可以检查回答里的引用是否真的来自给定片段。
每一环都可能出问题,所以评测要分层做:检索层用 Recall@k 和 MRR,生成层看忠实度,最后看端到端任务效果。
3.2 “文档怎么切片?切多大?”
我不会先定一个固定长度,而是先看文档结构。有标题层级的文档按章节切,每个片段带上文档标题和章节路径,不然切出来的片段脱离上下文,比如”做法:先编译依赖”根本不知道在说哪个项目。表格、代码块尽量不从中间切断。没有结构的长文本再用固定长度加重叠。
大小是个取舍:太小,一个片段装不下完整的意思;太大,一个片段里混了多个主题,和查询的相似度被稀释,放进上下文也更费 token。最终要用检索测试集来调,比较不同切法的 Recall。
Anthropic 提过一个叫 Contextual Retrieval 的做法,给每个片段前面加一段说明它在原文里讲什么的上下文再建索引,他们的测试里检索失败率明显下降。
3.3 “向量检索和关键词检索各有什么问题?”
向量检索按意思找,用户换个说法也能找到,但对精确的词不敏感。我项目里查
fatal error: zlib.h这种带具体文件名的报错,向量检索把”找不到依赖库”排到了”头文件缺失”前面,因为语义上都在说缺 zlib。关键词检索 BM25 按字面重合打分,报错原文、错误码、库名这种查询非常准,但用户用自己的话描述症状,和文档用词不一样时就找不到。
所以实际一般做混合检索:两路各取前几十个,用 RRF 按排名融合。RRF 只看排名不看分数,不用解决两路分数尺度不同的问题。我项目 48 条查询上,BM25 的 R@1 是 43、向量 41、混合 45。
3.4 “重排有必要吗?”
看数据。重排模型是交叉编码器,把查询和每个候选拼在一起逐个打分,比 embedding 的双编码器准,但慢得多,只能用在少量候选上。
我项目里测过:混合检索加 bge-reranker 重排,总的 R@1 和不重排一样都是 45,改写类查询多对一条、没见过的变体少对一条,但每条查询从 33 毫秒变成 1126 毫秒,慢了 30 多倍,所以没用。这是因为我的知识库只有 15 条,混合检索已经排得够好。如果是几万条文档、召回候选里噪声多,重排通常更有价值。结论要靠自己的测试集跑出来,不能默认加。
3.5 “RAG 召回不准,怎么排查?”
先把问题分层定位,不要一上来就换模型。第一步看正确的片段在不在库里,很多时候是根本没收录或者解析坏了。第二步看检索结果,正确片段是完全没召回,还是召回了但排在后面。没召回的话查切片是否把关键信息切断了、查询和文档用词差异大不大,考虑加关键词检索或查询改写;排在后面就考虑重排或调融合。第三步,正确片段已经排在前面,模型却没用或者答错了,那是生成环节的问题,查提示词、片段顺序、上下文长度。
要做到这些,得有一份带标注的检索测试集,每条查询标上正确片段,并按查询类型分组统计,才能看出是哪一类查询出问题。
3.6 “怎么评测 RAG?”
分三层。检索层:准备查询和对应的正确片段,算 Recall@k、MRR,按查询类型分开看。生成层:看回答是否忠实于给定资料、是否回答了问题,可以用 LLM 当裁判,但要抽样人工校准。端到端:同一批任务,开和不开 RAG 各跑几轮,看最终任务指标。
我项目的教训是离线检索分数高不代表端到端有用。检索测试集上混合检索 R@1 有 45/48,但在 5 个没见过的项目上各跑 3 轮,开不开知识库都是 15/15 通过,步数差异在一倍标准差以内。原因是离线测试集是按库里已有条目写的,而实际任务中遇到最多的报错,库里根本没有对应经验。
3.7 “RAG 怎么降低幻觉?”
几个层面一起做。检索要准,给错资料比不给更糟。提示词明确要求只根据给定资料回答,资料不足时说不知道,并标注每句话的出处。片段带上来源和时间,资料之间冲突时让模型指出而不是自己挑一个。生成后可以做校验:检查引用的片段编号是否存在、引用的原文是否真在片段里,用另一个模型判断回答里的每个论断能否被资料支持。
但要承认 RAG 不能消除幻觉,模型仍可能曲解资料或把资料外的知识混进来,关键场景要保留人工核对。
第四部分 逐个详解
4.1 RAG 是什么,为什么需要
先说为什么会有 RAG。 2019 到 2020 年,研究者发现 BERT 这类预训练模型在训练时”记住”了大量事实,直接问它”某某出生在哪”,它常常答得上来。但知识全存在参数里有三个麻烦:想查的事实模型不一定能准确取出来;答对答错都说不出依据;世界变了,想更新知识只能重新训练。另一边,做开放域问答的系统早就在用”先检索、再阅读”的两段式做法(比如 2017 年的 DrQA:先从维基百科里找出相关段落,再让模型从段落里找答案),但检索器和生成答案的模型是分开搭的。2020 年 5 月,Facebook AI Research 的 Patrick Lewis 等人(合作方有伦敦大学学院和纽约大学)把两者合成一个模型:一个记在参数里的生成模型,加一个可以随时替换的维基百科向量索引,生成前先检索,检索到的段落作为条件一起输入,并给这种做法起名 Retrieval-Augmented Generation。他们在论文里特地演示了:分别用 2016 年 12 月和 2018 年 12 月的维基百科建索引,去问 82 位换过人的各国领导人是谁,换一份索引,答案就跟着变,不用重新训练。
官方定义:检索增强生成(Retrieval-Augmented Generation, RAG)这个名字来自 Lewis 等人 2020 年的论文 Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks,指在生成时先从外部知识源检索相关文档,把它们作为条件一起输入模型。现在工程上说的 RAG 泛指”检索 + 放进上下文 + 生成”这类做法。
打个比方:闭卷考试 vs 开卷考试。模型靠训练时记住的知识回答是闭卷,记不清就容易编;RAG 是开卷,先翻到相关的那几页再答。但开卷的前提是能翻到对的页。
模型本身有三个问题,RAG 针对的就是它们:
| 问题 | 例子 | RAG 怎么帮 |
|---|---|---|
| 不知道私有资料 | 公司内部文档、项目经验 | 把私有资料放进可检索的库 |
| 知识有截止时间 | 新版本的接口变化 | 库可以随时更新,不用重新训练 |
| 会编造且没法核对 | 编一个不存在的配置项 | 回答可以附出处,人能去核对 |
和其他方案比较:
| 方案 | 适合 | 不适合 |
|---|---|---|
| RAG | 知识经常变、需要出处、不同用户能看到的资料不同 | 需要综合整个语料库的全局问题(比如”这 1000 份报告的共同趋势”);需要改变模型的行为方式 |
| 长上下文全塞 | 资料总量不大、每次都要用 | 资料多、成本敏感;长上下文中间的信息利用变差(06 篇) |
| 微调 | 让模型学会一种输出风格、格式或特定能力 | 灌输会变化的事实知识;需要出处 |
| 工具调用查数据库 | 结构化数据的精确查询(订单、库存) | 非结构化文档 |
RAG 全链路:
flowchart TB
subgraph SG1["离线:建库"]
D1[原始文档<br/>PDF / 网页 / Markdown / 工单] --> D2[解析与清洗]
D2 --> D3[切片 + 元数据<br/>标题 / 来源 / 时间 / 权限]
D3 --> D4[embedding 模型<br/>转成向量]
D3 --> D5[分词<br/>建关键词索引]
D4 --> V[(向量索引)]
D5 --> K[(关键词索引)]
end
subgraph SG2["在线:查询"]
Q1[用户问题] --> Q2[查询改写<br/>补全指代 / 拆分 / 扩展]
Q2 --> R1[向量检索 前 N]
Q2 --> R2[关键词检索 前 N]
V --> R1
K --> R2
R1 --> F[融合 RRF]
R2 --> F
F --> RR[重排 取前 k]
RR --> P[拼提示词<br/>片段 + 来源 + 回答规则]
P --> G[模型生成]
G --> C[引用校验]
C --> A[回答 + 出处]
end
下面按顺序讲每一环。
4.2 文档解析与清洗
这一步最不起眼,但很多 RAG 效果差的根源在这里:解析错了,后面怎么调都没用。
| 文档类型 | 常见问题 | 处理 |
|---|---|---|
| 双栏排版读成交错的句子;页眉页脚、页码混进正文;表格变成一串乱序数字 | 用版面分析工具;去掉重复的页眉页脚;表格单独提取成结构化文本 | |
| 扫描件、图片 | 没有文字层 | OCR(光学字符识别);多模态模型识别 |
| 网页 | 导航栏、广告、评论区 | 正文提取 |
| Word、PPT | 格式信息丢失、图片里的文字 | 保留标题层级;图片单独处理 |
| 代码 | 按字符切会切断函数 | 按函数、类切 |
| 表格数据 | 行列关系丢失 | 每行转成”列名: 值”的文本,或者不走 RAG,用工具查数据库 |
清洗后一定要抽样人工看。随机抽 20 个片段读一遍,比调半天参数更早发现问题。
元数据(metadata) 要在这一步保留:文档标题、章节路径、来源链接、更新时间、所属部门或权限。检索时可以用来过滤(只查某个产品线的文档、只查当前用户有权限的),生成时用来标注出处。
权限是企业 RAG 必考的点:用户 A 不能通过问问题检索到只有用户 B 能看的文档。做法是检索时按用户权限过滤元数据,在检索层过滤,而不是让模型”不要说出来”。
4.3 切片(Chunking)
为什么要切:embedding 模型有输入长度上限(bge-m3 最长 8192 token);整篇文档一个向量太粗,和具体问题的相似度被无关内容稀释;放进上下文的也只应该是相关的部分。
动手实验 1:对比两种切法。只用标准库,保存为 chunk_demo.py。
import re
DOC = """# libpng 交叉编译记录
## 依赖
libpng 需要 zlib。目标平台的 sysroot 里没有 zlib,主机上的 zlib 不能用。
## 做法
先把 zlib 源码克隆到项目目录,用同一个 toolchain 编译并安装到 deps/zlib。
然后 configure libpng 时传 -DZLIB_ROOT=deps/zlib。
## 常见报错
Could NOT find ZLIB:说明没有指定 ZLIB_ROOT,或者 zlib 没有安装成功。
"""
def fixed_chunks(text, size=40, overlap=10):
chunks, start = [], 0
while start < len(text):
chunks.append(text[start:start + size])
start += size - overlap
return chunks
def heading_chunks(text):
parts = re.split(r"(?m)^(?=## )", text)
title = parts[0].strip().lstrip("# ").strip()
return [f"[{title}] {p.strip()}" for p in parts[1:]]
print("== 固定长度 40 字符,重叠 10")
for i, c in enumerate(fixed_chunks(DOC)):
print(f"{i}: {c!r}")
print("\n== 按二级标题切分,并补上文档标题")
for i, c in enumerate(heading_chunks(DOC)):
print(f"{i}: {c!r}")
实际输出:
== 固定长度 40 字符,重叠 10
0: '# libpng 交叉编译记录\n\n## 依赖\nlibpng 需要 zlib。目标'
1: '需要 zlib。目标平台的 sysroot 里没有 zlib,主机上的 zlib'
2: ',主机上的 zlib 不能用。\n\n## 做法\n先把 zlib 源码克隆到项目目录'
3: ' 源码克隆到项目目录,用同一个 toolchain 编译并安装到 deps/zl'
4: '装到 deps/zlib。\n然后 configure libpng 时传 -DZ'
5: 'png 时传 -DZLIB_ROOT=deps/zlib。\n\n## 常见报错\nC'
6: '\n## 常见报错\nCould NOT find ZLIB:说明没有指定 ZLIB'
7: '明没有指定 ZLIB_ROOT,或者 zlib 没有安装成功。\n'
8: '。\n'
== 按二级标题切分,并补上文档标题
0: '[libpng 交叉编译记录] ## 依赖\nlibpng 需要 zlib。目标平台的 sysroot 里没有 zlib,主机上的 zlib 不能用。'
1: '[libpng 交叉编译记录] ## 做法\n先把 zlib 源码克隆到项目目录,用同一个 toolchain 编译并安装到 deps/zlib。\n然后 configure libpng 时传 -DZLIB_ROOT=deps/zlib。'
2: '[libpng 交叉编译记录] ## 常见报错\nCould NOT find ZLIB:说明没有指定 ZLIB_ROOT,或者 zlib 没有安装成功。'
逐段讲。
① 固定长度切分 fixed_chunks。
text[start:start + size]:字符串切片,从start开始取size个字符start += size - overlap:下一片从”本片末尾往回 10 个字符”处开始,这就是重叠(overlap)。重叠是为了减少一句话刚好被切在边界上、两边都不完整的情况while start < len(text):直到起点超出文本长度
看结果:片段 4 和 5 把 -DZLIB_ROOT=deps/zlib 切成了两半;片段 3 里”源码克隆到项目目录”不知道是什么的源码;片段 8 只剩一个句号。固定长度完全不管语义。
② 按标题切分 heading_chunks。
re.split(模式, text):用正则表达式(regular expression,描述文本模式的小语言)拆分字符串r"(?m)^(?=## )":拆开来读- 字符串前的
r表示原始字符串(raw string),反斜杠不转义,写正则时常用 (?m)多行模式,让^匹配每一行的开头,而不只是整个字符串的开头^行首(?=## )叫前瞻(lookahead):要求后面跟着##,但不把它吃掉,所以拆分后## 依赖这几个字还留在片段里
- 字符串前的
parts[0]是第一个二级标题之前的内容,也就是# libpng 交叉编译记录;.strip()去掉首尾空白,.lstrip("# ")去掉左边的#和空格,得到标题- 每个片段前面加上
[文档标题]
结果是三个完整的片段,每个都知道自己属于”libpng 交叉编译记录”。
切片策略总结:
| 策略 | 做法 | 适合 | 注意 |
|---|---|---|---|
| 固定长度 + 重叠 | 按字符或 token 数切,相邻片段重叠一部分 | 没有结构的长文本 | 会切断句子和语义;重叠增加存储 |
| 按句子 / 段落 | 在句号、空行处切,再合并到目标长度 | 普通文章 | 段落长短差异大 |
| 按文档结构 | 按标题、章节、函数切 | Markdown、手册、代码 | 有的章节很长,还要再切 |
| 语义切分 | 相邻句子 embedding 相似度突然下降处切 | 主题转换多的长文 | 要额外算 embedding,效果不一定比结构切分好 |
| 父子片段 | 用小片段检索(准),命中后把所在的大段落放进上下文(全) | 需要精确定位又需要完整上下文 | 要维护两级索引 |
| 一条经验一个片段 | 知识本身就是独立条目 | FAQ、经验库 | 我项目就是这种,15 条经验各是一个检索单元 |
给片段补上下文。 切片后的片段丢失了”它在讲什么”。Anthropic 在 2024 年 9 月发布的 Contextual Retrieval 做法是:用模型给每个片段生成一小段说明(这个片段来自哪份文档、讲的是哪部分),拼在片段前面再做 embedding 和 BM25 索引。他们报告的结果是:只用上下文化的 embedding 检索失败率降低 35%,加上上下文化的 BM25 降低 49%,再加重排降低 67%。实验 1 里给片段加 [文档标题] 是最简单的版本。代价是建库时每个片段多一次模型调用。
切片大小怎么定:没有通用答案。常见的起点是几百 token,然后用检索测试集比较不同大小的 Recall@k。要同时考虑 embedding 模型的长度上限和放进上下文的 token 预算。
4.4 embedding:把文字变成向量
先说为什么会有 embedding。 传统搜索按字面匹配:查”找不到依赖库”,文档里写的是”缺少 zlib 这个包”,一个词都对不上,就搜不出来。要让程序知道这两句是一个意思,得先把”意思”变成能计算的东西。2013 年,Google 的 Tomas Mikolov 等人发布了 word2vec,用神经网络从大量文本里给每个词学出一个向量,经常出现在相似上下文里的词,向量就靠得近;他们在 16 亿词的语料上不到一天就训练完。但词向量只管单个词,一句话的意思不等于词向量的简单相加。
2018 年的 BERT 能很好地理解整句话,可它判断两句话像不像,要把两句拼在一起送进模型跑一遍。2019 年,德国达姆施塔特工业大学的 Nils Reimers 和 Iryna Gurevych 在 Sentence-BERT 里算过一笔账:在 1 万个句子里找最相似的一对,BERT 要做约 5000 万次推理,大约 65 小时。他们改成让模型把每句话单独编码成一个向量,句子之间只比向量,同样的事约 5 秒完成,准确率和 BERT 差不多。今天 RAG 用的 embedding 模型,基本都是这个”每段文字单独编码成一个向量”的路子。
官方定义:嵌入(embedding)是把文本映射为一个固定长度的数字向量(vector)的过程。训练目标让语义相近的文本得到方向相近的向量,因此可以用向量之间的相似度来衡量文本语义的相似度。
打个比方:给每段文字在一个几百上千维的空间里安排一个位置。“找不到依赖库”和”缺少 zlib 这个包”被放得很近,“链接时架构不匹配”放得远一点。检索就是”找离问题最近的几个点”。
余弦相似度(cosine similarity):衡量两个向量方向有多接近,取值 -1 到 1,越大越相似。
cos(a, b) = (a · b) / (|a| × |b|)
a · b是点积:对应位置相乘再相加|a|是向量长度- 如果先把向量归一化(normalize,长度缩放成 1),余弦相似度就等于点积,计算最快。所以很多向量库和 embedding 模型默认输出归一化向量
动手实验 2:用 bge-m3 看真实的相似度。需要 pip install sentence-transformers,模型约 2.2GB,第一次运行会下载(国内网络慢可以先从 ModelScope 下载到本地,把路径作为参数传进去)。保存为 embed_demo.py,运行 python embed_demo.py BAAI/bge-m3 或 python embed_demo.py 本地模型路径。
import sys
from sentence_transformers import SentenceTransformer
model = SentenceTransformer(sys.argv[1])
docs = {
"找不到依赖库": "find_package 找不到依赖库,目标平台 sysroot 里没有第三方库",
"头文件缺失": "fatal error: xxx.h file not found,编译时找不到头文件",
"架构不匹配": "链接时 file format not recognized,混入了主机架构的库",
}
queries = [
"配置的时候说缺少 zlib 这个包",
"fatal error: zlib.h: No such file or directory",
]
doc_vecs = model.encode(list(docs.values()), normalize_embeddings=True)
query_vecs = model.encode(queries, normalize_embeddings=True)
print("向量维度:", doc_vecs.shape[1])
for q, qv in zip(queries, query_vecs):
scores = doc_vecs @ qv
ranked = sorted(zip(docs, scores), key=lambda x: -x[1])
print(q)
for title, s in ranked:
print(f" {s:.3f} {title}")
实际输出(本地 bge-m3,省略了模型加载的进度条):
向量维度: 1024
配置的时候说缺少 zlib 这个包
0.643 找不到依赖库
0.590 架构不匹配
0.568 头文件缺失
fatal error: zlib.h: No such file or directory
0.702 头文件缺失
0.546 找不到依赖库
0.508 架构不匹配
逐段讲。
sys.argv[1]:sys.argv是命令行参数列表,[0]是脚本名,[1]是第一个参数SentenceTransformer(路径或名字):加载一个 embedding 模型model.encode(文本列表, normalize_embeddings=True):把一批文本编码成向量并归一化。返回的是 NumPy 数组(NumPy 是 Python 做数值计算的库),形状是”文本数 × 维度”,.shape[1]取维度,bge-m3 是 1024 维list(docs.values()):取字典所有的值组成列表zip(queries, query_vecs):把两个列表按位置配对,每次循环同时拿到查询文本和它的向量doc_vecs @ qv:@是矩阵乘法运算符。3×1024 的矩阵乘以 1024 维向量,一次得到 3 个点积,因为已经归一化,就是 3 个余弦相似度sorted(zip(docs, scores), key=lambda x: -x[1]):zip(docs, scores)把字典的键(标题)和分数配对;key=lambda x: -x[1]按分数的相反数排序,也就是从高到低
能看出三件事:
- 第一条查询没有出现”依赖库""find_package”任何字眼,向量检索依然把”找不到依赖库”排在第一。这是向量检索的长处:按意思找
- 分数差距很小(0.643 vs 0.590)。余弦相似度的绝对值不是概率,不能说”0.6 以上就相关”,不同模型的分数分布也不同。设相似度门槛要用测试集校准
- 第二条带明确文件名的报错,这次排对了。但在我项目的完整测试里,一条类似的查询
fatal error: zlib.h: No such file or directory cross compiling被向量检索排成了”找不到依赖库”第一、“头文件缺失”第二,因为库里的”找不到依赖库”条目恰好提到了zlib.h。向量检索对精确词的把握不稳定
embedding 模型怎么选:
| 考虑 | 说明 |
|---|---|
| 语言 | 中文场景选中文或多语言模型,比如 BGE 系列、通义、智谱的 embedding |
| 长度上限 | 片段不能超过模型上限,超出会被截断 |
| 维度 | 维度越高存储和计算越多,不一定越准 |
| 部署方式 | 本地模型(数据不出内网、要 GPU 或耐心)还是调用 API(按量付费) |
| 实际效果 | 公开排行榜(如 MTEB)只能参考,用自己的数据测 |
我项目用的 bge-m3(北京智源,2024):支持 100 多种语言,输入最长 8192 token,一个模型同时支持稠密、稀疏和多向量三种检索方式(项目里只用了稠密向量)。
换 embedding 模型要重建整个索引:不同模型的向量不能混在一起比。我项目的 DenseKnowledge 用”模型名 + 所有条目文本”算一个哈希值作为 collection 名,模型或条目一变就自动重建。
4.5 向量索引和向量数据库
有了向量,怎么在几百万个向量里快速找到最近的几个?
先说为什么会有向量数据库。 MySQL 这类传统数据库的索引(比如 B+ 树)擅长回答”id 等于 42""价格在 10 到 20 之间”,靠的是数值可以排序。可”和这个 1024 维向量最像的 10 个”没法靠排序回答,只能一个个算相似度。图片检索、推荐系统很早就碰到这个问题:要在上亿甚至十亿级的向量里找相似的,逐个比较根本来不及。2017 年 3 月,Facebook AI Research 开源了 FAISS(论文作者 Jeff Johnson、Matthijs Douze、Hervé Jégou),用近似搜索加 GPU,在 10 亿级向量上做最近邻搜索,比之前报告的最好结果快约 8.5 倍。但 FAISS 是一个库,不管数据怎么增删改、怎么持久化、怎么多台机器分摊。于是出现了专门的向量数据库,比如 Zilliz 开发的 Milvus 在 2019 年开源,把向量索引、增删改查、按字段过滤、分布式部署包成一个服务。后来 PostgreSQL(pgvector 扩展)、Elasticsearch 这些老牌系统也加上了向量检索。
精确搜索(暴力搜索):和每个向量都算一遍相似度。100 万个 1024 维向量,每次查询要做 100 万次点积。数据量小时(几万以内)完全够用,而且结果最准。我项目只有 15 条,其实暴力搜索就够了。
近似最近邻(Approximate Nearest Neighbor, ANN):只和一部分向量比较,速度快几个数量级,代价是可能漏掉一些真正的近邻。两种主流索引:
flowchart LR
subgraph IVF["IVF:先分桶再找"]
direction TB
Q1((查询)) --> C1[找最近的几个桶中心]
C1 --> B1[只在这几个桶里<br/>逐个比较]
end
subgraph HNSW["HNSW:多层图上跳着找"]
direction TB
L2[顶层:点很少<br/>大步跳到大致区域] --> L1[中间层:点多一些<br/>缩小范围]
L1 --> L0[底层:全部点<br/>在邻居里精细找]
end
| 索引 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 暴力(FLAT) | 全部比较 | 100% 准确 | 数据多了慢 |
| IVF | 先聚类成若干个桶,查询时只搜最近的几个桶 | 内存省、建索引快 | 桶边界附近的点容易漏 |
| HNSW | 建多层”邻居图”,从顶层稀疏图跳到底层稠密图 | 查询快、召回高,最常用 | 占内存多,建索引慢 |
| 量化(PQ 等) | 把向量压缩存储 | 大幅省内存 | 精度下降 |
HNSW 出自 Malkov 和 Yashunin 的论文(arXiv 1603.09320)。面试能说清”分层图、从上往下逐层逼近”就够了。参数上要知道:建索引时每个点连多少邻居、查询时搜索多宽,调大更准但更慢更占内存。
向量数据库怎么选:
| 方案 | 特点 | 适合 |
|---|---|---|
| FAISS | Meta 开源的向量检索库,不是数据库,没有增删改查的服务 | 离线、嵌入到程序里、研究 |
| Chroma | 轻量、开发体验好 | 原型和小项目 |
| Milvus / Milvus Lite | 专门的向量数据库,支持分布式;Lite 版是本地文件 | Lite 做开发,完整版做大规模生产。我项目用的 Lite |
| pgvector | PostgreSQL 的扩展 | 已经在用 PostgreSQL,数据量中等,想和业务数据放一起 |
| Elasticsearch / OpenSearch | 老牌搜索引擎,支持向量和 BM25 | 已经有搜索平台,需要混合检索和复杂过滤 |
| Qdrant、Weaviate 等 | 专门的向量数据库 | 按团队熟悉程度和部署需求选 |
面试时说选型理由比说出名字重要:数据规模、是否已有基础设施、是否需要混合检索和元数据过滤、运维能力。
我项目踩的 Milvus Lite 坑:新建 collection 时会自动加载到内存;但第二次启动进程、打开已存在的 collection 时,它是”已释放”状态,不先调用 load_collection 就搜不了。第一次跑评测是新建的所以没暴露,第二次启动就崩了。修复后在 tests/test_knowledge.py 里用两个子进程复现了这个场景。这类”第二次启动才出现”的问题,单进程测试发现不了。
4.6 BM25:关键词检索的标准做法
先说为什么会有 BM25。 最朴素的关键词检索是”数一数查询里的词在文档里出现了几次”。这马上会出问题:查”zlib 的 error”,“的”和”error”在几乎每篇文档里都有,真正有区分度的”zlib”反而被淹没;一篇很长的文档什么词都沾一点,总能凑出高分。1972 年,英国剑桥的 Karen Spärck Jones 提出,一个词出现在越少的文档里,它就越能说明问题,这就是 IDF(逆文档频率)的来源。之后伦敦城市大学的 Stephen Robertson 和同事在 Okapi 检索系统上反复试验打分公式,把词频饱和、文档长度修正也加了进去,BM25 里的 BM 是 Best Matching(最佳匹配),25 是他们试过的一系列公式里的编号,1994 年在美国的 TREC-3 检索评测上正式用出来,之后成了关键词检索的标准做法。
官方定义:BM25(Best Matching 25)是信息检索里经典的关键词相关性打分函数,Elasticsearch、Lucene 等搜索引擎的默认排序算法都基于它。它根据查询里的词在文档中出现的情况给文档打分。
它的直觉有三条:
- 词在这篇文档里出现得越多,越相关,但增长会饱和(出现 10 次不比 5 次相关两倍)
- 越稀有的词越重要:
ZLIB_ROOT只在一两篇文档里出现,命中它比命中”的""error”有价值得多 - 长文档要打折:长文档天然包含更多词,不能因为长就占便宜
我项目 agent/knowledge.py 里的实现:
def tokenize(s):
return re.findall(r"[a-zA-Z_][a-zA-Z0-9_+\-.]*|[一-鿿]", s.lower())
def _score(self, query_tokens, i, k1=1.5, b=0.75):
freq = Counter(self.docs[i])
s = 0.0
for w in query_tokens:
if w not in freq:
continue
idf = math.log((self.N - self.df[w] + 0.5) / (self.df[w] + 0.5) + 1)
tf = freq[w]
s += idf * tf * (k1 + 1) / (tf + k1 * (1 - b + b * len(self.docs[i]) / self.avgdl))
return s
逐行讲。
分词 tokenize:
re.findall(模式, 字符串):找出所有匹配的片段,返回列表[a-zA-Z_][a-zA-Z0-9_+\-.]*:以字母或下划线开头,后面跟任意个字母、数字、下划线、加号、减号、点。这样ZLIB_ROOT、c++、libpng.so都能作为一个整体|表示”或者”[一-鿿]:一个中文字符(Unicode 里常用汉字所在的范围)。中文按单字切,没有用分词词典s.lower():先转小写,ZLIB和zlib当成同一个词
打分 _score,对查询里的每个词 w:
Counter(self.docs[i]):Counter是collections模块里的计数器,统计第 i 篇文档里每个词出现几次self.N:文档总数;self.df[w]:包含词 w 的文档数(document frequency)idf = log((N - df + 0.5) / (df + 0.5) + 1):逆文档频率(inverse document frequency)。df 越小(词越稀有),idf 越大tf = freq[w]:词频(term frequency),这个词在本文档出现几次- 最后一行是 BM25 的核心:
tf * (k1 + 1) / (tf + k1 * ...):tf 越大分数越高,但分母里也有 tf,所以会饱和。k1控制饱和速度,常用 1.2 到 2(1 - b + b * 文档长度 / 平均长度):长度归一化。文档比平均长,分母变大,分数打折。b控制打折力度,0 表示不打折,常用 0.75
- 所有查询词的得分相加
为什么中文单字切分在我项目里也管用? REPORT 里记录,BM25 在”改写描述”类查询上也有 13/17 的 R@1,因为按单字切,意思相近的中文句子很容易有字重合(“找不到""依赖""库”)。但单字切分会带来大量无意义的匹配,文档一多就会变差。正式中文场景一般用分词工具(如 jieba)或者搜索引擎自带的中文分词器。
BM25 的长处和短处:
| 长处 | 短处 |
|---|---|
| 精确词(错误码、函数名、型号)非常准 | 同义词、换说法就找不到 |
| 不需要模型,毫秒级,我项目每条查询 0.1 毫秒 | 不理解语义,“不能用”和”能用”几乎一样 |
| 结果可解释:因为命中了哪几个词 | 中文效果依赖分词质量 |
4.7 混合检索和 RRF
既然向量和 BM25 各有长短,就两路都查,再合并结果。问题是两路的分数没法直接加:BM25 分数可能是 0 到 20,余弦相似度是 0 到 1,而且分布完全不同。
倒数排名融合(Reciprocal Rank Fusion, RRF) 只看排名,不看分数。出自 Cormack 等人 2009 年的 SIGIR 论文。公式:
RRF(文档 d) = Σ 1 / (k + d 在第 i 路结果里的排名)
对每一路结果,文档排第 1 名得 1/(k+1),第 2 名得 1/(k+2)……所有路的得分加起来排序。k 通常取 60,作用是让排名靠前和靠后的差距不要太悬殊。
flowchart LR
Q[查询] --> B[BM25<br/>1. k004<br/>2. k002<br/>3. k010]
Q --> V[向量<br/>1. k002<br/>2. k010<br/>3. k004]
B --> F["RRF,k=60<br/>k002: 1/62 + 1/61 = 0.03252<br/>k004: 1/61 + 1/63 = 0.03227<br/>k010: 1/63 + 1/62 = 0.03200"]
V --> F
F --> R[1. k002<br/>2. k004<br/>3. k010]
k002 在两路里都排得靠前(第 2 和第 1),融合后排第一;k004 虽然在 BM25 里是第一,但在向量里是第三,融合后第二。两路都认可的文档会被推到前面。
我项目 HybridKnowledge.search 的实现就是这个公式,两路各取前 10 名(pool=10),RRF_K = 60。4.11 节的实验 3 里有一个独立的 rrf 函数可以跑。
混合检索的其他做法:分数归一化后加权求和(要调权重,而且对分数分布敏感);有些向量库和搜索引擎内置了混合检索。RRF 的优点是不用调参、不怕分数尺度不同,是最稳妥的起点。
4.8 重排(Rerank)
先说为什么会有”先召回、再重排”两段式。 搜索引擎很早就是这样做的:先用便宜的方法(比如 BM25)从海量文档里捞出几百到几千条候选,再用更贵、更准的模型只给这些候选重新排序,因为贵的模型不可能对所有文档都跑一遍。BERT 出来以后,这个第二段的效果跳了一大截。2019 年 1 月,纽约大学的 Rodrigo Nogueira 和 Kyunghyun Cho 发表 Passage Re-ranking with BERT,把查询和段落拼在一起送进 BERT 打相关性分,在微软的 MS MARCO 段落排序榜上排第一,MRR@10(第一个相关结果排得越靠前分越高)比之前最好的结果相对提高了 27%。现在说的交叉编码器重排模型,就是这个思路。
官方定义:重排是检索的第二阶段,用一个更精确但更慢的模型,对第一阶段召回的少量候选重新打分排序。常用的是交叉编码器(cross-encoder)模型。
为什么 embedding 不够准:embedding 模型是双编码器(bi-encoder),查询和文档分别编码成向量再比较,文档向量可以提前算好存起来,所以快。但查询和文档在编码时”互相看不见”,细节上的对应关系抓不住。
交叉编码器把查询和文档拼在一起输入模型,模型可以逐词对照两者,直接输出一个相关性分数。更准,但每个”查询 + 文档”组合都要完整跑一次模型,没法预先计算。
flowchart TB
subgraph SG3["双编码器:embedding 模型"]
direction LR
A1[查询] --> E1[编码器] --> V1[查询向量]
A2[文档] --> E2[编码器<br/>可以提前算好] --> V2[文档向量]
V1 --> S1[点积]
V2 --> S1
end
subgraph SG4["交叉编码器:rerank 模型"]
direction LR
B1["[查询] + [文档] 拼在一起"] --> E3[编码器<br/>查询和文档逐词互相关注] --> S2[相关性分数]
end
所以典型流程是两阶段:向量或混合检索从海量文档里召回 50 到 100 个候选(快),重排模型对这些候选精排,取前 5 个(准)。
我项目的实测(48 条查询,15 个条目,bge-reranker-v2-m3 对混合检索的候选重排):
| 方式 | 全部 R@1 | 改写描述 R@1 | 没见过的变体 R@1 | MRR | 每条查询 |
|---|---|---|---|---|---|
| 混合 | 45/48 | 14/17 | 10/10 | 0.958 | 33 毫秒 |
| 混合 + 重排 | 45/48 | 15/17 | 9/10 | 0.951 | 1126 毫秒 |
重排后改写类多对 1 条、变体类少对 1 条,总分一样,MRR 还略低,每条查询慢了 30 多倍。结论是在这个项目里不值得加。
但这个结论有明确的适用范围:知识库只有 15 条,混合检索的前 3 名里基本都有正确答案,重排能改进的空间很小。文档量大、召回候选噪声多的场景,重排通常有明显收益。要不要重排,用自己的测试集跑一遍再决定。
4.9 查询改写
用户的原始问题常常不适合直接拿去检索。几种常见改写:
| 方法 | 做法 | 解决什么 |
|---|---|---|
| 指代补全 | 多轮对话里”那它怎么装?“改写成”zlib 交叉编译后怎么安装” | 查询里缺主语 |
| 多查询(Multi-Query) | 让模型把一个问题改写成 3 到 5 个不同说法,分别检索再合并 | 用户用词和文档用词不一致 |
| 问题分解 | ”libpng 和 re2 编译时依赖处理有什么不同”拆成两个子问题分别检索 | 一个问题涉及多个主题 |
| HyDE | 先让模型写一个”假想的答案”,用这个答案去做向量检索 | 问题很短、和文档的表达形式差别大 |
| 关键词提取 | 从长报错日志里提取关键行再检索 | 查询太长、噪声太多 |
| 回退提问 | 先问更抽象的问题(“交叉编译时依赖库一般怎么处理”)检索背景知识 | 具体问题直接检索不到 |
HyDE(Hypothetical Document Embeddings)出自 Gao 等人 2022 年的论文 Precise Zero-Shot Dense Retrieval without Relevance Labels。思路是:问题和答案的文本形式差别很大,但”假想答案”和”真实答案文档”的形式相近,所以用假想答案的向量去找更容易找到。假想答案里的具体事实可能是编的,没关系,只用它来检索。
代价:每种改写都多一次模型调用,增加延迟和成本;改写可能偏离原意。一般先不改写,看测试集上哪一类查询失败,再针对性地加。
我项目里对应的做法:Agent 调 search_knowledge 时,工具描述要求”把报错的关键行作为查询”,相当于让模型自己做了关键词提取。errors.py 的 extract_details 也能从日志里提取错误行,但没有用在检索上。
4.10 生成:把检索结果用好
检索到了正确片段,模型也不一定用对。提示词的常见结构:
系统提示词:
你是交叉编译助手。根据<资料>回答问题。
规则:
1. 只使用资料中的信息;资料不足以回答时,明确说"资料中没有相关信息"
2. 每个结论后用 [编号] 标注出处
3. 资料之间有冲突时,指出冲突,不要自行选择
<资料>
[k002] find_package 找不到依赖库(来源:runs:14,140;更新:2026-09-13)
成因:...
处理:...
[k010] 不要用主机上的库和头文件(来源:manual)
...
</资料>
用户问题:Could NOT find ZLIB 怎么办?
要点:
| 要点 | 原因 |
|---|---|
| 资料用明显的标签包起来 | 让模型分清哪些是资料、哪些是指令;也降低资料里藏着的恶意指令被执行的风险(11 安全篇) |
| 每个片段带编号和来源 | 方便引用和核对 |
| 最相关的放在开头或结尾 | 长上下文中间位置利用最差(06 篇 Lost in the Middle) |
| 片段数量适中 | 放太多无关片段会干扰;我项目取前 3 条 |
| 允许说”不知道” | 否则模型倾向于用自己的知识补全,产生幻觉 |
| 设相关度门槛 | 最高分都很低时,宁可不给资料 |
我项目 Knowledge.format 在检索结果为空时返回”知识库里没有匹配的条目,按你自己的判断处理”,BM25 模式还有 min_score=1.0 的门槛。但向量和混合模式没有门槛,总会返回前 3 条,即使都不相关。留出集评测里 libarchive 的链接错误就是这样拿到了无关条目。
生成后的校验:
- 引用校验:回答里的
[k002]是否在给定的资料里;引用的原文是否真的出现在片段中 - 忠实度检查:用另一个模型逐句判断”这句话能否被资料支持”
- 拒答检查:资料不相关时是否正确地说了不知道
4.11 评测:分层测,分类看
检索层指标:
| 指标 | 定义 | 例子 |
|---|---|---|
| Recall@k | 正确答案出现在前 k 个结果里的查询比例 | 48 条查询里 45 条的正确答案排第 1,R@1 = 45/48 |
| Precision@k | 前 k 个结果里正确答案所占比例 | 前 3 个里有 1 个对的,P@3 = 1/3 |
| MRR(平均倒数排名) | 每条查询取”第一个正确答案排名的倒数”,再求平均 | 排第 1 得 1,排第 2 得 0.5,排第 3 得 0.33,没找到得 0 |
| nDCG | 考虑多个相关文档和相关程度的排序质量 | 有分级标注(非常相关 / 部分相关)时用 |
动手实验 3:用我项目的 BM25 实现和 48 条测试集,复现 REPORT 里的数字。需要有项目代码,把项目根目录作为参数。保存为 retrieval_metrics.py,运行 python3 retrieval_metrics.py projects/crossbuild-agent。
import json
import sys
from pathlib import Path
PROJECT = Path(sys.argv[1])
sys.path.insert(0, str(PROJECT / "agent"))
import knowledge
def rank_of(ids, gold):
for position, doc_id in enumerate(ids, start=1):
if doc_id in gold:
return position
return None
def rrf(rankings, k=60):
scores = {}
for ranking in rankings:
for position, doc_id in enumerate(ranking, start=1):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + position)
return sorted(scores, key=scores.get, reverse=True)
rows = [json.loads(line) for line in (PROJECT / "eval" / "retrieval_testset.jsonl").read_text().splitlines()]
kb = knowledge.Knowledge()
hit1 = hit3 = 0
mrr = 0.0
for row in rows:
gold = row["gold"] if isinstance(row["gold"], list) else [row["gold"]]
ids = [entry["id"] for _, entry in kb.search(row["q"], topk=3)]
r = rank_of(ids, gold)
hit1 += r == 1
hit3 += r is not None
mrr += 1 / r if r else 0
n = len(rows)
print(f"BM25 on {n} queries: R@1 {hit1}/{n} R@3 {hit3}/{n} MRR {mrr / n:.3f}")
bm25_list = ["k004", "k002", "k010"]
dense_list = ["k002", "k010", "k004"]
print("RRF 融合:", rrf([bm25_list, dense_list]))
实际输出:
BM25 on 48 queries: R@1 43/48 R@3 47/48 MRR 0.934
RRF 融合: ['k002', 'k004', 'k010']
和 REPORT 里 BM25 那一行完全一致。
逐段讲。
sys.path.insert(0, 路径):sys.path是 Python 查找模块的目录列表,插到最前面,后面的import knowledge才能找到项目里的knowledge.pyimport knowledge只用到 BM25 的Knowledge类,不需要装向量相关的库,因为项目里DenseKnowledge把pymilvus等的导入写在了类的初始化方法内部,不用它就不会导入rank_of(ids, gold):返回第一个正确答案的排名,没有返回None。gold是列表,因为有 5 条查询同时对应两个条目都算对hit1 += r == 1:r == 1的结果是 True 或 False,在加法里 True 当 1、False 当 0hit3 += r is not None:前 3 名里有正确答案就加 1mrr += 1 / r if r else 0:条件表达式,r为None时加 0rrf:scores.get(doc_id, 0)取已有分数(没有就是 0);sorted(scores, key=scores.get, reverse=True)对字典的键按值从大到小排序,key=scores.get表示”用这个函数算每个键的排序依据”
测试集怎么建,比算指标更重要。 我项目的检索测试集经历了一次重要修正:
- 第一版只有 15 条查询,几乎全是报错原文。BM25 按关键词匹配天然占优,R@1 14/15,看起来检索没有问题
- 扩到 48 条,分成四类分开统计:
| 类别 | 条数 | 来源 | 为什么要有这一类 |
|---|---|---|---|
| 原始 | 15 | 第一版人工标注 | 对照 |
| 见过的报错 | 6 | 运行记录里新增条目对应的报错原文 | 条目的匹配模式就是从这些报错里摘的,偏乐观,只作对照 |
| 改写描述 | 17 | 人工写的中文症状描述,刻意不用报错原文里的词 | 测语义检索能力 |
| 没见过的变体 | 10 | 同类问题换一种报错写法(gcc / clang、x86 / aarch64、不同措辞) | 测泛化能力 |
四种检索方式的完整结果(15 个条目):
| 方式 | 原始 R@1 | 见过的报错 R@1 | 改写描述 R@1 | 没见过的变体 R@1 | 全部 R@1 | 全部 R@3 | MRR | 每条查询 |
|---|---|---|---|---|---|---|---|---|
| BM25 | 15/15 | 6/6 | 13/17 | 9/10 | 43/48 | 47/48 | 0.934 | 0.1ms |
| 向量 | 12/15 | 5/6 | 16/17 | 8/10 | 41/48 | 47/48 | 0.913 | 44ms |
| 混合 | 15/15 | 6/6 | 14/17 | 10/10 | 45/48 | 47/48 | 0.958 | 33ms |
| 混合 + 重排 | 15/15 | 6/6 | 15/17 | 9/10 | 45/48 | 47/48 | 0.951 | 1126ms |
如果只看”全部”那一列,你会错过最重要的信息:向量检索总分最低,但在改写描述上是最好的;BM25 在原始报错上满分,改写上最差。分类统计才能看出”各有长短、混合互补”。
注意样本量:48 条查询,差 2 条就是 4 个百分点。这些差距只能说明方向,不能说”混合比 BM25 好 4%”。
生成层指标:RAGAS(Es 等,2023)提出了一套不依赖人工标准答案的 RAG 评测方法,常被引用的维度包括:
| 维度 | 问的问题 |
|---|---|
| 忠实度(faithfulness) | 回答里的论断能不能从检索到的资料里推出来 |
| 答案相关性(answer relevance) | 回答是否切题 |
| 上下文相关性(context relevance) | 检索到的资料是否和问题相关、有没有太多无关内容 |
这些通常用模型当裁判来打分,要抽样人工校准(09 评测篇讲怎么校准)。
4.12 完整案例:我项目的知识库为什么没用
这是面试时讲 RAG 最有说服力的部分,因为它得出了一个”负面”结论,而且过程经得起追问。
flowchart TD
A[第一版:10 条人工经验 + BM25] --> B[端到端评测:通过率 90% → 100%]
B --> C{能归因于知识库吗}
C -- 查证 --> C1[步数变化 4 好 4 坏 2 平]
C -- 查证 --> C2[两百多次工具调用只查了 3 次]
C -- 查证 --> C3[救回的 re2 查的内容库里没有]
C1 --> D[结论:不能归因]
C2 --> D
C3 --> D
D --> E[扩库:从 160 次运行提炼<br/>7 条候选,审核后 15 条]
E --> F[检索测试集扩到 48 条、分四类<br/>四种检索方式对比]
F --> G[离线:混合检索最好 45/48]
G --> H[端到端:换 5 个没跑过的项目<br/>避免用提炼来源的项目测]
H --> I[三种配置各 3 轮<br/>全部 15/15 通过<br/>步数差异在一倍标准差内]
I --> J[查调用记录:30 次运行只查 8 次<br/>最常查的 libarchive 链接错误库里没有]
J --> K[结论:瓶颈是覆盖不足和调用率低<br/>不在检索算法]
几个关键细节:
① 为什么要换项目测(留出集)。 新增的 5 条经验是从原来 10 个评测项目的运行记录里提炼的,再拿这 10 个项目测,等于提前给了答案。所以另选 5 个 Agent 从没跑过的项目:libuv、curl、libjpeg-turbo、freetype、libarchive。这叫留出集(held-out set)。
② 端到端结果。
| 配置 | 3 轮通过 | 平均步数 | 标准差 | 输入 token(15 次合计) |
|---|---|---|---|---|
| 不开知识库 | 5, 5, 5 | 14.2 | 8.1 | 279 万 |
| BM25 | 5, 5, 5 | 15.7 | 7.4 | 301 万 |
| 混合检索 | 5, 5, 5 | 16.8 | 8.1 | 347 万 |
通过率没有区分度;开知识库步数反而略多,但差异在一倍标准差以内;token 明显多了(检索结果进了上下文)。
③ 知识库到底被怎么用了。 30 次开知识库的运行一共调用 8 次:
| 项目 | 查询内容 | 返回的第一条 | 有用吗 |
|---|---|---|---|
| libarchive(6 次) | ld.lld: error: undefined symbol: LZ4_decompress_safe 这类链接错误 | k012(PIC)或 k004(编译器识别) | 无关 |
| curl(1 次) | Could NOT find Libpsl | k002(找不到依赖库) | 对题 |
| libarchive(1 次) | found host library ... undefined symbols at link | k004 | 无关,k010 更接近 |
④ 结论。
- 离线检索测试集是按库里已有条目写的,所以离线分数高估了实际作用
- 真正查得最多的问题(可选依赖的链接错误),库里根本没有,检索算法再好也只能返回不相关的条目
- 模型很少主动查
- 这类经验不能现在补进库再用同样的项目测,否则留出集就不再”留出”了
面试时怎么说这个结论:不是”RAG 没用”,而是”在这个任务上,检索算法的优化已经不是瓶颈,要提高效果需要扩大知识覆盖、改成失败后自动检索,并且需要依赖链更深的评测项目来区分”。能说清一个方法在什么条件下不起作用,比说它很有用更显功力。
4.13 进阶:Agentic RAG 和 GraphRAG
Agentic RAG:不是固定”检索一次再生成”,而是让 Agent 自己决定要不要检索、查什么、查几次、结果不够时换个查询再查。我项目就是这种形式:检索是 Agent 的一个工具。
| 固定流程 RAG | Agentic RAG | |
|---|---|---|
| 检索时机 | 每次都检索 | 模型判断 |
| 查询 | 用户原始问题或固定改写 | 模型根据当前情况构造 |
| 次数 | 一次 | 可以多次、多个来源 |
| 风险 | 不需要时也检索,引入噪声 | 该查的时候不查(我项目 30 次运行只查 8 次) |
GraphRAG:微软 2024 年的论文 From Local to Global: A Graph RAG Approach to Query-Focused Summarization。先用模型从文档中抽取实体和关系建成知识图谱,对图做社区划分并为每个社区预先生成摘要;回答时综合各社区摘要。它针对的是普通 RAG 不擅长的全局性问题,比如”这批文档的主要主题是什么”。代价是建库时要大量调用模型,成本高。
让 Agent 直接搜,还要不要建索引? 这是 2025 到 2026 年编程 Agent 圈子里争得最多的一个问题,两边都有一手数据:
| 只靠 Agent 自己搜(agentic search) | 搜索工具加语义索引 | |
|---|---|---|
| 代表 | Claude Code | Cursor |
| 做法 | 给模型 grep、glob、读文件这些工具,模型自己一轮轮找 | 除了 grep,再给一个基于自训 embedding 模型的语义搜索工具 |
| 一手说法 | Claude Code 负责人 Boris Cherny 在 X 上说,早期版本用过 RAG 加本地向量库,很快发现 Agent 自己搜一般效果更好,而且更简单,没有安全、隐私、索引过期和可靠性的问题 | Cursor 的 Improving agent with semantic search(2025-11):离线评测里回答代码库问题的准确率平均高 12.5%(不同模型 6.5% 到 23.5%);线上 A/B 测试里,1000 个文件以上的大代码库代码保留率高 2.6%;而且明确说 grep 和语义搜索一起用效果最好 |
| 代价 | 每次都要多轮搜索,token 和时间多;按意思找、字面词又对不上时容易找不到 | 要建索引、保持索引和代码同步,还有代码上传的隐私问题 |
两边其实不矛盾:代码里有函数名、报错原文这类精确字符串,grep 很准;“处理登录超时的逻辑在哪”这种按意思找的问题,语义检索更好。这和本篇 4.7 节”关键词和向量各有弱点、所以要混合”是同一个道理,只是这里检索由 Agent 当工具调用。
面试时可以这样答:文件能直接读、资料量不大、更新频繁(比如代码仓库),先让 Agent 用搜索工具自己找;资料量大、要按意思查、要按权限过滤(比如企业知识库),还是要建索引做混合检索。 最后用评测决定,而不是跟风。
什么时候考虑它们:先把基础的切片、混合检索、评测做扎实。只有测试集显示某一类问题(多跳推理、全局总结)明显失败时,再引入更复杂的方案。
第五部分 对照项目
| 本篇知识点 | 项目里的位置 | 做到了什么 | 没做到或可以改进的 |
|---|---|---|---|
| 知识条目 | knowledge/entries.jsonl | 15 条,每条含标题、匹配模式、成因、处理、来源 | 规模小,覆盖面窄 |
| 经验提炼 | knowledge/mine_runs.py | 按报错签名分组,模型提炼,人工审核 | 离线手动触发 |
| 切片 | 一条经验一个检索单元 | 天然完整 | — |
| BM25 | knowledge.py 的 Knowledge | 自己实现,中英文分词,min_score 门槛 | 中文单字切分 |
| 向量检索 | DenseKnowledge,bge-m3 + Milvus Lite | 模型或条目变化时自动重建;修复了重启后未加载的 bug | 没有相关度门槛 |
| 混合检索 | HybridKnowledge,RRF k=60,两路各取前 10 | 离线总分最高,Agent 接入用它 | — |
| 重排 | bge-reranker-v2-m3,hybrid_rerank 模式 | 测过,不值得 | — |
| 查询改写 | 工具描述要求用报错关键行查询 | 由模型完成 | extract_details 没用于检索 |
| 生成 | Knowledge.format 输出编号、成因、处理 | 带条目编号 | 没有要求引用、没有忠实度检查 |
| 检索评测 | eval/retrieval_eval.py、48 条测试集 | 分四类统计 R@1、R@3、MRR、延迟 | 测试集按已有条目写,高估实际作用 |
| 端到端评测 | 留出集 5 个项目 × 3 配置 × 3 轮 | 得出无可测量收益的结论并定位原因 | 通过率无区分度,需要更难的项目 |
| 调用分析 | runs.db 查 search_knowledge 调用 | 统计调用次数、查询内容、返回是否有用 | — |
第六部分 追问清单
| 你刚讲完 | 下一个追问 | 回答方向 |
|---|---|---|
| RAG 流程 | 和微调怎么选 | 知识会变、要出处用 RAG;改行为和格式考虑微调 |
| 切片 | 表格和代码怎么切 | 表格转行文本或走数据库;代码按函数切 |
| embedding | 怎么选模型 | 语言、长度、部署、用自己数据测;换模型要重建索引 |
| 余弦相似度 | 设多少分作为门槛 | 绝对值不是概率;用测试集校准;我项目 0.64 vs 0.59 差距很小 |
| 向量库 | 为什么用 Milvus Lite | 本地开发方便;15 条其实暴力搜索就够;生产按规模和现有设施选 |
| HNSW | 原理是什么 | 多层图从稀疏到稠密逐层逼近;参数调大更准更慢 |
| BM25 | 公式里 k1 和 b 是什么 | k1 控制词频饱和,b 控制长度归一化 |
| 混合检索 | 为什么用 RRF 不用加权 | 分数尺度不同;RRF 只看排名、不用调参 |
| 重排 | 为什么没用 | 实测总分不变慢 30 倍;库小;大库可能有价值 |
| 召回不准 | 怎么排查 | 库里有没有 → 召回没有 → 排名 → 模型是否使用 |
| 评测 | 测试集怎么建 | 分类统计;原文 / 改写 / 变体;警惕按已有条目写题 |
| 端到端没提升 | 那你做 RAG 的意义是什么 | 定位到瓶颈是覆盖和调用率;方法论可复用;改进方向清楚 |
| 降幻觉 | 怎么检测模型编造 | 引用校验、忠实度检查、允许拒答、门槛 |
| 权限 | 不同用户能看的文档不同怎么办 | 检索层按元数据过滤,不靠模型保密 |
| GraphRAG | 你怎么看 | 解决全局问题;建库成本高;先把基础做扎实再说 |
| Agentic RAG | 现在编程 Agent 不都用 grep 了吗,还要向量库吗 | Claude Code 只用搜索工具,Cursor 实测加语义检索更准;精确字符串 grep 好,按意思找要向量;看资料量、更新频率、权限,用评测定 |
第七部分 闭卷自测
1. 画出 RAG 的离线和在线两条流程。
答案
离线:解析 → 清洗 → 切片(带元数据)→ embedding → 写入向量索引(同时建关键词索引)。 在线:查询改写 → 向量和关键词检索 → RRF 融合 → 重排 → 拼提示词(片段 + 来源 + 规则)→ 生成 → 引用校验。
2. 实验 1 里固定长度切分出了哪些问题?按标题切分为什么还要加上文档标题?
答案
固定长度把 -DZLIB_ROOT=deps/zlib 切成两半,片段 3 不知道是什么的源码,最后一片只剩句号,完全不管语义。加文档标题是因为片段脱离原文后丢失了”在讲哪个项目”的上下文,影响检索和理解,这也是 Contextual Retrieval 的思路。
3. 向量归一化之后,余弦相似度和点积是什么关系?
答案
相等。余弦相似度 = 点积 / (两个向量长度的乘积),归一化后长度都是 1。
4. 实验 2 里相似度 0.643 和 0.590 差距很小,能不能设”0.6 以上算相关”?
答案
不能直接这么设。余弦相似度的绝对值不是概率,不同模型的分数分布不同,同一模型下相关和不相关的分数也可能很接近。门槛要用标注过的测试集校准。
5. BM25 公式里的 idf 为什么对稀有词给更高的权重?k1 和 b 分别控制什么?
答案
稀有词(如 ZLIB_ROOT)出现在少数文档里,命中它更能说明相关;常见词到处都有,区分度低。k1 控制词频的饱和速度,b 控制文档长度归一化的力度(0 为不打折)。
6. 为什么混合检索常用 RRF,而不是把 BM25 分数和余弦相似度直接相加?
答案
两者分数尺度和分布完全不同(BM25 可能是几到几十,余弦是 0 到 1),直接相加会被一方主导,加权又要调参。RRF 只用排名,不受分数尺度影响,不需要调参。
7. 手算:BM25 结果是 [A, B, C],向量结果是 [B, C, A],k=60,RRF 融合后的顺序是什么?
答案
A = 1/61 + 1/63 ≈ 0.03227;B = 1/62 + 1/61 ≈ 0.03252;C = 1/63 + 1/62 ≈ 0.03200。顺序 B、A、C。
8. 交叉编码器为什么比 embedding 模型准?为什么不能直接用它检索整个库?
答案
交叉编码器把查询和文档拼在一起输入,能逐词对照两者;embedding 是分别编码,编码时互相看不见。但交叉编码器每个”查询 + 文档”组合都要完整跑一次模型,没法预先计算,对整个库逐一打分太慢,只能用于少量候选的重排。
9. RAG 回答错了,你怎么定位是哪一环的问题?
答案
依次查:正确资料是否在库里(解析、收录)→ 是否被召回(切片、用词差异、检索方式)→ 召回了排名是否靠前(融合、重排)→ 排在前面但模型没用或用错(提示词、片段位置、上下文长度、是否允许拒答)。需要一份标注了正确片段的测试集。
10. 为什么检索测试集要按查询类型分开统计?用我项目的数据说明。
答案
只看总分会掩盖不同方法的长短。我项目里向量检索总分最低(41/48),但改写描述类最好(16/17);BM25 原始报错满分,改写类最差(13/17)。分类统计才看得出两者互补,混合检索为什么总分最高。
11. 我项目离线检索 R@1 有 45/48,为什么端到端没有收益?
答案
一是测试集按库里已有条目写,高估了实际作用;二是实际任务里最常遇到的报错(libarchive 的可选依赖链接错误)库里没有,检索算法再好也只能返回无关条目;三是模型很少主动查,30 次运行只调 8 次;另外通过率本身全是 15/15,没有区分度。
12. 为什么端到端评测要换 5 个没跑过的项目?
答案
新增经验是从原来 10 个项目的运行记录里提炼的,再用这些项目测相当于提前给了答案,会高估效果。换成 Agent 从没跑过的项目(留出集)才能测真实的泛化效果。同理,libarchive 的经验不能现在补进库再用同样的项目测。
13. 企业知识库里不同员工能看的文档不同,怎么保证不越权?
答案
在检索层按用户权限过滤:片段带权限元数据,检索时只在当前用户有权限的范围内查。不能靠提示词让模型”不要说出来”,因为资料一旦进了上下文就可能泄露。
延伸阅读
- RAG 原始论文(Lewis 等,2020)
- Anthropic:Contextual Retrieval(2024-09)— 给片段补上下文、上下文化 BM25、重排的组合效果
- BGE M3-Embedding(2024)— 多语言、多功能、长输入的 embedding 模型
- HNSW 论文(Malkov、Yashunin)— 最常用的近似最近邻索引
- HyDE 论文(Gao 等,2022)— 用假想答案做检索
- RAGAS 论文(Es 等,2023)— 不依赖人工答案的 RAG 评测
- GraphRAG 论文(Edge 等,2024)— 知识图谱 + 社区摘要回答全局问题
- Lost in the Middle — 片段在上下文里的位置影响利用效果
- Cursor:Improving agent with semantic search(2025-11)— 编程 Agent 里 grep 加语义检索和只用 grep 的离线、线上对比
- 项目
eval/REPORT.md的”知识库扩充与检索对比”和”知识库对 Agent 的端到端效果:留出集”两节 — 本篇 4.11、4.12 的原始记录
下一篇:09 评测——“怎么证明变好了”,这一篇里反复出现的问题,下一篇系统讲。