第23回 检索增强生成(RAG)基础——分块、嵌入与检索

书库万卷不嫌多,关键一页要寻着。
若无证据凭空说,十句像真九句错。

上两回把智能体的“手脚”讲明白了:
会调用工具,才算能办事。

而智能体最常用、最稳当的一把工具,便是“查资料”。
江湖里给它起了个响亮名字:检索增强生成(RAG)

RAG 的朴素愿望很简单:

  • 模型不靠记忆硬背
  • 而是先去资料库找证据
  • 再拿证据来回答
  • 还能把证据引用出来,给人核查

2024–2025 的多篇综述把 RAG 视为应对“幻觉、过时知识、不可追溯”的核心路线之一,并把系统拆成可控组件来讨论。12

这一回,我们不求玄妙,只把 RAG 的“最低配”跑通:
分块、向量化、相似度检索、拼上下文、生成回答。


一、RAG 的四步:切、嵌、找、写

把 RAG 写成四个动作:

  1. 分块(Chunking):把长文切成一段段
  2. 嵌入(Embedding):把每段变成向量
  3. 检索(Retrieve):用相似度找最相关的几段
  4. 生成(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 的风险常见三种:

  1. 检索错了:找来的段落与问题不相干
  2. 检索杂了:段落里包含冲突信息,模型容易挑错一句
  3. 检索少了:证据不足却硬答,反而更像幻觉

所以第三篇要做的,不只是“会检索”,而是:

  • 会判断证据够不够
  • 会在证据冲突时做核查
  • 会在不够时改写查询、再检索

导读提到的 Corrective RAG(CRAG)就是针对“检索错了怎么办”来讨论纠错与再检索的思路之一。3


六、小结:RAG 是“耳目”,但耳目也会看错

这一回你只需牢牢记住四步:

  • 切:文档怎么分块
  • 嵌:块怎么向量化
  • 找:怎么相似度检索
  • 写:把证据交给模型,让它据此回答

下一回(第24回)我们讲一件更现实的事:
你说“检索质量好”,到底好在哪?
你说“回答更可靠”,到底可靠在哪?

不讲感觉,讲指标。
欲知后事如何,且听下回分解。


幻觉核查

  • RAG 作为应对幻觉与过时知识的路线:可在 RAG 综述中核对其对动机与组件的归纳。12
  • CRAG 的定位与目标:可核对其标题与摘要对“纠错检索”的描述。3
  • 本回代码为教学玩具检索器,不宣称达到工业级语义检索效果。

逻辑审计

  • 与第22回衔接:工具调用讲“规矩”,本回把检索当作工具,引入“证据”作为约束。
  • 与导读一致:导读强调“引用与核查”,本回把回答建立在“可检索证据”之上。
  • 为后续铺路:第24回要评估检索与生成;第25–27回要提升检索与生成的可控性;第29回将对比长上下文与 RAG。

引用与溯源

Footnotes

  1. 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

  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

  3. Gu, J.-C., et al. Corrective Retrieval Augmented Generation arXiv:2401.15884 (2024-01) https://arxiv.org/abs/2401.15884 2