手写一个最小 RAG:我的四个翻车现场
Joyer Lee Lv3

TL;DR:为了把「读过很多 RAG 原理」变成「亲手造过 RAG」,我给自己立了红线——只用 numpy 和 openai 官方 client,禁一切 RAG 框架,在 47 篇 Dify 插件文档上手写了检索半场(切分 → Embedding → 混合检索 → 重排 → 评测断言 → FastAPI 服务壳)。过程中翻车四次:①200 字小块让链接锚文本冒充正主;②同一篇文档霸占 Top-5 的 4 席;③加了 BM25 混合检索还是翻车——两个通道同时被同一批块骗过;④连业界标配 MMR 都输给了 10 行的来源去重。这篇文章记录每次翻车的诊断数据和修复机制,全部实验累计花了 0.15 元。

一、为什么手写

市面上 RAG 教程的默认路径是:LangChain + ChromaDB + 一个 template,二十行跑通。跑通之后呢?换个大模型叫 RAG 2.0,换个向量库叫 RAG 3.0——框架把所有值得犯的错误都替你犯完了,也替你藏起来了。

我想亲手造一遍——不为产出什么,就想知道框架替我藏起来的到底是什么。是不是真的懂一个东西,我有个朴素的验收标准:能不能讲出它每个环节的失败模式。「我调过 chunk_size」和「我有一组对比数据能说明 chunk_size 为什么没有全局最优」是两个级别的回答——前者证明用过,后者证明拆开看过、摔过跤,还摔明白了。所以规则定为:

  • 依赖只允许 numpy + openai(调 embedding 模型),禁 LangChain / LlamaIndex / 向量库
  • 检索核心 main.py 单文件(最终 187 行),每个机制手写
  • 先固化评测断言,再做优化——后面的每次翻车都靠它当场抓住

语料是 47 篇 Dify 插件开发中文文档(.mdx),embedding 用 text-embedding-v4(1024 维,0.0005 元/千 token)。只做检索半场,不做生成——先把「召回」这一个环节吃透。

二、30 行跑通检索半场

链路五步:读文档 → 滑窗切分 → 批量 embedding(带磁盘缓存)→ 余弦相似度 → Top-5。

1
2
3
4
5
6
7
8
9
10
def chunk(text: str, size: int, overlap: int) -> list[str]:
"""滑窗切分:窗口长 size,每次前进 size-overlap,相邻块共享 overlap 字符"""
assert 0 < overlap < size
step = size - overlap
chunks = []
for i in range(0, len(text), step):
chunks.append(text[i:i + size])
if i + size >= len(text):
break
return chunks

两个值得说的工程细节:

缓存 + 对账校验。embedding 是唯一花钱的环节,磁盘缓存一份(向量 + 块文本)。重跑时重新切分一遍,和缓存的块文本做 assert texts == cached——宁可崩溃,不可错答。这个 assert 后来真的救过我:改切分逻辑时缓存没失效,对账直接报错,避免了「向量和文本错位」这种查一天都查不到的暗病。

argsort 的经典陷阱。numpy 的 argsort() 默认升序,取 Top-k 必须 [::-1] 反转。漏了这一步,你拿到的是「最不相关的 k 个」,而且没有任何报错。

跑通 3 个测试问题:如何初始化插件开发环境 / 插件怎么调试和查看日志 / 如何接入一个新的模型供应商。肉眼判断 Top-5 召回——这是第一代评测,人审。

三、第一次翻车:chunk_size 没有全局最优

控制变量实验:语料、模型、问题、检索算法全不变,只改切分配置——大块(2000 字符/重叠 200,212 块)vs 小块(200 字符/重叠 50,2315 块)。

问题大块小块归因
Q1 初始化✅ Top-1/2 命中正主❌ 正主完全掉出 Top-5锚文本污染 + 上下文丢失
Q2 调试✅ 覆盖 2 篇文档⚠️ 准但 Top-4 全来自同一篇小块语义纯 → 多样性塌缩
Q3 接模型✅ 5/5✅ 5/5语料本身聚焦,配置不敏感

其中最有教学价值的是 翻车①。小块配置下拿了全场最高分(余弦 0.719)的一块,内容是别的文档里的一句话:「关于如何准备插件开发的脚手架工具,详细说明请参考 [初始化开发工具](链接)」。它为什么赢?

小块把这句交叉引用单独切了出来——这一块的全部内容就是「指向初始化教程的一个指针」,和 query「如何初始化插件开发环境」字面几乎重合,向量化后方向自然一致。而真正讲初始化的教程正文,每块混着代码和命令,和 query 的整体方向反而没那么重合。小块把「目录条目」和「正文内容」平等对待了,而目录条目天然长得很像问题。 类比:你问书店老板哪本书讲初始化,他指了目录页上印着「初始化」三个字的那本。

同一组实验还送了翻车②:霸榜。Q2 小块下 Top-4 全来自同一篇文档——每块语义都纯正、都和 query 相似,于是 5 个名额被一篇占用 4 个。检索系统的价值在「给多个来源的视角」,霸榜等于视野塌缩。

顺带一个推论:两档配置下相似度分数的绝对值不可比(0.719 vs 0.652 不说明前者更相关),因为参与比较的块本身不同,向量分布整体漂移。上线 RAG 时改了切分,所有相似度阈值都要重新标定。

结论:chunk_size 没有全局最优,只有「数据 + 场景」相关的权衡。但 Q1 的翻车得治——下一个机制登场。

四、翻车③:加了 BM25 混合检索,还是翻车

词面检索的经典答案是 BM25,我手写了完整版:IDF × TF 饱和 × 长度归一,三个机制各治一个病。

机制治什么病砍掉的后果
IDF 逆文档频率「插件」在每篇都出现→权重趋零,稀有词→权重高本语料篇篇讲插件,没 IDF 则所有块都因「插件」得分,区分度崩
k1 TF 饱和词出现 10 次 ≠ 10 倍重要重复的导航文本、URL 词在长块里狂刷分
b 长度归一长块天然词多,需拉平大块配置里块越长分越高,长块通吃

IDF 公式用 Lucene 变体 log(1 + (N−df+0.5)/(df+0.5))——原版 BM25 的 IDF 在「词出现在超过半数文档」时就会算出负分,是个经典坑。

中文分词不引 jieba,用相邻两字 bigram(搜索引擎处理中文的经典做法,Lucene CJKAnalyzer 同款):如何初 → 如何/何初。好处是不需要词边界、天然免疫分词错误;跨字噪声 gram(何初/化插)真词必中、噪声偶中,被信号自然淹没。

融合用最朴素的方案:余弦和 BM25 各自 min-max 归一化(余弦挤在 0.3~0.8 窄带、BM25 能到 30+,不归一直接加权等于 BM25 独裁),然后 0.6×向量 + 0.4×词面。

结果:Q1 在小块配置下依然 FAIL。

这次没急着调参,先写了个诊断脚本看数据。真相是:正主文档的块,词面最好名次第 10、向量最好名次第 9——两个通道的 Top-5 都没有它。而一批「链接农场块」(别的文档里指向正主的锚文本引用)在词面和语义两个通道同时冒充正主。融合只是加权和,救不出双通道都落后的块;α 怎么调都救不动,因为问题根本不在权重。

我差点得到的教训是「混合检索也就这样」,实际教训是:混合检索不是银弹,两个通道共享同一个盲区时,融合只会把盲区放大得更好看。

五、翻车①③的修复:诊断驱动的 BM25F 标题字段

诊断数据指向的病根,和第一次翻车是同一个:200 字小块丢了「我属于哪篇文档」的身份——正主的块不知道自己讲初始化,农场块的锚文本却自带关键词。

修法借了 BM25F(字段加权 BM25)的思路——严格说是简化实现,没做字段级 TF 融合:文档标题单独当一个字段,query 的词命中某文档标题 → 该文档的全部块加分(权重 2)。标题是文档的浓缩语义,等于把文档身份还给每个小块。

1
2
for i in range(n):  # 字段二·标题:命中 query 词,全篇块受益
s[i] += W_TITLE * sum(idf(g) for g in q_grams & title_grams[i])

效果一句话:正主的词面名次从第 10 到第 1,Q1 在 200 档由 FAIL 转 PASS(6 条断言全绿)。

这个机制还有一个漂亮的副作用,全是 IDF 的功劳:语料里 21 篇文档标题带「插件」(工具插件、触发器插件、Bundle 插件……),它们的标题加分 ≈ 0——因为「插件」人人都有,IDF 趋零;只有标题真含「初始/始化/开发」的文档吃到分。如果标题加分不带 IDF,这个机制自己就会变成新的污染源。

六、重排消融:MMR 输给了 10 行 cap

混合检索修好了精确度(翻车①③结案),但翻车②的霸榜还在(小块 Top-5 仍有 4 席来自同一篇)。重排层的候选有两个:来源去重 cap 和 MMR。我两个都实现了,做消融——同一融合分,只换重排机制。

cap 的全部逻辑,10 行:

1
2
3
4
5
6
7
8
9
def diversify(order, names, k=5, cap=2):
kept, seen = [], Counter()
for idx in order:
if seen[names[idx]] < cap:
kept.append(idx)
seen[names[idx]] += 1
if len(kept) == k:
break
return np.array(kept)

MMR(Carbonell & Goldstein 1998)是重排的招牌算法:每轮选 λ·相关性 − (1−λ)·与已选集合的最大相似度 最大的块,多样性信号用已缓存的块向量余弦。预期是「业界标配至少不输 10 行 cap」。

实测打脸(多样性指标 = Top-5 内块向量两两余弦均值,越小越多样):

配置/问题capMMR λ=0.85MMR λ=0.7MMR λ=0.5
小块 Q2 调试(霸榜现场)✅ 3 篇 · 0.717❌ 1 篇 · 0.755✅ 2 篇 · 0.705✅ 2 篇 · 0.685
大块 Q3 接模型✅ 3 篇 · 0.782✅ 2 篇 · 0.757✅ 2 篇 · 0.757✅ 2 篇 · 0.757
小块 Q1 初始化✅ 3 篇 · 0.700✅ 3 篇 · 0.696✅ 3 篇 · 0.696✅ 3 篇 · 0.557

(其余 3 组——大块 Q1/Q2、小块 Q3——结论一致,完整 6 组可由消融脚本 mmr_ablation.py 复现。)

三个发现:

  1. 翻车④:MMR 在正主霸榜场景失效甚至倒退(λ=0.85 退化回 1 篇垄断)。拆两层:相关性断层太大——霸榜块融合分 0.998 vs 后续 0.78,0.15 权重的多样性项罚不动这个鸿沟;而且同文档的块讲的是不同小节(步骤 1/2/3),彼此向量余弦只有 ~0.7,「语义多样性」根本不把它们视为重复。cap 的配额信号是文档名——离散、精确、不可欺骗。
  2. MMR 的真正价值在跨文档语义多样性:λ=0.5 时 Q1 两两余弦降到 0.557,把语义不同向的文档提了进来。但同时它会无差别惩罚合法的相关簇——Q3 三个模型供应商文档本就互为近亲,MMR 把其中一篇挤出了榜。
  3. λ 是真旋钮:同一个场景,λ 从 0.85 到 0.5,来源数 1→2→2。没有「既保相关性又保多样性」的免费值。

生产结论:cap 上主链路(对同文档霸榜是精确打击,来源数有下限保证,零调参);MMR 留作二级多样性方案(跨文档近似重复场景),且上线前必须过同一套断言。

七、12 条断言 + 90 行服务壳

这套实验能安心做下去,靠的是先固化验收:3 问 × 2 配置 × 2 判据 = 12 条断言(判据一:Top-5 必含正主文档;判据二:来源 ≥2 篇),python main.py 跑完打 PASS/FAIL 汇总,退出码即回归结果。

它至少两次当场抓住问题:BM25 首版修复无效(5/6,防止「感觉修好了」);MMR λ=0.85 翻车(来源 1 篇)。**如果跳过断言直接上「业界标配」,这两次退化都会被精确度分数掩盖。**断言绑文档级不绑名次——名次多样性留给重排层,边界清晰。

工程化最后一步是 FastAPI 服务壳(app.py,90 行):检索函数一行不复制,靠两处小重构换来复用——search() 计算/打印分离(返回数据,打印归 CLI),索引构建提成 build_index()(CLI 断言和 API 启动走同一入口)。启动时索引从缓存读入常驻内存,零 embedding 花费;每次搜索只花一次 query 编码。上线前做了对账测试:HTTP 层返回 vs 直调函数,六组组合逐名次比对全一致——序列化层也纳入验收。

mini-rag 检索页:BM25F+向量混合检索,来源去重后 Q2 不再霸榜

八、成本账单

项目token费用
全量 embedding(两档配置,2527 块)~29 万0.145 元
冒烟 + 消融 + 全部回归/对账的 query 编码~千级<0.001 元
词面检索、BM25、重排—0(纯 CPU)
合计≈0.15 元

缓存生效后复跑全部实验 = 0 元。手写红线的意外红利:因为每个环节透明,才知道钱花在哪、什么时候是 0。

九、收束

回到开头的问题:手写换来了什么?

  • 遇到召回差,我能给出排查顺序:先看切分(锚文本污染/上下文丢失),再看检索通道(单通道 vs 混合、共享盲区),再看重排(霸榜/多样性),每一步有数据支撑
  • 我知道「业界标配」不等于「你的场景的标配」——MMR 在我的失效模式上输给了 10 行 cap,而且是被断言当场抓住的
  • 我知道 RAG 的钱花在哪:embedding 一次性成本 0.15 元,检索全链路可以做成 0 边际成本

手写不是反对框架。框架值得用——但值得用在你知道它替你做了什么之后。

附:概念 ↔ 代码 ↔ 实测现象对照表

如果只带走一张表:每个检索概念在这两百多行代码里各有一处实现,各有一次翻车或实测现象作证。回看复习时扫这张表,比重读全文快。

概念在 demo 里的位置它的实测现象
滑窗切分 chunkingchunk():固定窗口+重叠翻车①②:200 字小块让锚文本冒充正主、同篇霸榜 4 席
Embedding 与成本embed_chunks():批量+磁盘缓存+对账 assert改切分后缓存没失效,assert 当场报错;全实验 0.15 元
余弦相似度与 Top-kargsort()[::-1][:k] 三连漏了反转拿到「最不相关的 k 个」,且无任何报错
相似度分数的可比性跨配置对比(无独立函数)0.719 vs 0.652 不可比:改切分=分布漂移=阈值重标
中文词面分词 bigramtokenize():相邻两字+英文整词免分词器;跨字噪声 gram 被真词信号淹没
BM25 三机制bm25_scores():IDF / k1 饱和 / b 归一IDF 用 Lucene 变体防负分;纯 BM25 首版 5/6 被断言抓住
混合检索与量纲normalize() min-max + α 加权余弦挤窄带、BM25 能到 30+,不归一=BM25 独裁;翻车③:双通道同盲区,α 救不动
字段加权(BM25F 思路)doc_title() + 标题字段(W_TITLE=2)翻车①③的修复:正主词面第 10→第 1;IDF 让 21 篇「插件」标题自动免污染
来源去重 capdiversify():每篇 ≤2 块翻车②的修复:Top-4 同一篇 → 3 篇来源
MMR 多样性重排mmr_ablation.py:λ·相关性−(1−λ)·最大相似翻车④:λ=0.85 退化回 1 篇;λ=0.5 两两余弦 0.700→0.557
评测断言与回归TARGETS + run_experiment() + 退出码12 条断言两次当场抓退化;跳过断言的退化会被精确度分数掩盖
服务壳与复用app.py:lifespan 索引常驻 + /search启动零 embedding;HTTP 对账 6/6 逐名次一致