Agent 面试题

RAG:Embedding、Chunk 与检索增强

RAG:Embedding、Chunk 与检索增强

本篇讲透两个问题: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 这件事有一个无法同时满足的三角:

  1. 语义完整性:一个块应该是一个完整的意思单元(别把一句话、一个条款拦腰砍断)。
  2. 噪声稀释:一个块塞进去的内容越杂,向量越「平均」,检索时越难被精准命中。
  3. 上下文够用:模型拿到这个块,得能回答得了问题(块太小可能缺关键上下文)。

切大 → 语义完整 ✅、上下文够 ✅,但噪声稀释 ❌(向量变「糊」,检索命中率下降)。 切小 → 噪声少 ✅、向量准 ✅,但语义断裂 ❌、上下文不够 ❌。

所以「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 也只能瞎编。所以排障顺序是:

  1. 召回层:正确文档在不在 Top-K 里?
    • 不在 → 问题在 chunking / embedding / 检索策略(加 Hybrid、加 rewrite、调 chunk)
    • 在但排太后 → 问题在排序(加 Reranker)
  2. 生成层:正确文档在 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-encoderQuery 与 Doc 各自独立编码,可预计算,支持 ANN 检索
Cross-encoderquery+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_enhancementsHybrid / RRF / Reranker / Rewrite 各自把命中拉回来的方向
归因顺序demo_attribution先打印「正确块是否入召回」,再决定怀疑哪一层

⚠️ 再次强调:脚本用的是玩具向量器,命中数只用于演示方向性差异,不是 benchmark。真做 RAG,请用真实 embedding 模型 + 你自己的评估集。

改一改再跑

  1. 把 chunk_recursive 的 seps 去掉 ","(不再在最细的逗号处切),看块长分布怎么变——体会「粗边界优先」为什么重要。
  2. 在 demo_enhancements 里关掉 RRF,改成「两路分数直接相加」,看排序结果是否变得不稳定——体会 RRF 为什么按排名。
  3. 把 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 的本质是把知识从权重里拆出来变成可寻址、可更新、可授权的资产;选型看知识属性,切分对齐语义边界,增强要对症下药,排障永远先看召回再看生成。

AI Agent面试RAGEmbedding

← 返回项目