动手实现一个 RAG 问答机器人¶
对应原站:动手实现一个 RAG 问答机器人(会员内容;本章为独立原创重写)
一句话结论:RAG(Retrieval-Augmented Generation,检索增强生成)就是先从一堆文档里挑出和问题最相关的几段,只把这几段塞进 prompt,让模型「照着资料答」。四步:切块 → 向量化 → 检索 → 拼 prompt。
假设产品经理丢给你一份 300 页的帮助中心,让你做一个客服机器人,用户问「会员多少钱」。
你可能会问:现在模型上下文都 1M token 了,直接把 300 页全塞进去不行吗?
能塞,但每个问题都要为 300 页付费,而用户只关心其中一段。更麻烦的是,资料越长,模型越容易在中间那堆无关内容里分心。所以真正的问题是:怎么在问模型之前,先把那一段找出来。
先跑起来看看¶
代码在 code/agent-node/rag/rag.ts(Python 版 rag.py),资料是 rag/docs/ 下 6 篇虚构的相册 App「拾光」的帮助文档(两个版本共用这一份)。整套检索用普通数组实现(Python 版用纯 numpy),不下载任何模型:
$ node rag/rag.ts --fake "会员多少钱?学生有优惠吗?"
共 18 个块,向量维度 16384
问题:会员多少钱?学生有优惠吗?
检索结果:
0.160 membership.md#会员与价格 【会员与价格】学生认证后购买年付 Pro 可享五折,认证需上传有效期内的学生证照…
0.070 membership.md#会员与价格 【会员与价格】Pro 会员月付 39 元,年付 299 元(约合每月 25 元)…
0.065 membership.md#会员与价格 【会员与价格】拾光提供免费版和 Pro 会员两档。免费版有 15 GB 云空间,…
---- 发给模型的 user 消息 ----
<docs>
<doc id="1" source="membership.md#会员与价格">【会员与价格】学生认证后购买年付 Pro 可享五折,认证需上传有效期内的学生证照片,审核时间一般为 1 个工作日。</doc>
<doc id="2" source="membership.md#会员与价格">【会员与价格】Pro 会员月付 39 元,年付 299 元(约合每月 25 元)。Pro 会员有 2 TB 云空间,回收站永久保留,并解锁智能相册和家庭共享。</doc>
<doc id="3" source="membership.md#会员与价格">【会员与价格】拾光提供免费版和 Pro 会员两档。免费版有 15 GB 云空间,删除的照片在回收站保留 30 天。</doc>
</docs>
问题:会员多少钱?学生有优惠吗?
回答(离线 fake 模型):Pro 会员月付 39 元、年付 299 元 [1];学生认证后年付可享五折 [2]。
$ uv run python -m rag.rag --fake "会员多少钱?学生有优惠吗?"
共 18 个块,向量维度 16384
问题:会员多少钱?学生有优惠吗?
检索结果:
0.160 membership.md#会员与价格 【会员与价格】学生认证后购买年付 Pro 可享五折,认证需上传有效期内的学生证照…
0.070 membership.md#会员与价格 【会员与价格】Pro 会员月付 39 元,年付 299 元(约合每月 25 元)…
0.065 membership.md#会员与价格 【会员与价格】拾光提供免费版和 Pro 会员两档。免费版有 15 GB 云空间,…
---- 发给模型的 user 消息 ----
<docs>
<doc id="1" source="membership.md#会员与价格">【会员与价格】学生认证后购买年付 Pro 可享五折,认证需上传有效期内的学生证照片,审核时间一般为 1 个工作日。</doc>
<doc id="2" source="membership.md#会员与价格">【会员与价格】Pro 会员月付 39 元,年付 299 元(约合每月 25 元)。Pro 会员有 2 TB 云空间,回收站永久保留,并解锁智能相册和家庭共享。</doc>
<doc id="3" source="membership.md#会员与价格">【会员与价格】拾光提供免费版和 Pro 会员两档。免费版有 15 GB 云空间,删除的照片在回收站保留 30 天。</doc>
</docs>
问题:会员多少钱?学生有优惠吗?
回答(离线 fake 模型):Pro 会员月付 39 元、年付 299 元 [1];学生认证后年付可享五折 [2]。
检索分数和 user 消息都是真实计算出来的;只有最后一行回答是 fake 模型的预设文本。注意 fake 回答里的 [1] [2] 编号其实标错了(价格在 doc 2,学生优惠在 doc 1),这恰好提醒你:引用编号是模型生成的,不是检索系统保证的,上线前要抽查。
四步流水线¶
左边只在文档更新时跑一次,右边每个问题跑一次。下面一步步拆。
切块:一段话说清一件事¶
function chunkMarkdown(path: string, maxChars = 200): Chunk[] {
const raw = readFileSync(path, "utf8");
const title = raw.split("\n")[0].replace(/^[# ]+/, "").trim();
for (let para of raw.split(/\n\s*\n/)) { // 先按空行分段
...
for (const sent of para.split(/(?<=[。!?])/)) { // 太长再按句号切
...
chunks.push({ source: `${basename(path)}#${title}`, text: `【${title}】${buf}` });
你可能会问:为什么每块前面都要加 【会员与价格】 这个标题?
因为切开之后,「Pro 会员月付 39 元」这一段里并没有「价格」两个字。用户问「具体价格是多少」,没有标题的块就匹配不上。这招叫上下文补全,第 4 章会用实验证明它的效果。
向量化:把文本变成一串数字¶
真实的 RAG 会调用一个 embedding 模型,把文本变成一串浮点数(向量),意思相近的文本向量也相近。Anthropic 自己不提供 embedding 模型,官方文档推荐的是 Voyage AI,比如 voyage-4 默认输出 1024 维。这里为了离线,用了一个朴素得多的方法:
/** 中文没有空格分词:直接取字符 1-gram 和 2-gram;英文/数字按词切。 */
function ngrams(s: string): string[] { ... }
class HashingTfidf {
transform(texts: string[]): Float64Array[] {
return texts.map((t) => {
const v = new Float64Array(DIM);
for (const g of ngrams(t)) v[crc32(g) % DIM] += 1; // 哈希到 16384 个桶里计数
for (let h = 0; h < DIM; h++) v[h] = Math.log1p(v[h]) * this.idf[h]; // 常见字降权
const norm = Math.sqrt(dot(v, v));
return v.map((x) => x / norm); // 归一化
});
}
}
def _ngrams(text):
"""中文没有空格分词:直接取字符 1-gram 和 2-gram;英文/数字按词切。"""
...
class HashingTfidf:
def transform(self, texts):
m = np.zeros((len(texts), DIM))
for i, t in enumerate(texts):
for g in _ngrams(t):
m[i, zlib.crc32(g.encode()) % DIM] += 1 # 哈希到 16384 个桶里计数
m = np.log1p(m) * self.idf # 常见字降权
return m / np.linalg.norm(m, axis=1, keepdims=True) # 归一化
拆开看就三件事:
- 数字:把「会员多少钱」拆成「会」「员」「会员」「员多」……每个片段哈希到一个桶里计数。这叫哈希技巧(hashing trick),省掉了维护词表。
- 降权:IDF(逆文档频率)让「的」「是」这种到处都有的字权重变低,「退款」「蓝牙」这种只在少数块出现的字权重变高。
- 归一化:每个向量除以自己的长度,之后两个向量的点积就是余弦相似度。
一个类比:这像图书管理员靠「书里出现了哪些关键字」来找书。你问「蓝牙连不上」,他翻出所有提到「蓝牙」的页。
这个类比也正是它的局限:它只认字面,不懂意思。真 embedding 模型更像一个读过很多书的人,你说「登不上去了、口令想不起来」,他知道你在说「忘记密码」。我们的哈希 TF-IDF 做不到,第 4 章有实测。
检索:一次矩阵乘法¶
search(query: string, k = 3): Hit[] {
const q = this.vec.transform([query])[0];
const scores = this.matrix.map((row) => dot(row, q)); // 18 个块 × 16384 维,逐行点积全算完
const top = scores.map((_, i) => i).sort((a, b) => scores[b] - scores[a]).slice(0, k);
return top.map((i) => [scores[i], this.chunks[i]]);
}
18 个块的规模,暴力算就够了。到百万级才需要 HNSW 这类近似最近邻索引,那是向量数据库的活。说穿了,向量检索的核心就是 matrix @ q 这一行(Node 版没有 numpy,写成逐行点积 matrix.map((row) => dot(row, q)),算的是同一个东西)。
拼 prompt:让模型只看资料¶
这句 system 有两个关键约束:只根据资料(防止模型用训练知识编),没有就说没有(给模型一个合法的「不知道」出口)。少了第二句,模型面对不相关的资料时往往会硬凑一个答案。
几个不同问法的实测 top-1,全都命中正确文档:
(「检索 top-1 命中正确文档」这条用例(Python 版 test_retrieval_top1)覆盖了「能不能导出我的数据」「忘记密码怎么办」「蓝牙连不上」「可以退钱吗」「支持日语吗」等 6 个问法。)
回到主线:上下文有限且按 token 计费,RAG 就是在调用模型之前先做一次「上下文筛选」,只把最可能有用的几百个 token 放进去。它用一个检索步骤换来了省 token 和更少的干扰,代价是检索一旦漏掉,模型就再也看不到那段资料。
对照你的环境¶
你在 webapp 里接翻译平台的 i18n、查 Linear 单、翻 Notion 知识库,都是「先找资料再回答」。区别在于你让 Claude Code 自己去搜(MCP 工具 + grep),而不是预先建好向量索引。这两条路线各适合什么场景,是下一章的主题。
常见坑¶
- 块切得太碎或太大。太碎丢上下文(「它」指谁?),太大一块里混了好几件事,相似度被稀释。按语义边界(段落、标题)切,再控制长度。
- 不给块补标题。切开后很多块缺少主题词,检索不到。
- 不让模型说「不知道」。检索结果不相关时模型会编,答案看起来还特别像回事。
- 相信引用编号。编号是模型生成的,可能错位,见上面 fake 输出。
- 索引和文档不同步。文档改了索引没重建,模型会自信地引用旧价格。
练习¶
- 把
k从 3 改成 1 和 6,各问三个问题,观察回答质量和 prompt 长度的变化。 - 给
search加一个分数阈值:最高分低于 0.05 时直接回「资料中没有相关说明」,不调用模型。用「你们 CEO 是谁」测试。 - 开放题:如果帮助文档有中英两个版本,用户用英文问中文文档里才有的内容,我们的哈希 TF-IDF 会怎样?真 embedding 模型会怎样?你会怎么设计?
延伸阅读¶
- Anthropic:Contextual Retrieval(给块补上下文的系统化做法):https://www.anthropic.com/news/contextual-retrieval
- Embeddings 文档:https://platform.claude.com/docs/en/build-with-claude/embeddings
- 本库:下一章 RAG 原理和使用场景
回到开头的问题:300 页帮助中心不必全塞进去。先花一次矩阵乘法找到那三段,再让模型照着读。