第23回 检索增强生成(RAG)基础——分块、嵌入与检索
书库万卷不嫌多,关键一页要寻着。
若无证据凭空说,十句像真九句错。
上两回把智能体的“手脚”讲明白了:
会调用工具,才算能办事。
而智能体最常用、最稳当的一把工具,便是“查资料”。
江湖里给它起了个响亮名字:检索增强生成(RAG)。
RAG 的朴素愿望很简单:
- 模型不靠记忆硬背
- 而是先去资料库找证据
- 再拿证据来回答
- 还能把证据引用出来,给人核查
2024–2025 的多篇综述把 RAG 视为应对“幻觉、过时知识、不可追溯”的核心路线之一,并把系统拆成可控组件来讨论。12
这一回,我们不求玄妙,只把 RAG 的“最低配”跑通:
分块、向量化、相似度检索、拼上下文、生成回答。
一、RAG 的四步:切、嵌、找、写
把 RAG 写成四个动作:
- 分块(Chunking):把长文切成一段段
- 嵌入(Embedding):把每段变成向量
- 检索(Retrieve):用相似度找最相关的几段
- 生成(Generate):把检索到的段落喂给模型,让它据此回答
你会发现,这四步都是“可替换模块”。
第三篇后面几回,几乎就是在这四步上做升级:
- 第25回升级“切”之前:先把问题改写得更适合检索
- 第26回升级“找”:混合检索、重排序
- 第27回升级“写”:让模型更会引用、更会自检
- 第28回升级“跑”:并行、缓存、隐私
- 第29回讨论“还需不需要找”:长上下文来挑战 RAG
二、分块:不是越短越好,也不是越长越好
分块像切菜:
- 切太碎:每块信息不完整,检索到也用不上
- 切太大:每块里杂质太多,检索命中却难定位关键句
最朴素的分块策略就两条:
- 按固定长度切(例如每 200 字一段)
- 或按自然段/标题切(让每段自洽)
真正工程里还有“重叠窗口”:
让相邻块共享一小段内容,防止关键句被一刀切断。
这一回我们用最简单的“按句子分块”,只为把流程跑起来。
三、嵌入与相似度:用高二数学够用的直觉
第01回讲过:
向量是“把东西放进空间”的办法。
RAG 的嵌入,就是把每段文字变成一个向量。
检索时,就用余弦相似度找“角度最接近”的段落。
高二直觉版解释:
- 向量越指向同一方向,越相似
- 夹角越大,越不像
我们不用任何第三方库,也能写一个“袋子模型”的嵌入:
- 用词频当向量坐标
- 用余弦相似度做检索
它不先进,但能让你摸到 RAG 的骨架。
四、极简可跑代码:从零实现一个玩具 RAG 检索器
下面代码实现:
- 把文档切成句子(chunk)
- 用词频向量表示(embedding)
- 用余弦相似度检索 top-k
- 把命中的句子拼成“证据上下文”
import math
import re
DOC = """
RAG 的关键是外部证据。模型先检索,再回答,最后最好能给引用。
分块会影响检索质量:太短信息不全,太长噪声太多。
相似度检索常用余弦相似度:向量夹角越小,越相似。
工具调用要可校验:参数格式要严格,否则容易失败。
长上下文并不必然取代 RAG:检索仍能节省成本并提升可控性。
""".strip()
def tokenize(s):
return [w for w in re.split(r"[^0-9A-Za-z\u4e00-\u9fff]+", s) if w]
def bow_vector(tokens):
v = {}
for t in tokens:
v[t] = v.get(t, 0) + 1
return v
def dot(a, b):
return sum(a.get(k, 0) * b.get(k, 0) for k in a.keys())
def norm(a):
return math.sqrt(sum(x * x for x in a.values()))
def cosine(a, b):
na = norm(a)
nb = norm(b)
if na == 0 or nb == 0:
return 0.0
return dot(a, b) / (na * nb)
def split_sentences(text):
sents = re.split(r"[。!?\n]+", text)
return [s.strip() for s in sents if s.strip()]
def build_index(chunks):
vecs = []
for c in chunks:
vecs.append(bow_vector(tokenize(c)))
return vecs
def retrieve(query, chunks, vecs, k=2):
qv = bow_vector(tokenize(query))
scored = []
for i, v in enumerate(vecs):
scored.append((cosine(qv, v), i))
scored.sort(reverse=True)
top = scored[:k]
return [(chunks[i], score) for score, i in top]
if __name__ == "__main__":
chunks = split_sentences(DOC)
vecs = build_index(chunks)
for q in ["什么是RAG", "相似度怎么做", "长上下文会替代RAG吗"]:
hits = retrieve(q, chunks, vecs, k=2)
ctx = " | ".join([h[0] for h in hits])
print("Q:", q)
print("EVIDENCE:", ctx)
print()
你会看到它能把“相近句子”检索出来。
当然,它还不够“懂语义”,但流程已经成型。
真正的大模型嵌入,只是把 bow_vector 换成更强的向量表示;
真正的 RAG 生成,只是把 “EVIDENCE” 交给 LLM 并要求它只根据证据回答。
五、RAG 会带来新问题:证据错、证据噪、证据不全
看官莫以为“有证据就万事大吉”。
RAG 的风险常见三种:
- 检索错了:找来的段落与问题不相干
- 检索杂了:段落里包含冲突信息,模型容易挑错一句
- 检索少了:证据不足却硬答,反而更像幻觉
所以第三篇要做的,不只是“会检索”,而是:
- 会判断证据够不够
- 会在证据冲突时做核查
- 会在不够时改写查询、再检索
导读提到的 Corrective RAG(CRAG)就是针对“检索错了怎么办”来讨论纠错与再检索的思路之一。3
六、小结:RAG 是“耳目”,但耳目也会看错
这一回你只需牢牢记住四步:
- 切:文档怎么分块
- 嵌:块怎么向量化
- 找:怎么相似度检索
- 写:把证据交给模型,让它据此回答
下一回(第24回)我们讲一件更现实的事:
你说“检索质量好”,到底好在哪?
你说“回答更可靠”,到底可靠在哪?
不讲感觉,讲指标。
欲知后事如何,且听下回分解。
幻觉核查
- RAG 作为应对幻觉与过时知识的路线:可在 RAG 综述中核对其对动机与组件的归纳。12
- CRAG 的定位与目标:可核对其标题与摘要对“纠错检索”的描述。3
- 本回代码为教学玩具检索器,不宣称达到工业级语义检索效果。
逻辑审计
- 与第22回衔接:工具调用讲“规矩”,本回把检索当作工具,引入“证据”作为约束。
- 与导读一致:导读强调“引用与核查”,本回把回答建立在“可检索证据”之上。
- 为后续铺路:第24回要评估检索与生成;第25–27回要提升检索与生成的可控性;第29回将对比长上下文与 RAG。
引用与溯源
Footnotes
-
Gao, Y., et al. Retrieval-Augmented Generation for Large Language Models: A Survey arXiv:2312.10997 (v5: 2024-03-27) https://arxiv.org/abs/2312.10997 ↩ ↩2
-
Huang, Y., et al. A Survey on Retrieval-Augmented Text Generation for Large Language Models arXiv:2404.10981 (v2: 2024-08-23) https://arxiv.org/abs/2404.10981 ↩ ↩2
-
Gu, J.-C., et al. Corrective Retrieval Augmented Generation arXiv:2401.15884 (2024-01) https://arxiv.org/abs/2401.15884 ↩ ↩2