PERSONAL LAB / ai/agent开发
AI Agent 开发

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@kRecall 看正确答案有没有被找回;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 效果差的根源在这里:解析错了,后面怎么调都没用。

文档类型常见问题处理
PDF双栏排版读成交错的句子;页眉页脚、页码混进正文;表格变成一串乱序数字用版面分析工具;去掉重复的页眉页脚;表格单独提取成结构化文本
扫描件、图片没有文字层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-m3python 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] 按分数的相反数排序,也就是从高到低

能看出三件事:

  1. 第一条查询没有出现”依赖库""find_package”任何字眼,向量检索依然把”找不到依赖库”排在第一。这是向量检索的长处:按意思找
  2. 分数差距很小(0.643 vs 0.590)。余弦相似度的绝对值不是概率,不能说”0.6 以上就相关”,不同模型的分数分布也不同。设相似度门槛要用测试集校准
  3. 第二条带明确文件名的报错,这次排对了。但在我项目的完整测试里,一条类似的查询 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)。面试能说清”分层图、从上往下逐层逼近”就够了。参数上要知道:建索引时每个点连多少邻居、查询时搜索多宽,调大更准但更慢更占内存。

向量数据库怎么选:

方案特点适合
FAISSMeta 开源的向量检索库,不是数据库,没有增删改查的服务离线、嵌入到程序里、研究
Chroma轻量、开发体验好原型和小项目
Milvus / Milvus Lite专门的向量数据库,支持分布式;Lite 版是本地文件Lite 做开发,完整版做大规模生产。我项目用的 Lite
pgvectorPostgreSQL 的扩展已经在用 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 等搜索引擎的默认排序算法都基于它。它根据查询里的词在文档中出现的情况给文档打分。

它的直觉有三条:

  1. 词在这篇文档里出现得越多,越相关,但增长会饱和(出现 10 次不比 5 次相关两倍)
  2. 越稀有的词越重要ZLIB_ROOT 只在一两篇文档里出现,命中它比命中”的""error”有价值得多
  3. 长文档要打折:长文档天然包含更多词,不能因为长就占便宜

我项目 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_ROOTc++libpng.so 都能作为一个整体
  • | 表示”或者”
  • [一-鿿]:一个中文字符(Unicode 里常用汉字所在的范围)。中文按单字切,没有用分词词典
  • s.lower():先转小写,ZLIBzlib 当成同一个词

打分 _score,对查询里的每个词 w:

  • Counter(self.docs[i])Countercollections 模块里的计数器,统计第 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@1MRR每条查询
混合45/4814/1710/100.95833 毫秒
混合 + 重排45/4815/179/100.9511126 毫秒

重排后改写类多对 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.pyextract_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.py
  • import knowledge 只用到 BM25 的 Knowledge 类,不需要装向量相关的库,因为项目里 DenseKnowledgepymilvus 等的导入写在了类的初始化方法内部,不用它就不会导入
  • rank_of(ids, gold):返回第一个正确答案的排名,没有返回 Nonegold 是列表,因为有 5 条查询同时对应两个条目都算对
  • hit1 += r == 1r == 1 的结果是 True 或 False,在加法里 True 当 1、False 当 0
  • hit3 += r is not None:前 3 名里有正确答案就加 1
  • mrr += 1 / r if r else 0:条件表达式,rNone 时加 0
  • rrfscores.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@3MRR每条查询
BM2515/156/613/179/1043/4847/480.9340.1ms
向量12/155/616/178/1041/4847/480.91344ms
混合15/156/614/1710/1045/4847/480.95833ms
混合 + 重排15/156/615/179/1045/4847/480.9511126ms

如果只看”全部”那一列,你会错过最重要的信息:向量检索总分最低,但在改写描述上是最好的;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, 514.28.1279 万
BM255, 5, 515.77.4301 万
混合检索5, 5, 516.88.1347 万

通过率没有区分度;开知识库步数反而略多,但差异在一倍标准差以内;token 明显多了(检索结果进了上下文)。

③ 知识库到底被怎么用了。 30 次开知识库的运行一共调用 8 次:

项目查询内容返回的第一条有用吗
libarchive(6 次)ld.lld: error: undefined symbol: LZ4_decompress_safe 这类链接错误k012(PIC)或 k004(编译器识别)无关
curl(1 次)Could NOT find Libpslk002(找不到依赖库)对题
libarchive(1 次)found host library ... undefined symbols at linkk004无关,k010 更接近

④ 结论。

  • 离线检索测试集是按库里已有条目写的,所以离线分数高估了实际作用
  • 真正查得最多的问题(可选依赖的链接错误),库里根本没有,检索算法再好也只能返回不相关的条目
  • 模型很少主动查
  • 这类经验不能现在补进库再用同样的项目测,否则留出集就不再”留出”了

面试时怎么说这个结论:不是”RAG 没用”,而是”在这个任务上,检索算法的优化已经不是瓶颈,要提高效果需要扩大知识覆盖、改成失败后自动检索,并且需要依赖链更深的评测项目来区分”。能说清一个方法在什么条件下不起作用,比说它很有用更显功力。

4.13 进阶:Agentic RAG 和 GraphRAG

Agentic RAG:不是固定”检索一次再生成”,而是让 Agent 自己决定要不要检索、查什么、查几次、结果不够时换个查询再查。我项目就是这种形式:检索是 Agent 的一个工具。

固定流程 RAGAgentic 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 CodeCursor
做法给模型 grepglob、读文件这些工具,模型自己一轮轮找除了 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.jsonl15 条,每条含标题、匹配模式、成因、处理、来源规模小,覆盖面窄
经验提炼knowledge/mine_runs.py按报错签名分组,模型提炼,人工审核离线手动触发
切片一条经验一个检索单元天然完整
BM25knowledge.pyKnowledge自己实现,中英文分词,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.dbsearch_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. 企业知识库里不同员工能看的文档不同,怎么保证不越权?

答案

在检索层按用户权限过滤:片段带权限元数据,检索时只在当前用户有权限的范围内查。不能靠提示词让模型”不要说出来”,因为资料一旦进了上下文就可能泄露。

延伸阅读

  1. RAG 原始论文(Lewis 等,2020)
  2. Anthropic:Contextual Retrieval(2024-09)— 给片段补上下文、上下文化 BM25、重排的组合效果
  3. BGE M3-Embedding(2024)— 多语言、多功能、长输入的 embedding 模型
  4. HNSW 论文(Malkov、Yashunin)— 最常用的近似最近邻索引
  5. HyDE 论文(Gao 等,2022)— 用假想答案做检索
  6. RAGAS 论文(Es 等,2023)— 不依赖人工答案的 RAG 评测
  7. GraphRAG 论文(Edge 等,2024)— 知识图谱 + 社区摘要回答全局问题
  8. Lost in the Middle — 片段在上下文里的位置影响利用效果
  9. Cursor:Improving agent with semantic search(2025-11)— 编程 Agent 里 grep 加语义检索和只用 grep 的离线、线上对比
  10. 项目 eval/REPORT.md 的”知识库扩充与检索对比”和”知识库对 Agent 的端到端效果:留出集”两节 — 本篇 4.11、4.12 的原始记录

下一篇:09 评测——“怎么证明变好了”,这一篇里反复出现的问题,下一篇系统讲。

Related · Agent 开发
⎇ main ai/agent开发 28 节 230 notes UTF-8