直觉:把"开卷考试"接到大模型上

大模型的知识冻结在训练时刻的权重里,更新一次代价高昂,而且它无法凭空知道你公司内网的文档。RAG(Retrieval-Augmented Generation)的核心思路非常朴素:别让模型靠记忆答题,给它一份相关材料,让它开卷考试。

但"开卷"这件事在工程上远比听起来复杂。它不是简单地把文档塞进 prompt,而是一条完整的数据流水线:切分、向量化、检索、重排、拼接、生成。每一环都有自己的失败模式。下面我们逐段拆开。

机制:一条完整的 RAG 数据流

整条链路分**离线(建库)在线(查询)**两个阶段。

离线阶段:

1
原始文档 → 清洗 → 切分(chunking) → embedding 模型 → 向量 + 原文 → 写入向量库

在线阶段:

1
用户 query → embedding → 向量检索(召回 top-k) → 重排(rerank top-n) → 拼 prompt → LLM 生成

切分(chunking):被低估的关键一步

切分粒度直接决定召回质量。chunk 太大,单个块里混入大量无关内容,稀释了语义向量,也浪费 LLM 的 context;太小,又会切断上下文,导致一个完整论述被劈成两半,检索时只命中一半。

常见策略:

  • 固定窗口 + overlap:每 chunk 约 256~512 token,相邻 chunk 重叠 10%~20%,避免边界处语义断裂。
  • 按结构切分:沿 Markdown 标题、段落、代码块边界切,保持语义完整性。
  • 父子分块:用小 chunk 做检索(语义精准),命中后返回它所属的大 chunk(上下文完整)。这是实践中性价比很高的技巧。

向量化与检索:相似度的数学

embedding 模型把一段文本映射成一个 dd 维向量(典型 dd 在数百到数千量级)。检索时计算 query 向量 qq 与每个文档向量 viv_i 的相似度,最常用余弦相似度:

sim(q,vi)=qviqvi\text{sim}(q, v_i) = \frac{q \cdot v_i}{\|q\|\,\|v_i\|}

若向量已做 L2 归一化(v=1\|v\|=1),余弦相似度退化为内积,计算更快。暴力检索是 O(Nd)O(N \cdot d),当 NN 达到百万、千万级时无法接受,因此生产中用 ANN(近似最近邻) 索引,例如 HNSW(分层可导航小世界图)。HNSW 把搜索复杂度从 O(N)O(N) 降到约 O(logN)O(\log N),代价是召回率略低于 100%("近似"二字的来源)和较高的内存占用——图结构本身要存边。

重排(rerank):召回与精排的分工

为什么召回之后还要重排?因为向量检索用的是双塔(bi-encoder):query 和 document 各自独立编码成向量,再算相似度。双塔快,可离线预算文档向量,但 query 和 document 之间没有交互,精度有上限。

rerank 用的是交叉编码器(cross-encoder):把 [query, document] 拼在一起送进模型,输出一个相关性打分。两者的 token 在每一层 attention 里充分交互,精度高得多,但无法预计算——每个 query-doc 对都要现算,所以慢。

工程上的标准做法是两级流水:

1
2
bi-encoder  召回 top-50   (快,覆盖广)
cross-encoder 精排 top-5 (慢,但只对 50 个候选算)

用便宜的召回换覆盖率,用昂贵的精排换准确率,把 cross-encoder 的计算量限制在小集合上。

代码:一个最小可用的 RAG 骨架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def rag_answer(query, vector_db, reranker, llm, k=50, n=5):
# 1. query 向量化并召回
q_vec = embed(query)
candidates = vector_db.search(q_vec, top_k=k) # ANN, 返回 (doc, score)

# 2. cross-encoder 重排
scored = [(doc, reranker.score(query, doc.text)) for doc, _ in candidates]
scored.sort(key=lambda x: x[1], reverse=True)
top_docs = [doc for doc, _ in scored[:n]]

# 3. 拼 prompt,注意把检索内容和指令分隔清楚
context = "\n\n---\n\n".join(d.text for d in top_docs)
prompt = (
f"仅依据以下资料回答,无法从资料得出则回答\"不知道\"。\n"
f"资料:\n{context}\n\n问题: {query}"
)

# 4. 生成,并附带引用来源
answer = llm.generate(prompt)
return answer, [d.source for d in top_docs]

注意第 3 步那句 prompt 约束——它是抑制幻觉的关键防线,明确告诉模型"没有依据就别编"。

工程权衡与踩坑

1. 召回不到 ≠ 模型不行。 RAG 的失败大多在检索端而非生成端。如果答案根本没被召回,再强的 LLM 也无能为力。调试 RAG 时第一步永远是:把召回的 chunk 打印出来人工看,确认答案是否在里面。

2. 纯向量检索打不过关键词。 向量擅长语义相似,但对精确匹配(产品型号、错误码、人名)反而不如传统 BM25。生产系统普遍用混合检索:向量召回 + BM25 召回,再融合。常见融合方法 RRF(Reciprocal Rank Fusion)只看排名不看分数,避免两套打分量纲不一致的问题:

RRF(d)=rretrievers1k+rankr(d)\text{RRF}(d) = \sum_{r \in \text{retrievers}} \frac{1}{k + \text{rank}_r(d)}

其中 kk 是平滑常数(常取 60),rankr(d)\text{rank}_r(d) 是文档 dd 在第 rr 个检索器里的排名。

3. context 不是越多越好。 把召回的 50 个 chunk 全塞进去,既烧 token 又触发"中间遗忘"(lost in the middle)——模型对长上下文里居中位置的信息利用率明显下降。所以才要重排后只取 top-n,把最相关的放在头尾。

4. embedding 模型和 query 必须同源。 建库用的 embedding 模型一旦更换,整个向量库必须重建,否则 query 向量和库里的文档向量不在同一语义空间,检索结果是噪声。

5. 切分时机决定一切。 表格、代码、公式被粗暴切断是常见事故。建库前务必针对你的文档类型定制 chunking,别迷信"一刀切 512 token"。

小结

RAG 不是"给 LLM 喂文档"这么一句话,而是一条召回-精排-生成的流水线。它的工程精髓在于用快而粗的双塔召回保证覆盖率,用慢而准的交叉编码器精排保证准确率,再用混合检索弥补语义向量在精确匹配上的短板。当你的 RAG 效果不佳时,按"切分 → 召回 → 重排 → prompt"的顺序逐段排查,比盲目换更大的生成模型有效得多。