本篇讲透两个问题:RAG 到底在解决什么,以及从 Embedding 到 Chunk 到增强检索的整条链路,每一步的坑在哪。所有代码节选都能在配套仓库
agent-10-rag/rag.py里跑出来,文末给脚本对应表。
一、导语:RAG 不是「检索一下塞进 prompt」
如果面试官问「什么是 RAG」,你答「把文档切块、向量检索、Top-K 塞进 prompt」,这个答案及格但拿不到高分。因为这句话描述的是操作,不是原理。真正的分水岭在于你有没有意识到:
RAG 的本质,是把「知识」从模型权重里拆出来,变成一种可以随时增删改查的外部资产。
模型权重里的知识是参数化记忆(parametric memory):训练时烧进参数、无法定点修改、无法溯源、永远滞后、还会随训练被抹掉。检索库里的知识是非参数化记忆(non-parametric memory):改一条是一条、能标出处、能随时热更新、不占参数预算。
这两者的分工,才是 RAG 存在的理由。所以本篇不按「怎么做 RAG」来组织,而按「每走一步,你放弃了什么、换来了什么」来组织。这四步分别是:选型(要不要 RAG)→ 表示(怎么把文本变向量)→ 切分(怎么把文档切块)→ 增强(怎么让 Top-K 真的有用)。
二、问题来源:为什么非得有「外部知识」这一层
面试里最容易被追问的是这句话:「现在模型上下文都 1M 了,还需要 RAG 吗?」
这是一个好问题,因为它逼你把 RAG 的价值链条拆开。RAG 提供的东西里,只有一部分是「把文字塞进上下文」——这部分确实会被长上下文替代。但另外三部分长上下文永远替代不了:
| RAG 实际提供的 | 长上下文能替代吗 | 说明 |
|---|---|---|
| 补充事实进上下文 | ✅ 能 | 上下文够大,全文塞进去也行 |
| 可寻址(能定位到源文档/段落) | ❌ 不能 | 出处是检索库的属性,塞进上下文就糊了 |
| 可更新(改一条立刻生效) | ❌ 不能 | 权重和上下文都不支持定点热更 |
| 可授权(按用户/租户做过滤) | ❌ 不能 | 权限边界在检索层,不在模型层 |
| 成本可控(只取相关片段) | ❌ 不能 | 1M 上下文每次全塞,成本是线性爆炸的 |
所以正确的回答是:长上下文替代的是「事实塞入」,替代不了「知识的可寻址、可更新、可授权、成本可控」。 只有当你的知识是静态的、小规模的、不需要授权隔离的,长上下文才可能真的替代 RAG。
同理,微调替代的是「行为、风格、格式、领域语感」,而不是「事实」。想让模型记住「我们公司的退货政策是 7 天」,微调是最差的选择——改不动、查不出、换不了。这类知识应该走 RAG。反过来,想让模型说话像你们客服的口吻、稳定输出某种 JSON 结构,微调才合适。
一句话选型口诀:
- 知识要更新/要溯源/要授权 → RAG
- 行为要固化/要风格/要格式稳定 → 微调
- 知识静态 + 量小 + 无需隔离 → 长上下文直塞
三者不是互斥的,生产系统常常是「微调做行为底座 + RAG 做事实供给 + 长上下文做当轮工作台」。
三、讲透 Q1:Embedding 是双塔吗?Cosine 到底在算什么?
这是 RAG 里最容易被「说得头头是道但一追问就崩」的地方。
3.1 Embedding 的本质是「把语义压成一个可比的距离」
Embedding 做的事,是把一段文本映射成一个稠密向量 v ∈ R^d,使得语义相近的文本在向量空间里距离近。这个「距离近」是训练出来的,不是天生的。
关键认知:相似度度量必须和模型训练时用的那个一致。因为训练目标是在优化「用这个度量算出来的排序」,你换一个度量,等于换了一个优化目标之外的标准,排序会失真。
- 训练用余弦相似度(向量做归一化、只看夹角)的模型 → 检索就必须用 cosine,不能换成欧氏距离;
- 训练用点积的模型 → 就必须用点积,因为点积里混入了向量的长度信息;
- 少部分模型训练时就用欧氏距离 → 那就用欧氏。
所以面试里被问「为什么用 cosine」,正确答法是:「不是 cosine 更优,而是我用的这个 embedding 模型是拿 cosine 训的,我得跟它对齐。」 这句话一出口,层次就出来了。
余弦相似度的定义:
cos(a, b) = (a · b) / (|a| × |b|)
它算的是两个向量的夹角——把长度归一化掉,纯粹看方向。对于文本语义,方向比长度更有意义(长度常常只是词数/信息量的副产品)。这也是为什么检索前通常先把向量归一化,然后余弦相似度就等于点积,能直接用向量库的点积索引(内积索引比余弦快得多)。
3.2 双塔(bi-encoder)与非对称检索
生产检索几乎都是双塔结构:Query 一个塔(encoder),Document 一个塔(另一个或共享 encoder),两侧各自独立编码成向量,只在最后用 cosine 比。为什么不把 query 和 doc 拼起来一起编码(那是 cross-encoder,也就是 Reranker 干的活)?
因为双塔可以预计算。文档库里的上百万条 doc 向量,可以离线算好存进向量索引;线上来一个 query 只编码一次,然后做一次近似最近邻(ANN)搜索。而 cross-encoder 必须把「query + 每个 doc」成对送进模型,无法预计算,只能对小候选集做精排——这就是 Reranker 的定位。
双塔带来一个必须注意的现象:非对称检索。query 通常是「一个问题」(短、口语、缺上下文),doc 通常是「一段陈述」(长、书面、完整)。这俩在语义空间里的分布是不一样的。好一点的 embedding 模型会为两侧用不同的编码策略(比如 query 前缀指令、或者干脆双塔不共享参数)来对齐这个不对称性。很多「检索效果差」的根因就出在这里:你拿一个对称模型去做非对称任务。
3.3 一个诚实的提醒
本文配套代码 agent-10-rag/rag.py 里,为了不依赖任何模型下载、纯本地可跑,用的是一个玩具向量器——字符二元组哈希 + 256 维。它不是真 embedding,只能演示「机制」,不能用来评估效果。凡是演示里出现的命中数,都只用于说明「有增强 vs 没增强」的方向性差异,不能当作 benchmark。真做 RAG,embedding 模型选型本身就是一个独立课题(多语言、领域适配、维度与成本的权衡)。
四、讲透 Q2:Chunk 该切多大?为什么只做 Top-K 一定不够?
4.1 Chunk 的核心矛盾
Chunk 这件事有一个无法同时满足的三角:
- 语义完整性:一个块应该是一个完整的意思单元(别把一句话、一个条款拦腰砍断)。
- 噪声稀释:一个块塞进去的内容越杂,向量越「平均」,检索时越难被精准命中。
- 上下文够用:模型拿到这个块,得能回答得了问题(块太小可能缺关键上下文)。
切大 → 语义完整 ✅、上下文够 ✅,但噪声稀释 ❌(向量变「糊」,检索命中率下降)。 切小 → 噪声少 ✅、向量准 ✅,但语义断裂 ❌、上下文不够 ❌。
所以「Chunk 该切多大」没有标准答案。面试里标准的扣分答案是「中文 300–800 token,overlap 10–20%」——这是起点经验值,不是答案。真正的高分答案是:
切分策略是超参,必须用评估集(问题 → 应有答案 → 命中标注)去调,而不是凭感觉定。
你要能说出:为什么这个语料该切这个大小?因为它的「语义单元」平均就是这么大。代码文档的语义单元是「一个函数」,法律合同是「一个条款」,产品手册是「一个小节」——切分粒度应该对齐语料本身的自然边界,而不是对齐一个固定的 token 数。
4.2 四种切分策略
配套代码实现了四种,各有适用场景:
| 策略 | 做法 | 适合 | 代价 |
|---|---|---|---|
| 固定长度 + overlap | 每 N 字符切一刀,相邻块重叠 M 字符 | 无结构纯文本、快速起步 | 会切断语义;overlap 带来冗余存储 |
| 递归结构切分 | 按 \n\n → \n → 句号 → 逗号 逐级降级切 | Markdown/代码/有层次结构文档 | 结构不规范时退化成固定长度 |
| 语义切分 | 用相邻句向量相似度找「语义断点」 | 段落松散、话题漂移的长文 | 慢、依赖 embedding 质量 |
| 父子块 small-to-big | 子块(小)做检索、父块(大)做喂入 | 既想检索准又想上下文全 | 需要维护父子映射;存储翻倍 |
递归切分是性价比最高的默认选择——它优先在「最粗的结构边界」(空行、段落)切,切不动了才降到「句」「逗号」,尽量让每个块落在自然边界上。
small-to-big 是解矛盾的关键技巧:用小块去检索(小块向量纯净、命中准),命中之后返回它所属的大块给模型(大块上下文完整)。检索粒度 ≠ 喂给模型的粒度,这两个解耦,就把 4.1 的三角矛盾拆掉了大半。
4.3 简单 Top-K 的三个病灶
就算 Chunk 切得完美,只做「embedding → Top-K」仍然会在三类问题上翻车:
病灶一:措辞敏感。 用户问「怎么退钱」,文档里写的是「退款流程」——如果模型没把「退钱/退款」对齐好,向量距离就会偏。同义改写、口语化、跨语言的表述,纯稠密检索很脆。
病灶二:精确匹配弱。 用户问「错误码 E1042 怎么解决」,稠密向量会把「E1042」这种罕见 token 的信息糊掉——因为它没见过几次,学不出好表示。而倒排索引(BM25)对「精确 token」是天生强的。
病灶三:多跳无力。 用户问「A 和 B 的关系是什么」,答案分散在文档 1 和文档 3 里。Top-K 一次检索只返回「跟 query 最像的块」,它没法「先找 A、再拿 A 去查 B」。这需要迭代检索(第 11 期会展开)。
三个病灶对应四种增强——别乱上,要对症:
| 增强手段 | 治哪个病灶 | 机制 |
|---|---|---|
| Hybrid Search(稠密 + BM25 倒排) | 病灶②(精确匹配弱) | 两路各召回,互补 |
| RRF(Reciprocal Rank Fusion) | 融合问题(多路结果怎么合) | 按排名而非分数融合,免去分数不可比问题 |
| Reranker(cross-encoder 精排) | 病灶①(措辞敏感、排序不准) | 对 Top-N 候选做 query-doc 联合编码,重排前 K |
| Query Rewrite | 病灶①③(口语、多跳) | 把 query 改写成更好的检索式/拆成子问题 |
RRF 为什么按排名而不按分数融合?因为稠密检索的 cosine 分数和 BM25 的分数量纲完全不同(一个 01,一个 0几十,还随数据集漂移)。直接加权求和要先归一化,而归一化本身很脆。RRF 绕开这个问题:不看分数,只看「在各自列表里排第几」,公式是 score = Σ 1/(k + rank)(k 常取 60)。简单、鲁棒、无需调参,这是它在工业界流行的原因。
五、从机制到工程:检索归因
RAG 效果不好,90% 的人第一反应是「换个更强的生成模型」。这是错的。正确的归因顺序是:
先看召回,再看生成。
因为生成模型再强,也变不出你没检索到的信息。如果正确文档压根没进 Top-K,那再好的 LLM 也只能瞎编。所以排障顺序是:
- 召回层:正确文档在不在 Top-K 里?
- 不在 → 问题在 chunking / embedding / 检索策略(加 Hybrid、加 rewrite、调 chunk)
- 在但排太后 → 问题在排序(加 Reranker)
- 生成层:正确文档在 Top-K 里,模型却答错了?
- 这才是生成模型的问题(prompt 组织、上下文过长导致的中段遗忘、指令跟随差)
配套代码的 demo_attribution 就是这个思路的演示:先打印「正确块是否入召回」,再决定要不要怀疑生成环节。不做这步归因,你会花 90% 时间调错的东西。
一个常见的隐蔽病灶:上下文过长导致的中段遗忘(lost in the middle)。你为了「保险起见」把 Top-20 全塞进去,结果模型只关注开头和结尾,中间的正确块被忽略了。这时候正确做法是减少喂入(Reranker 精排 + 只喂 Top-3),而不是加更多。
六、深入讨论:RAG 的评估怎么做
面试到 L3,几乎必被追问「你怎么知道你的 RAG 变好了」。不能只说「感觉准了」。标准的三层评估指标:
| 层次 | 指标 | 怎么测 |
|---|---|---|
| 召回层 | Recall@K、MRR、命中率 | 人工标注「问题 → 正确块」,离线跑 |
| 生成层 | 忠实度(faithfulness)、答案相关性 | LLM-as-judge 或人工,判「答案是否只依据召回内容」 |
| 端到端 | 任务成功率、人工抽检 | 线上 A/B |
关键点:召回和生成必须分开测。因为一个 RAG 系统「答案对」可能有两种完全不同的原因——蒙对的(召回没中但模型瞎编对了)和真答对的(召回中了且答案忠实)。不分开测,你根本不知道该修哪层。
还有一个必备指标:忠实度 / 幻觉率。RAG 的一大卖点就是「可溯源」,但如果模型拿着检索内容还瞎编,那 RAG 等于白做。所以要能判「这句话有没有出处」。
七、常见错误认识
- 「RAG 就是检索一下塞 prompt」 —— 操作层面没错,但漏掉了 RAG 真正的价值:可寻址、可更新、可授权、成本可控。
- 「上下文变长了,RAG 就没用了」 —— 长上下文替代不了知识资产的这四个属性,只能替代「事实塞入」。
- 「相似度用 cosine 因为它是标准」 —— 不存在「标准」,只有「和你的模型训练目标对齐」。换度量 = 换评估标准 = 排序失真。
- 「chunk 切成 512 是行业标准」 —— 512 是起点不是标准。粒度应对齐语料的自然语义边界,并用评估集调。
- 「Top-K 效果不好就换更强的 embedding」 —— 先归因:是召回没中还是排序靠后?换模型是最后手段。
- 「检索效果差就上 Reranker」 —— Reranker 只在「召回里已经有了、只是排序不对」时有用。召回都没中,Reranker 无从下手。
- 「把所有 Top-20 都塞进去更保险」 —— 会触发 lost in the middle,反而降低质量。精排后少喂更好。
八、概念速查
| 概念 | 一句话 |
|---|---|
| 参数化记忆 | 烧进模型权重的知识,改不动、查不出、会滞后 |
| 非参数化记忆 | 存外部检索库的知识,可增删改查、可溯源、可授权 |
| Embedding | 把文本映射成稠密向量,使语义近的向量距离近 |
| 双塔 / bi-encoder | Query 与 Doc 各自独立编码,可预计算,支持 ANN 检索 |
| Cross-encoder | query+doc 联合编码,精度高但不能预计算,用于 Reranker |
| 非对称检索 | Query(短、口语)与 Doc(长、书面)分布不同,需对齐 |
| 语义完整性 vs 噪声稀释 | 切分粒度的核心矛盾,切大糊、切小断 |
| small-to-big | 小块检索、大块喂入,解耦检索粒度与上下文粒度 |
| BM25 | 基于词频与逆文档频率的稀疏倒排检索,精确 token 强 |
| Hybrid Search | 稠密 + 稀疏两路召回并融合 |
| RRF | 按排名(而非分数)融合多路结果,免归一化 |
| Reranker | 对候选做 cross-encoder 精排,提升排序质量 |
| Query Rewrite | 改写/拆解 query,改善检索式质量 |
| Recall@K / MRR | 召回层评估指标 |
| 忠实度 / faithfulness | 生成答案是否只依据召回内容,反幻觉指标 |
九、动手实验
配套仓库:agent-developer-interview(分支 master),脚本 agent-10-rag/rag.py。
核心代码节选
① 双塔相似度:余弦 = 归一化后的点积
def cosine(a, b):
na = math.sqrt(sum(x * x for x in a)) or 1.0
nb = math.sqrt(sum(x * x for x in b)) or 1.0
return sum(x * y for x, y in zip(a, b)) / (na * nb)
def retrieve(query, chunks, k=3):
qv = embed(query)
scored = [(cosine(qv, embed(c)), c) for c in chunks]
scored.sort(key=lambda t: t[0], reverse=True)
return scored[:k]
注意这里每来一个 query 都重新 embed(c),真实系统里 chunk 向量是预计算存索引的——这正是双塔能上线的关键。
② 递归结构切分:优先落在自然边界
def chunk_recursive(text, max_len=120, seps=("\n\n", "\n", "。", ";", ",")):
if len(text) <= max_len:
return [text]
for sep in seps:
if sep in text:
parts, buf = [], ""
for piece in text.split(sep):
cand = buf + sep + piece if buf else piece
if len(cand) > max_len and buf:
parts.append(buf); buf = piece
else:
buf = cand
if buf:
parts.append(buf)
return [p for p in parts if p.strip()]
return [text[i:i + max_len] for i in range(0, len(text), max_len)]
先试最粗的 \n\n,不行降到 \n、句号……最后兜底才是硬切。
③ 父子块 small-to-big:小块检索、大块喂入
def chunk_small_to_big(text, child_len=60, parent_len=180):
parents = chunk_recursive(text, max_len=parent_len)
mapping = [] # (child, parent)
for p in parents:
for c in chunk_recursive(p, max_len=child_len):
mapping.append((c, p))
return mapping
④ RRF:按排名融合,不碰分数
def rrf(rank_lists, k=60):
score = {}
for lst in rank_lists:
for rank, item in enumerate(lst):
score[item] = score.get(item, 0.0) + 1.0 / (k + rank)
return sorted(score.items(), key=lambda t: t[1], reverse=True)
脚本对应表
| 文章小节 | 演示函数 | 看什么 |
|---|---|---|
| 四种切分 | demo_chunking | 同一段文本,四种策略切出来的块长与边界 |
| Top-K 三病灶 | demo_topk_failure | 措辞改写后稠密检索命中下滑;精确 token 检索偏弱 |
| 四种增强 | demo_enhancements | Hybrid / RRF / Reranker / Rewrite 各自把命中拉回来的方向 |
| 归因顺序 | demo_attribution | 先打印「正确块是否入召回」,再决定怀疑哪一层 |
⚠️ 再次强调:脚本用的是玩具向量器,命中数只用于演示方向性差异,不是 benchmark。真做 RAG,请用真实 embedding 模型 + 你自己的评估集。
改一改再跑
- 把
chunk_recursive的seps去掉","(不再在最细的逗号处切),看块长分布怎么变——体会「粗边界优先」为什么重要。 - 在
demo_enhancements里关掉 RRF,改成「两路分数直接相加」,看排序结果是否变得不稳定——体会 RRF 为什么按排名。 - 把
retrieve的k从 3 改成 10,再在demo_attribution里观察「中段块」被忽略的现象——这就是 lost in the middle 的简化版。
十、延伸阅读
站内:
- 第 05 期《上下文工程》:检索的结果最终要进上下文,拼接顺序与裁剪策略决定了「检索对了但模型没用上」。
- 第 11 期《架构取舍》:多跳检索 → Agentic RAG;碎片检索 vs 预编译 Wiki(什么场景该放弃 RAG)。
站外:
- Lewis et al., Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks(RAG 的原始论文,2020)。
- 各向量库官方文档:HNSW / IVF 等 ANN 索引原理。
- Cormack et al., Reciprocal Rank Fusion(RRF 原始论文)。
十一、一句话总结
RAG 的本质是把知识从权重里拆出来变成可寻址、可更新、可授权的资产;选型看知识属性,切分对齐语义边界,增强要对症下药,排障永远先看召回再看生成。
