RAG:面试速记 + 从零学习
这份文档分两层用:
| 场景 | 读哪里 |
|---|---|
| 面试前 10 分钟 | 第一部分速记页:一张链路图 + 每一环会丢什么 + 高频考点表 |
| 面试前一天 | 第二部分易混对照 + 第三部分口述稿,出声念一遍 |
| 平时学习 | 第四部分逐环详解,按数据流排的 |
| 想动手 | 第五部分:可运行实验,含一个真实的检索失败案例 |
| 检验是否真会 | 第六部分闭卷自测 |
全文围绕一个问题:你问一句话,系统怎么从一堆文档里挑出该看的那几段——每一环可能丢掉什么。
RAG 面试的追问几乎都落在”为什么没召回到”和”你怎么证明它变好了”上。
配套代码在本文第五部分,实验输出是真跑出来的。Agent 侧见 Agent 从零开始图解。
第一部分 速记页
1.1 一次 RAG 问答的完整旅程
flowchart TB
subgraph OFF["离线:建库(做一次)"]
A1[原始文档 PDF/MD/代码] --> A2[解析成纯文本]
A2 --> A3["切片 chunk<br/>切多大、怎么切、重叠多少"]
A3 --> A4["embedding<br/>每片变成一串数字"]
A4 --> A5["写进向量索引<br/>连同原文和元数据"]
end
subgraph ON["在线:查询(每次问)"]
B1[用户问题] --> B2["query 改写<br/>可选"]
B2 --> B3["向量检索 topK<br/>+ 关键词检索"]
B3 --> B4["rerank 精排<br/>可选,取 topN"]
B4 --> B5["拼进 prompt<br/>资料 + 问题 + 约束"]
B5 --> B6[模型生成]
B6 --> B7["带出处的回答"]
end
A5 -.->|索引| B3
看图,记五句话:
- RAG 不是一个模型,是一条流水线。 效果差的时候,先定位是哪一环出的问题,而不是去改 prompt。
- 离线那半边决定了在线那半边的上限。 切片切坏了,后面再怎么调都救不回来。
- 检索是”召回 + 精排”两步。 召回要快要全(topK 取 20-50),精排要准(topN 取 3-5)。
- 模型只能看到你塞进 prompt 的那几段。 没检索到 = 一定答不对,这是最常见的失败原因。
- “模型答错了”要先查是没检索到、检索到了没排上去、还是排上去了但被淹没。 三种病三种药。
1.2 每一环会丢什么
这张表是排查手册,面试问”召回不准怎么查”就按这个顺序说。
| 环节 | 会丢什么 | 典型症状 |
|---|---|---|
| 文档解析 | 表格结构、代码缩进、图片里的字、PDF 分栏顺序 | 答案在表格里但答不出来 |
| 切片 | 跨片的上下文、被切断的句子、脱离标题的正文 | 检索到的片段看起来相关但不完整 |
| embedding | 精确的专有名词、数字、代码标识符 | 查 CMAKE_SYSTEM_NAME 召回一堆讲 CMake 的泛泛内容 |
| 检索 | 同义表达、多跳问题的中间环节 | 换个说法就找不到了 |
| rerank | (不丢,是补救)没做的话相关的排在 20 名开外 | topK 里有正确答案,但 top3 里没有 |
| 拼 prompt | 超长时被截断、关键信息在中间被忽略 | 资料给了但没用上 |
| 生成 | 模型不采信检索结果,用自己的先验 | 答案和给的资料矛盾 |
1.3 高频考点一页表
切片
| 考点 | 一句话答案 | 别答错 |
|---|---|---|
| 切多大 | 没有标准答案,取决于文档类型和 embedding 模型。常见 200-500 字,代码和 FAQ 更小,叙述性文档更大 | 别张口就说”512 最好” |
| 为什么要重叠 | 防止答案正好被切在边界上。常见重叠 10-20% | 重叠越大越好是错的,会放大冗余 |
| 有哪些切法 | 定长切、按标点/段落切、按标题层级切(结构化文档最优)、语义切(算相邻句相似度找断点) | |
| 切片要带什么 | 把标题路径拼进片内容里(“第 3 章 > 3.2 交叉编译 > 正文…”),否则正文脱离上下文 | 这条最实用,很多人不知道 |
| 一句话总结 | 切片的目标是让每一片自己能读懂 |
向量与检索
| 考点 | 一句话答案 | 别答错 |
|---|---|---|
| embedding 是什么 | 把文本映射成一个几百到几千维的向量,语义相近的向量方向相近 | 不是”压缩”,是”语义坐标” |
| 怎么算相似 | 余弦相似度(看方向夹角)最常用;归一化后和内积等价 | 欧氏距离在未归一化时会被长度影响 |
| 向量检索为什么快 | 用近似最近邻索引(HNSW、IVF、PQ),牺牲一点召回率换速度 | 不是精确检索 |
| HNSW 是什么 | 多层图索引,上层稀疏跳跃、下层稠密精查。建索引慢、查得快、吃内存 | |
| 向量检索的短板 | 专有名词、代码标识符、精确数字匹配差 | 这是混合检索存在的理由 |
| BM25 是什么 | 基于词频的关键词检索,词对不上就一分不给 | |
| 混合检索怎么融合 | 各自取 topK 后用 RRF(倒数排名融合)合并,或加权分数(要先归一化) | 分数不同量纲,不能直接相加 |
| 向量库怎么选 | 数据量小(十万级)用 pgvector,省一个组件;量大或要复杂过滤用 Milvus / Qdrant | 不是越专业越好 |
提升召回的手段
| 手段 | 解决什么 | 代价 |
|---|---|---|
| Query 改写 | 用户问法口语、有指代(“它怎么配”) | 多一次模型调用 |
| 多查询(Multi-Query) | 一个问题生成 3-5 个变体分别检索再合并 | 成本 ×N |
| HyDE | 先让模型编一个”假想答案”,用它去检索 | 编错了会带偏 |
| Rerank | 召回回来的 20-50 条重新精排 | 延迟 +100ms 量级 |
| 元数据过滤 | 限定版本、时间、来源 | 要在切片时就存好元数据 |
| 父子切片 | 用小片检索、返回大片给模型 | 实现复杂一点 |
生成与降幻觉
| 考点 | 一句话答案 | 别答错 |
|---|---|---|
| 怎么降幻觉 | 先分类:没检索到(改检索)、检索到没用上(改 prompt 和顺序)、模型不采信(要求引用出处 + 允许说不知道) | 只答”写好 prompt”是最低分 |
| 出处怎么做 | 每片带 id,要求模型在回答里标注引用了哪几片,程序校验引用的 id 确实存在 | |
| 允许说”不知道”很重要 | 明确写”资料里没有就回答不知道”,能砍掉一大块编造 | |
| 关键信息放哪 | 超长上下文中间的内容容易被忽略,把最相关的放开头或结尾 |
评测
| 考点 | 一句话答案 | 别答错 |
|---|---|---|
| 检索侧指标 | Recall@K(正确片在不在 topK 里)、MRR、命中率 | 这一步不需要模型,可以自动跑 |
| 生成侧指标 | 答案正确率、忠实度(有没有超出资料乱说)、引用准确率 | |
| 怎么构造测试集 | 从真实文档里挑,人工写问题并标注正确片的 id。50-100 条就能用 | 用模型生成问题要人工过一遍 |
| LLM-as-Judge 靠谱吗 | 相对比较(A/B 哪个好)比绝对打分靠谱;要固定评分 prompt 并抽样人工校验 | 不能完全替代人工 |
| 改了没变好怎么办 | 一次只改一个变量,每轮记数字。这是面试最看重的习惯 |
第二部分 易混对照
2.1 关键词检索 vs 向量检索
| BM25(关键词) | 向量检索 | |
|---|---|---|
| 原理 | 数词频,词对不上给零分 | 比语义方向 |
| 强在 | 专有名词、代码标识符、精确数字、罕见词 | 同义表达、换个说法、跨语言 |
| 弱在 | 换种说法就完全失效 | 精确匹配,容易召回”看起来像”的内容 |
| 要不要训练 | 不要,统计方法 | 要一个 embedding 模型 |
| 增量更新 | 便宜 | 要重新算向量 |
两者是互补不是替代,所以生产系统基本都用混合检索。第五部分有一个实测案例:同一个意图,“怎么让 cmake 进入交叉编译模式”BM25 稳稳排第一,换成”怎样开启跨平台构建”就完全找不到——一个词都没对上。
2.2 召回 vs 精排
| 召回(retrieval) | 精排(rerank) | |
|---|---|---|
| 目标 | 别漏(宁可多给) | 别错(挑出最相关的) |
| 数量 | topK = 20~50 | topN = 3~5 |
| 方法 | 向量 + BM25,快 | Cross-Encoder 模型,慢但准 |
| 算一次的代价 | 毫秒级,可预先建索引 | 每个候选都要过一遍模型 |
为什么不直接用 rerank 模型检索全库:rerank 是把 query 和文档拼在一起送进模型算相关性,没法预计算,全库跑一遍代价不可接受。所以先用便宜的方法粗筛,再用贵的方法精排。
这个”粗筛 + 精排”的两段式在搜索、推荐里都是一样的套路,能说出来是加分项。
2.3 Embedding 模型 vs Rerank 模型
| Embedding(Bi-Encoder) | Rerank(Cross-Encoder) | |
|---|---|---|
| 怎么算 | query 和文档分别编码成向量,再比相似度 | query 和文档拼在一起送进模型,直接输出相关性分数 |
| 能预计算吗 | 能,文档向量离线算好 | 不能 |
| 精度 | 较低(两边独立编码,交互信息丢了) | 高 |
| 速度 | 快 | 慢 |
一句话:Bi-Encoder 快因为能预计算,Cross-Encoder 准因为 query 和文档能互相”看见”。
2.4 向量索引 vs 向量数据库
| 是什么 | 例子 | |
|---|---|---|
| 向量索引 | 一个数据结构/算法库,管”怎么快速找最近邻” | FAISS、HNSWlib |
| 向量数据库 | 索引 + 持久化 + 元数据过滤 + 增删改 + 分布式 + 权限 | Milvus、Qdrant、Weaviate |
| 关系库的向量扩展 | 在 PostgreSQL 里加向量列和索引 | pgvector |
小项目(十万片以内)用 pgvector 通常最划算:少一个组件、事务和元数据过滤天然就有、备份运维都是现成的。面试时能说出”我为什么没上 Milvus”比说”我用了 Milvus”更能体现判断力。
2.5 chunk size vs overlap vs topK
三个参数经常被一起调,但作用不同:
| 参数 | 调大的效果 | 调大的代价 |
|---|---|---|
| chunk size | 每片上下文更完整 | 片内噪声多,向量语义被稀释,检索变糊 |
| overlap | 边界处的答案不容易丢 | 冗余变多,同一内容占多个坑位 |
| topK | 召回更全 | 进 prompt 的噪声多、token 贵;超过窗口还得截 |
互相牵制:chunk 调小要配合 topK 调大,否则给模型的信息总量不够。这个权衡是面试常考点。
2.6 RAG vs 长上下文 vs 微调 vs 工具调用
| 手段 | 解决 | 什么时候选 |
|---|---|---|
| 直接塞进上下文 | 资料少 | 几千字以内、不常变,最简单,优先考虑 |
| RAG | 资料多、会变、要出处 | 知识库场景的默认选择 |
| 微调 | 模型不会某种格式或风格 | 知识类问题不要用微调 |
| 工具调用(查数据库/API) | 需要实时、精确的结构化数据 | 查库存、查订单不该用 RAG |
常见错误是把”查某个具体数值”做成 RAG。结构化查询用工具,非结构化知识才用 RAG。
第三部分 口述稿
3.1 RAG 是什么(60 秒)
“RAG 是给模型外挂知识。模型本身只知道训练时见过的东西,不知道我们公司的文档,硬问就会编。
做法分两半。离线把文档解析、切片、算成向量存进索引;在线拿用户问题去检索,把最相关的几片拼进 prompt,让模型基于这些资料回答。
关键认知是:模型只能看到我塞进 prompt 的那几片。所以 RAG 的效果上限在检索,不在生成。大部分人一上来调 prompt,其实问题在切片和召回。“
3.2 为什么召回不准,你怎么查(90 秒)
“我按链路顺序排查,分三种情况。
第一种,根本没检索到。把正确答案所在的片手动找出来,看它在 topK 里排第几。如果排在 50 名开外,是检索问题——可能是切片把它切碎了,可能是 query 和文档用词差太远,这时候加 BM25 做混合检索、或者做 query 改写。
第二种,检索到了但没排进 top3。这是 rerank 该解决的,召回放宽到 30-50 条,加一个 Cross-Encoder 精排。
第三种,排进去了但模型没用。检查是不是 prompt 太长关键信息被淹没了,或者模型不采信资料用了自己的先验——这时候要求它标注引用出处,并明确允许回答不知道。
三种病三种药,先分类再动手,不能一上来就调 prompt。“
3.3 切片怎么切(60 秒)
“没有一个万能的大小,取决于文档类型。我的原则是让每一片自己能读懂。
具体做法:优先按结构切——Markdown 按标题层级、代码按函数、FAQ 按条目,因为结构本身就是作者划的语义边界。定长切是兜底方案。
有一条很实用但很多人不做:把标题路径拼进片的内容里,比如正文前面加上”第 3 章 > 3.2 交叉编译”。否则一段正文脱离标题,既检索不准,模型看了也不知道在讲什么。
重叠一般给 10% 到 20%,防止答案正好被切在边界。重叠不是越大越好,会产生大量冗余片占坑位。
最后,chunk 调小了 topK 要跟着调大,否则给模型的信息总量不够。这两个参数得一起看。“
3.4 向量检索和关键词检索怎么配(60 秒)
“两者短板正好相反。向量检索强在同义表达,弱在精确匹配——查一个具体的宏名或者报错码,它会召回一堆’看起来像’的内容。BM25 正相反,词对得上就准,对不上就一分没有。
我做过一个对比实验:同一个意图,问’怎么让 cmake 进入交叉编译模式’,BM25 稳稳排第一,因为 cmake、交叉、编译这几个词都在文档里;换成’怎样开启跨平台构建’,一个词都对不上,正确答案直接掉出 top3。
所以生产上基本都是混合检索。两路各取 topK,用 RRF 倒数排名融合合并——不能直接把分数相加,因为 BM25 分数和余弦相似度不是一个量纲。“
3.5 你怎么证明 RAG 变好了(90 秒)
“分两层指标。
检索侧可以完全自动化:准备一批问题,人工标注正确答案在哪一片,然后算 Recall@K——正确片在不在前 K 个里,还有 MRR 看它排第几。这一层不需要调模型,跑得快也便宜,改切片策略先看这个。
生成侧要看答案正确率和忠实度,忠实度是指有没有超出给定资料乱说。这层可以用 LLM-as-Judge 辅助,但要注意:让模型做 A/B 相对比较比让它绝对打分靠谱,而且要抽样人工校验。
测试集不用很大,50 到 100 条真实问题就够用,关键是一次只改一个变量,每轮记一个数字。我改切片策略、加 rerank、加 query 改写,每一步都有前后对比的数字,这样才知道哪个改动真的有用,也才能在面试里说清楚。“
3.6 幻觉怎么办(60 秒)
“先分类。一类是根本没给它依据,它只能编——这是检索问题。一类是给了依据但它不采信,用自己的先验——这是 prompt 和模型选择问题。还有一类是资料太长,关键部分在中间被忽略了。
针对第二类,三个办法比较有效:要求带出处,让模型标注它引用了哪几片,我在程序里校验这些 id 真实存在;明确允许说不知道,prompt 里写清楚’资料中没有就回答不知道’,这一条能砍掉很大一块编造;把最相关的资料放在开头或结尾,避开长上下文中间容易被忽略的位置。
最后还要有兜底:检索分数都很低的时候,直接回复’没找到相关资料’,不进生成这一步。“
3.7 向量库怎么选(45 秒)
“看数据规模和运维成本。我这个项目片数在万级,用的 pgvector——好处是少一个组件,元数据过滤和事务是数据库自带的,备份运维都是现成的。
上 Milvus、Qdrant 这类专用向量库的理由通常是:数据量到千万上亿、需要复杂的混合过滤、或者要分布式扩展。我这个规模上专用库属于过度设计。
这个判断本身比用了什么更重要——我见过很多项目一上来就上重型组件,其实 pgvector 一张表就解决了。“
第四部分 逐环详解
按数据流排序。零基础从这里开始读。
4.1 起点:模型为什么需要外挂知识
模型的知识来自训练数据,有三个硬限制:
| 限制 | 表现 |
|---|---|
| 时间截止 | 训练之后发生的事完全不知道 |
| 私有数据 | 你公司的文档它没见过 |
| 不可靠 | 它不知道自己不知道,会用相似的东西编一个 |
解决办法只有一个思路:把资料放进 prompt 里,让它照着答。
那为什么不把所有文档都塞进去?两个原因:上下文窗口有上限;就算塞得下,成本按输入 token 算,每次问都塞几十万字不现实,而且资料一多模型反而抓不住重点。
所以要先挑出相关的那几段——这就是 RAG 里的 R(Retrieval,检索)。
一句话概括整件事:RAG = 先搜索,再把搜到的东西喂给模型。 复杂度全在”怎么搜得准”。
4.2 文档解析:最容易被低估的一环
垃圾进垃圾出。这一环丢掉的东西,后面任何手段都补不回来。
| 文档类型 | 坑 |
|---|---|
| 分栏顺序错乱、表格变成乱序文本、页眉页脚混进正文 | |
| Word | 修订痕迹、批注、嵌套表格 |
| HTML | 导航栏和广告混进正文 |
| 代码 | 缩进和结构丢失 |
| 扫描件 | 需要 OCR,错字率直接传导到检索 |
实践建议:先人工抽查十份解析结果再往下做。很多”RAG 效果差”的项目,根因是 PDF 表格解析成了一团乱码,却在拼命调 embedding 模型。
4.3 切片:让每一片自己能读懂
为什么要切
两个理由:embedding 模型有输入长度上限;更重要的是,一整篇文档算出来的向量是”平均语义”,什么都不像。切成小片,每片语义集中,才检索得准。
四种切法
flowchart LR
A[原始文本] --> B["定长切<br/>每 400 字一刀"]
A --> C["按标点/段落切<br/>不切断句子"]
A --> D["按结构切<br/>Markdown 标题、代码函数"]
A --> E["语义切<br/>算相邻句相似度找断点"]
B --> B1["最简单<br/>会切断语义"]
C --> C1["常用兜底"]
D --> D1["结构化文档最优"]
E --> E1["效果好但慢且贵"]
优先级:有结构就用结构。Markdown 按标题层级、代码按函数或类、FAQ 按条目——这些边界是作者划的语义边界,比任何算法都准。没有结构才退回按段落切。
三个实用技巧
1. 把标题路径拼进片内容
不好: 设置 CMAKE_SYSTEM_NAME 为 Linux,CMAKE_SYSTEM_PROCESSOR 为 aarch64。
好: 交叉编译指南 > 第 3 章 工具链配置 > 3.2 必需变量
设置 CMAKE_SYSTEM_NAME 为 Linux,CMAKE_SYSTEM_PROCESSOR 为 aarch64。
一段脱离标题的正文,既检索不准(缺关键词),模型看了也不知道在讲什么场景。这条改动成本极低、收益很大,但很多实现里没有。
2. 重叠 10-20%
防止答案正好被切在边界上。但重叠会产生冗余片——同一段内容出现在多个片里,检索时占掉多个 topK 坑位。所以不是越大越好。
3. 父子切片
用小片(200 字)去检索,命中后返回它所属的大片(1000 字)给模型。兼顾”检索要精”和”上下文要全”。实现上就是每个小片存一个 parent_id。
尺寸怎么定
没有标准答案。经验起点:
| 文档类型 | 起点大小 |
|---|---|
| FAQ、报错条目 | 100-200 字(一条就是一片) |
| 技术文档 | 300-500 字 |
| 叙述性长文 | 500-800 字 |
| 代码 | 按函数,不按字数 |
定下来之后就用评测集验证,不要凭感觉。 这是第五部分实验二要做的事。
4.4 Embedding:把文本变成语义坐标
是什么
一个模型,输入一段文本,输出一串固定长度的数字(比如 1024 个浮点数)。这串数字是这段文本在”语义空间”里的坐标。
关键性质:意思相近的文本,向量方向也相近。
"怎么开启交叉编译" → [0.21, -0.05, 0.88, ...]
"如何配置跨平台构建" → [0.19, -0.03, 0.91, ...] ← 方向很接近
"今天午饭吃什么" → [-0.62, 0.44, 0.02, ...] ← 方向差很远
怎么比较
余弦相似度:看两个向量的夹角,值域 -1 到 1,越大越相似。
import numpy as np
def cosine(a, b):
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
如果向量已经归一化(长度为 1),余弦相似度就等于点积,这也是为什么很多向量库要求先归一化——点积算起来更快。
为什么不用欧氏距离:未归一化时,文本长度会影响向量模长,长文档和短文档没法公平比较。归一化之后两者其实等价。
选模型注意什么
| 维度 | 说明 |
|---|---|
| 中文支持 | 很多英文模型的中文效果差很多 |
| 向量维度 | 768 / 1024 / 1536 常见。维度高不一定好,但存储和检索成本线性上涨 |
| 最大输入长度 | 512 token 是常见上限,切片不能超过它 |
| 对称 vs 非对称 | 有些模型区分 query 和 document,要用不同前缀编码 |
最重要的一条:query 和 document 必须用同一个模型编码。 换模型要重建整个索引。
短板
embedding 对精确匹配不敏感。查 CMAKE_SYSTEM_PROCESSOR 可能召回一堆泛泛谈 CMake 的内容,因为在语义空间里它们确实很近。这是混合检索存在的根本理由。
4.5 向量索引:怎么在百万向量里快速找最近的
暴力比较是 O(N):一百万片就要算一百万次余弦。所以要用近似最近邻(ANN)索引。
| 索引 | 原理 | 特点 |
|---|---|---|
| HNSW | 多层图。上层稀疏用来”跳远”,下层稠密用来”精查” | 查询快、召回高、吃内存、建索引慢 |
| IVF | 先聚类成若干桶,查询时只搜最近的几个桶 | 内存友好,召回率取决于搜几个桶 |
| PQ | 把向量压缩成短码 | 省内存,精度有损,常和 IVF 组合 |
| 暴力(Flat) | 全量比较 | 精确,十万以内可接受 |
核心权衡:召回率 vs 速度 vs 内存,三者不可兼得。 HNSW 的 ef_search 参数调大,召回率上升、延迟上升——面试可以主动提这个参数。
重要认知:ANN 是近似的,意味着向量检索本身就会漏。你的 Recall@K 天花板不是 100%,这也是评测要测检索侧的原因。
4.6 关键词检索与混合融合
BM25 是什么
一句话:统计词频的打分公式。某个词在这篇文档里出现得多(TF 高)、在整个语料里出现得少(IDF 高),就加分;同时用文档长度做归一化,防止长文档因为词多而占便宜。
不需要训练,不需要 GPU,几十行代码就能实现(第五部分有完整可跑的)。
为什么必须要有它
第五部分的实测结果:
查询: 怎么让 cmake 进入交叉编译模式
9.331 CMAKE_SYSTEM_NAME 设为 Linux、CMAKE_SYSTEM_PROC... ← 正确,第一名
查询: 怎样开启跨平台构建
0.828 链接 aarch64 目标时出现 file format not recogni... ← 全错
0.828 交叉编译时报 cannot find -lstdc++,通常是 sysroot ...
同一个意图,换个说法,正确答案直接掉出结果。 这就是纯关键词检索的死穴,也正是向量检索要补的地方。反过来,查 CMAKE_SYSTEM_NAME 这种精确标识符时,BM25 又比向量准得多。
怎么融合:RRF
两路检索的分数量纲不同(BM25 可能是 0-20,余弦是 0-1),不能直接相加。标准做法是 RRF(Reciprocal Rank Fusion,倒数排名融合),只看排名不看分数:
def rrf(rank_lists, k=60):
scores = {}
for ranking in rank_lists:
for rank, doc_id in enumerate(ranking, start=1):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)
每个文档的得分是它在各路结果里 1/(k+排名) 的和。k 一般取 60,作用是压低头部差异,让排名靠前的几位不至于压倒性碾压。
RRF 的好处是不用调权重、不用归一化分数,工程上省心,效果也稳。要更精细可以用加权融合,但得先把分数归一化。
4.7 Query 改写:让问题和文档说同一种话
用户的问法和文档的写法经常对不上。三种常见手段:
| 手段 | 做法 | 适合 |
|---|---|---|
| 改写 | 让模型把口语问题改成书面表述,解决指代(“它怎么配”→“CMAKE_SYSTEM_NAME 怎么配”) | 多轮对话必备 |
| 多查询 | 一个问题生成 3-5 个变体,分别检索后合并 | 召回率要求高的场景 |
| HyDE | 让模型先编一个”假想答案”,用假想答案去检索 | 问题短、文档长的场景 |
HyDE 的直觉是:答案和答案更像,问题和答案不一定像。用一段假想的答案去匹配真实的文档段落,命中率往往更高。风险是模型编得离谱时会带偏检索。
多轮对话里 query 改写几乎是必须的,因为”它怎么配""那这个呢”这类问题单独拿去检索毫无意义。
4.8 Rerank:把对的挑到前面
召回阶段追求快和全,结果里必然混着噪声。Rerank 用一个更贵但更准的模型,把召回的 20-50 条重新排序,取前 3-5 条给模型。
为什么它更准:召回用的 Bi-Encoder 把 query 和文档分别编码,两边互相看不见;Rerank 用的 Cross-Encoder 把两者拼在一起送进模型,能捕捉细粒度的交互。
为什么不能直接用它检索全库:没法预计算,每个候选都要过一遍模型,全库跑一遍代价不可接受。
典型收益:topK=30 召回 + rerank 取 top3,通常比直接取向量 top3 明显好。代价是每次查询多 100ms 量级的延迟。
什么时候可以不做:知识库很小(几百片)、或者切片质量很高、或者延迟要求极严。
4.9 拼 prompt:给了资料不等于用了资料
一个可用的模板结构:
[system] 你根据提供的资料回答问题。资料中没有的内容,回答"资料中未提及",不要编造。
回答时标注引用了哪几条资料的编号。
[user] 资料:
[1] (来源: 交叉编译指南 > 3.2) CMAKE_SYSTEM_NAME 设为 Linux...
[2] (来源: 常见报错 > 链接错误) file format not recognized 说明...
[3] ...
问题:怎么让 cmake 进入交叉编译模式?
四个要点:
- 每条带编号和来源,才能要求引用、才能校验
- 明确允许说不知道,这一条能砍掉一大块编造
- 最相关的放开头或结尾——长上下文中间的内容容易被忽略
- 控制总长度,资料不是越多越好,噪声会稀释注意力
4.10 降幻觉:先分类,再对症
| 类型 | 根因 | 对策 |
|---|---|---|
| 没依据的编造 | 压根没检索到 | 改检索(混合、改写、rerank) |
| 有依据但不采信 | 模型先验太强 | 要求引用出处 + 强调”只依据资料” |
| 被淹没 | 资料太长、关键信息在中间 | 减少 topK、调整顺序、压缩 |
| 跨片拼接出错 | 答案需要综合多片 | 父子切片、增大 chunk、多跳检索 |
再加一道兜底:检索最高分低于阈值时,直接回复”没找到相关资料”,不进生成。这比让模型硬答强得多,也是实际产品里的标准做法。
面试答法:不要只说”写好 prompt”。先说分类,再说每类的对策,最后说兜底。
4.11 评测:这一节决定你的项目能不能拿分
两层指标
检索侧(自动化,便宜,改切片先看这个):
| 指标 | 含义 |
|---|---|
| Recall@K | 正确片在不在前 K 个里。最重要 |
| MRR | 正确片排名的倒数的平均,衡量排得多靠前 |
| 命中率 | 至少召回一条正确片的问题占比 |
生成侧(需要模型或人工):
| 指标 | 含义 |
|---|---|
| 答案正确率 | 人工或 Judge 判断答对没有 |
| 忠实度 | 有没有超出给定资料乱说 |
| 引用准确率 | 标注的引用编号是否真的支撑了答案 |
测试集怎么造
- 从真实文档里挑 50-100 个有代表性的问题(覆盖简单/多跳/边界/无答案四类)
- 人工标注每个问题的正确片 id——这是最花时间但最值钱的一步
- 无答案的问题也要有,用来测”会不会硬编”
用模型批量生成问题可以省力,但必须人工过一遍,否则会生成一堆照着原文抄的伪问题,测不出真实能力。
怎么迭代
一次只改一个变量,每轮记一个数字。
| 轮次 | 改动 | Recall@5 | 端到端正确率 |
|---|---|---|---|
| 基线 | 定长 500 切片,纯向量 top3 | — | — |
| 1 | 改成按标题切 + 拼标题路径 | — | — |
| 2 | 加 BM25 混合(RRF) | — | — |
| 3 | 召回放到 30 + rerank 取 3 | — | — |
这张表填满了,就是简历上”准确率由 X% 提升至 Y%“的来源,也是面试时最有力的材料。比任何技术名词都管用。
4.12 在 CrossBuild Agent 里怎么落地
回到实际项目。这个项目的 RAG 有三个不同于常规问答的地方:
1. 检索是工具,不是固定前置
只有编译报错、模型判断自己不认识这个错误时才调 search_knowledge。平时不查,省 token 也避免噪声。详见 Agent 从零开始图解 4.10。
2. 语料天然适合按条目切
知识库内容是”报错模式 → 成因 → 解决办法”,一条就是一片,不需要定长切。这类结构化语料的检索效果远好于连续文档。
3. 检索质量有客观度量
一般 RAG 项目要人工标注才知道召回准不准。这个项目不用——加了检索之后编译成功率涨没涨,就是答案。这是这个项目最值钱的一点:
基线(无 RAG) : 20 个项目成功 X 个
加入知识库检索 : 成功 Y 个
按报错条目切 vs 按段落: Y1 vs Y2
面试时这一条要主动讲。绝大多数候选人的 RAG 指标是人工打分的,你的是编译器判的。
第五部分 动手实验
本节代码不需要 API key 也能跑(实验一到三是纯本地的),输出是真跑出来的。
5.1 实验一:从零实现 BM25,亲眼看它在哪失效
八十行实现一个能用的 BM25。先不碰向量——先理解关键词检索能做什么、不能做什么。
import math, re
from collections import Counter
DOCS = [
"交叉编译时报 cannot find -lstdc++,通常是 sysroot 里缺少目标架构的 C++ 标准库",
"CMake 报 Could NOT find Threads,需要在 toolchain 文件里设置 CMAKE_THREAD_LIBS_INIT",
"链接 aarch64 目标时出现 file format not recognized,说明混入了主机架构的 .o 文件",
"musl 和 glibc 的差异主要在动态链接器路径、locale 支持和部分 GNU 扩展函数",
"CMAKE_SYSTEM_NAME 设为 Linux、CMAKE_SYSTEM_PROCESSOR 设为 aarch64 才会进入交叉编译模式",
]
def tok(s):
return re.findall(r"[a-zA-Z_+\-]+|[一-鿿]", s.lower())
class BM25:
def __init__(self, docs, k1=1.5, b=0.75):
self.docs = [tok(d) for d in docs]
self.k1, self.b = k1, b
self.avgdl = sum(len(d) for d in self.docs) / len(self.docs)
self.df = Counter()
for d in self.docs:
for w in set(d):
self.df[w] += 1
self.N = len(self.docs)
def score(self, query, i):
d = self.docs[i]
freq = Counter(d)
s = 0.0
for w in tok(query):
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 * (self.k1 + 1) / (tf + self.k1 * (1 - self.b + self.b * len(d) / self.avgdl))
return s
def search(self, query, topk=3):
scored = [(self.score(query, i), i) for i in range(self.N)]
scored.sort(reverse=True)
return [(round(s, 3), DOCS[i]) for s, i in scored[:topk] if s > 0]
对照公式看代码,三个部分一一对应:idf 是稀有度加权,tf 是词频,分母里的 len(d)/avgdl 是文档长度归一化。
实测输出(关键词能对上的情况):
查询: 怎么让 cmake 进入交叉编译模式
9.331 CMAKE_SYSTEM_NAME 设为 Linux、CMAKE_SYSTEM_PROC... ← 正确,第一名,分数遥遥领先
3.314 交叉编译时报 cannot find -lstdc++...
1.586 CMake 报 Could NOT find Threads...
查询: aarch64 链接报错
2.485 链接 aarch64 目标时出现 file format not recognized... ← 正确
1.596 musl 和 glibc 的差异... ← 噪声
1.001 CMake 报 Could NOT find Threads... ← 噪声
实测输出(换个说法,同一个意图):
查询: 怎样开启跨平台构建
0.828 链接 aarch64 目标时出现 file format not recogni... ← 全错
0.828 交叉编译时报 cannot find -lstdc++... ← 全错
(正确答案 CMAKE_SYSTEM_NAME 那条直接掉出 top3)
这就是纯关键词检索的死穴。 问”怎么让 cmake 进入交叉编译模式”,分数 9.331 稳稳第一;换成”怎样开启跨平台构建”——意思完全一样,但”跨平台""开启""构建”这些词文档里一个都没有,正确答案直接消失。
把这个实验跑一遍,第三部分 3.4 那段口述稿你就不用背了。
5.2 实验二:RRF 融合
两路检索的分数量纲不同,用排名融合:
def rrf(rank_lists, k=60):
scores = {}
for ranking in rank_lists:
for rank, doc_id in enumerate(ranking, start=1):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)
bm25_rank = ["D3", "D1", "D5"]
vector_rank = ["D5", "D2", "D3"]
print(rrf([bm25_rank, vector_rank]))
实测输出:
BM25 排名: ['D3', 'D1', 'D5']
向量 排名: ['D5', 'D2', 'D3']
RRF 融合后 : ['D3', 'D5', 'D1', 'D2']
D3: BM25第1名 向量第3名 -> 0.032266
D5: BM25第3名 向量第1名 -> 0.032266
D1: BM25第2名 向量第None名 -> 0.016129
D2: BM25第None名 向量第2名 -> 0.016129
读懂这个输出:D3 和 D5 分数完全相同——一个是”BM25 第 1 + 向量第 3”,一个是”BM25 第 3 + 向量第 1”,对称。而只被单路召回的 D1、D2 分数正好是它们的一半。
这就是 RRF 的核心行为:两路都认可的排前面,只有一路认可的排后面,而且完全不需要处理分数量纲。k=60 的作用是压缩头部差异,如果 k 取很小(比如 1),第一名会压倒性碾压第二名。
5.3 实验三:手算余弦相似度
import numpy as np
def cosine(a, b):
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
a = np.array([1.0, 0.0, 1.0])
b = np.array([2.0, 0.0, 2.0]) # 方向相同,长度不同
c = np.array([0.0, 1.0, 0.0]) # 正交
print(cosine(a, b)) # 1.0 —— 长度不影响
print(cosine(a, c)) # 0.0 —— 完全不相关
print(np.linalg.norm(a - b)) # 1.414 —— 欧氏距离却不为 0
重点:a 和 b 方向完全一致,余弦是 1,但欧氏距离是 1.414。这就是为什么文本检索用余弦而不用欧氏——我们关心语义方向,不关心向量长度(长度往往只反映文本长短)。
向量归一化之后,余弦等于点积,所以很多向量库要求先归一化,算起来更快。
5.4 实验四:切片策略 A/B(需要 embedding,做阶段 3 时跑)
这是真正决定项目成败的实验,流程:
- 准备 30 个问题,人工标注每题的正确片 id
- 用策略 A(定长 500)建库,跑一遍,记 Recall@5
- 用策略 B(按标题切 + 拼标题路径)重建库,同一批问题再跑,记 Recall@5
- 只改这一个变量,对比数字
def recall_at_k(results, gold_ids, k=5):
hit = sum(1 for r, g in zip(results, gold_ids) if g in r[:k])
return hit / len(gold_ids)
跑出来的这两个数字,就是简历上”准确率由 X% 提升至 Y%“的来源。没有这一步,项目讲不出深度。
5.5 排查手册
| 现象 | 先查哪里 |
|---|---|
| 换个说法就找不到 | 只有向量或只有 BM25?加混合检索 |
| 查具体标识符召回一堆泛泛内容 | 纯向量的典型症状,加 BM25 |
| 检索到的片看着相关但不完整 | 切片太小或切断了语义;试父子切片 |
| topK 里有正确答案但 top3 没有 | 加 rerank |
| 资料给了但模型没用 | 资料太长?关键的放开头结尾;prompt 里强调只依据资料 |
| 模型答的和资料矛盾 | 要求标注引用编号,程序校验 |
| 多轮对话第二轮就崩 | query 改写没做,“它""这个”无法检索 |
| 表格里的答案永远找不到 | 回去看文档解析,八成解析成乱码了 |
| 改了一堆东西不知道哪个有用 | 一次只改一个变量,每轮记数字 |
第六部分 闭卷自测
基础
- 为什么不把所有文档都塞进 prompt?(4.1)
- RAG 的效果上限在检索还是生成?为什么?(3.1)
- 一整篇文档算一个向量有什么问题?(4.3)
切片
- 四种切法分别是什么?优先用哪种?(4.3)
- “把标题路径拼进片内容”解决什么问题?(4.3)
- 重叠是不是越大越好?(4.3 / 2.5)
- chunk size 调小了,还要连带调什么?(2.5)
- 父子切片解决什么矛盾?(4.3)
向量与检索
- 余弦相似度和欧氏距离,文本检索为什么选前者?(4.4 / 5.3)
- query 和 document 能用不同的 embedding 模型吗?(4.4)
- HNSW 的原理是什么?调
ef_search会影响什么?(4.5) - 向量检索的 Recall 天花板为什么不是 100%?(4.5)
- BM25 的三个组成部分是什么?(4.6)
- 举一个 BM25 失效的具体例子。(5.1)
- 两路检索的分数为什么不能直接相加?RRF 怎么解决?(4.6 / 5.2)
- RRF 里 k=60 起什么作用?(5.2)
提升召回
- Query 改写、多查询、HyDE 分别适合什么场景?(4.7)
- HyDE 为什么可能比直接用问题检索更准?风险是什么?(4.7)
- Rerank 为什么比召回准?为什么不能直接用它检索全库?(2.3 / 4.8)
- 什么情况下可以不做 rerank?(4.8)
生成与幻觉
- 幻觉的四种类型和各自的对策?(4.10)
- “允许说不知道”为什么有效?(4.9 / 4.10)
- 为什么关键资料要放开头或结尾?(4.9)
- 检索分数都很低时应该怎么办?(4.10)
评测
- 检索侧和生成侧的指标分别有哪些?为什么先看检索侧?(4.11)
- 测试集怎么构造?为什么要包含”无答案”的问题?(4.11)
- LLM-as-Judge 怎么用才靠谱?(1.3)
- 为什么强调”一次只改一个变量”?(4.11)
选型与项目
- pgvector 和 Milvus 怎么选?(2.4 / 3.7)
- RAG、长上下文、微调、工具调用分别解决什么?查订单号该用哪个?(2.6)
- 你的项目里 RAG 的成功指标是什么?为什么它比人工打分可信?(4.12)
附:相关资料
- Agent 侧:Agent 从零开始图解
- 专题深挖:
ai/03-RAG/(切片与召回、Embedding 与向量索引、查询改写与 Rerank、长上下文与递归 RAG、RAG 系统设计面试题) - 评测专题:
ai/05-Eval与观测/ - 项目落地:
docs/crossbuild-agent-plan.md阶段 3 与阶段 4