PERSONAL LAB / interview/从零开始图解
Interview 从零开始图解

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

看图,记五句话:

  1. RAG 不是一个模型,是一条流水线。 效果差的时候,先定位是哪一环出的问题,而不是去改 prompt。
  2. 离线那半边决定了在线那半边的上限。 切片切坏了,后面再怎么调都救不回来。
  3. 检索是”召回 + 精排”两步。 召回要快要全(topK 取 20-50),精排要准(topN 取 3-5)。
  4. 模型只能看到你塞进 prompt 的那几段。 没检索到 = 一定答不对,这是最常见的失败原因。
  5. “模型答错了”要先查是没检索到、检索到了没排上去、还是排上去了但被淹没。 三种病三种药。

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~50topN = 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 文档解析:最容易被低估的一环

垃圾进垃圾出。这一环丢掉的东西,后面任何手段都补不回来。

文档类型
PDF分栏顺序错乱、表格变成乱序文本、页眉页脚混进正文
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 进入交叉编译模式?

四个要点:

  1. 每条带编号和来源,才能要求引用、才能校验
  2. 明确允许说不知道,这一条能砍掉一大块编造
  3. 最相关的放开头或结尾——长上下文中间的内容容易被忽略
  4. 控制总长度,资料不是越多越好,噪声会稀释注意力

4.10 降幻觉:先分类,再对症

类型根因对策
没依据的编造压根没检索到改检索(混合、改写、rerank)
有依据但不采信模型先验太强要求引用出处 + 强调”只依据资料”
被淹没资料太长、关键信息在中间减少 topK、调整顺序、压缩
跨片拼接出错答案需要综合多片父子切片、增大 chunk、多跳检索

再加一道兜底:检索最高分低于阈值时,直接回复”没找到相关资料”,不进生成。这比让模型硬答强得多,也是实际产品里的标准做法。

面试答法:不要只说”写好 prompt”。先说分类,再说每类的对策,最后说兜底。

4.11 评测:这一节决定你的项目能不能拿分

两层指标

检索侧(自动化,便宜,改切片先看这个):

指标含义
Recall@K正确片在不在前 K 个里。最重要
MRR正确片排名的倒数的平均,衡量排得多靠前
命中率至少召回一条正确片的问题占比

生成侧(需要模型或人工):

指标含义
答案正确率人工或 Judge 判断答对没有
忠实度有没有超出给定资料乱说
引用准确率标注的引用编号是否真的支撑了答案

测试集怎么造

  1. 从真实文档里挑 50-100 个有代表性的问题(覆盖简单/多跳/边界/无答案四类)
  2. 人工标注每个问题的正确片 id——这是最花时间但最值钱的一步
  3. 无答案的问题也要有,用来测”会不会硬编”

用模型批量生成问题可以省力,但必须人工过一遍,否则会生成一堆照着原文抄的伪问题,测不出真实能力。

怎么迭代

一次只改一个变量,每轮记一个数字。

轮次改动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 时跑)

这是真正决定项目成败的实验,流程:

  1. 准备 30 个问题,人工标注每题的正确片 id
  2. 用策略 A(定长 500)建库,跑一遍,记 Recall@5
  3. 用策略 B(按标题切 + 拼标题路径)重建库,同一批问题再跑,记 Recall@5
  4. 只改这一个变量,对比数字
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 改写没做,“它""这个”无法检索
表格里的答案永远找不到回去看文档解析,八成解析成乱码了
改了一堆东西不知道哪个有用一次只改一个变量,每轮记数字

第六部分 闭卷自测

基础

  1. 为什么不把所有文档都塞进 prompt?(4.1)
  2. RAG 的效果上限在检索还是生成?为什么?(3.1)
  3. 一整篇文档算一个向量有什么问题?(4.3)

切片

  1. 四种切法分别是什么?优先用哪种?(4.3)
  2. “把标题路径拼进片内容”解决什么问题?(4.3)
  3. 重叠是不是越大越好?(4.3 / 2.5)
  4. chunk size 调小了,还要连带调什么?(2.5)
  5. 父子切片解决什么矛盾?(4.3)

向量与检索

  1. 余弦相似度和欧氏距离,文本检索为什么选前者?(4.4 / 5.3)
  2. query 和 document 能用不同的 embedding 模型吗?(4.4)
  3. HNSW 的原理是什么?调 ef_search 会影响什么?(4.5)
  4. 向量检索的 Recall 天花板为什么不是 100%?(4.5)
  5. BM25 的三个组成部分是什么?(4.6)
  6. 举一个 BM25 失效的具体例子。(5.1)
  7. 两路检索的分数为什么不能直接相加?RRF 怎么解决?(4.6 / 5.2)
  8. RRF 里 k=60 起什么作用?(5.2)

提升召回

  1. Query 改写、多查询、HyDE 分别适合什么场景?(4.7)
  2. HyDE 为什么可能比直接用问题检索更准?风险是什么?(4.7)
  3. Rerank 为什么比召回准?为什么不能直接用它检索全库?(2.3 / 4.8)
  4. 什么情况下可以不做 rerank?(4.8)

生成与幻觉

  1. 幻觉的四种类型和各自的对策?(4.10)
  2. “允许说不知道”为什么有效?(4.9 / 4.10)
  3. 为什么关键资料要放开头或结尾?(4.9)
  4. 检索分数都很低时应该怎么办?(4.10)

评测

  1. 检索侧和生成侧的指标分别有哪些?为什么先看检索侧?(4.11)
  2. 测试集怎么构造?为什么要包含”无答案”的问题?(4.11)
  3. LLM-as-Judge 怎么用才靠谱?(1.3)
  4. 为什么强调”一次只改一个变量”?(4.11)

选型与项目

  1. pgvector 和 Milvus 怎么选?(2.4 / 3.7)
  2. RAG、长上下文、微调、工具调用分别解决什么?查订单号该用哪个?(2.6)
  3. 你的项目里 RAG 的成功指标是什么?为什么它比人工打分可信?(4.12)

附:相关资料

  • Agent 侧:Agent 从零开始图解
  • 专题深挖:ai/03-RAG/(切片与召回、Embedding 与向量索引、查询改写与 Rerank、长上下文与递归 RAG、RAG 系统设计面试题)
  • 评测专题:ai/05-Eval与观测/
  • 项目落地:docs/crossbuild-agent-plan.md 阶段 3 与阶段 4
Related · 从零开始图解
⎇ main interview/从零开始图解 60 节 253 notes UTF-8