手写 RAG 管线:从「模型编不了」到「模型查得到」
前言
这是我的 Agent 学习项目的三连:W2 用 prompt 约束让模型”别编”,W4 用 Tool Use 让模型”编不了”(只能调函数拿数据),W6 用 RAG 让模型”查得到”——给它一个能按语义检索的本地知识库。
这篇文章沿着管线走一遍:切分 → embedding → 向量库 → 混合检索,每一步为什么存在、踩了什么坑,最后回答一个必须面对的问题——RAG 什么时候会失效。
一、切分:把长文档拆成可索引的卡片
RAG 的第一步是把长文档切成小块,每块单独做 embedding。切太粗检索不精确,切太细丢失上下文。
三种策略各有取舍:
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 固定长度 | 每 N 字符一刀 | 简单、块大小均匀 | 切断句子 |
| 段落切分 | 按空行分块 | 保留语义单元 | 块大小不均 |
| 句子切分 | 按标点断句累积 | 语义完整 | 无标点长文本要硬切 |
前端类比:切分就是把一篇文章拆成可索引的卡片。固定切分 = 每 1000 字符一刀切(可能切断句子);段落切分 = 按空行分段(保留完整段落);滑动窗口 = 卡片之间留重叠(检索到边界时上下文不丢)。
二、Embedding:把文本变成语义坐标
切好的块要变成模型能比较的数字——embedding 模型把文本映射到高维向量空间,语义相近的文本向量方向接近。
用本地 bge-m3(阿里开源)而非 sentence-transformers:local-first 架构模型数据不出域,复用已有 Ollama 基础设施,避免装几个 GB 的 torch。
实测语义排序:
1"一首适合健身房的电子摇滚" vs "电子摇滚 BPM 140" → 相似度 0.6062"一首适合健身房的电子摇滚" vs "古典钢琴曲" → 相似度 0.438同类 0.606 > 跨类 0.438——这就是向量检索的”信号”。差距 0.17 就是排序的依据。
前端类比:embedding ≈ 把文本投影到”语义坐标系”,查询和文档都在坐标系里,检索 = 找距离最近的点。
三、向量库:Chroma 持久化
向量存哪?本地 Chroma(PersistentClient 落盘)。
Chroma 概念对照:
- Collection ≈ 一张 MySQL 表
- Document + Embedding + Metadata ≈ 一行记录(内容 + 向量 + 标签)
- Query ≈ SELECT … ORDER BY 相似度 DESC LIMIT k
踩了一组 Chroma 1.5.9 的 API 坑(文档没写清,只有实测能发现):
query()返回 dict 不是对象——result["ids"]不是result.ids,且值是双层嵌套([['id1','id2']])add()强制要求ids——ids=None直接 TypeError,需自动生成delete()强制要求条件——空调用 ValueError,清空要先get()全部 ids
这组坑恰恰证明了项目原则的价值:先手写理解原理,再上框架。我用 Chroma 前已经知道向量检索的原理,所以这些 API 怪癖是”表面差异”不是”理解障碍”。
四、混合检索:向量 + BM25 + RRF
单一向量检索有个盲区:query 里的精确关键词(歌名、版权方)向量反而弱。混合检索 = 向量召回(语义)+ BM25 召回(关键词)→ RRF 融合。
BM25 一句话:一个词在”这篇文档里出现得多、但在整个语料里出现得少”→ 这篇文档该排前面。词频(tf)管”多”,逆文档频率(IDF)管”稀有”。
手写 BM25 时踩的坑:doc_freq.update(counts) 应为 counts.keys()——df 是”多少篇文档含这个词”,一篇文档出现 100 次也只计 1,用 counts 会把词频累加进去导致 IDF 计算错误。
RRF 融合:向量分数(0~1)和 BM25 分数(可到几十)量纲不同,直接加权相加没有意义。RRF 不看分数看排名:
1融合分(id) = Σ 每个检索器给它的 1/(排名 + k)前端类比:多路召回结果的”民主投票”——不看每个搜索引擎的分数(尺度不同没法比),只看”谁在你的结果里排得靠前”。
实测验证(核心测试):向量 + BM25 都排前面的文档,融合分压过只在 BM25 词频极高的文档——RRF 的”双命中加权”生效。
五、RAG 什么时候会失效
这是项目验收必考题,我的答案分四层:
1. 检索不到 → 答案必然错 库里没有答案,再好的 RAG 也答不出来。它只是换个方式说”不知道”。解决:检索不到时告诉用户”库里没有”,而不是硬答——这跟 W4 的 search_catalog 返回空列表一个道理。
2. 切分切坏了语义 → 检索到但答不对 段落被切断、上下文丢失,块本身就不完整。解决:用段落/句子切分 + 重叠窗口,而不是无脑固定长度。
3. 检索到了但没答上 → 召回率不足 query 和 chunk 措辞差异大,向量没匹配上。解决:混合检索(BM25 补关键词)+ query 改写 + 适当扩大 top_k。
4. 最隐蔽的:检索对,但模型仍然错 检索结果正确,LLM 拿着正确上下文仍答错——可能是 prompt 没要求严格基于上下文,也可能是 chunk 里混入了噪声信息。解决:prompt 明确”只基于给定材料回答,材料没有就明说”,必要时加 reranker 精排。
一句话总结:RAG 失效的根源是**“检索不到”和”检索到但没用好”。前者是召回问题,后者是生成问题。RAG 不是给模型加知识——是给模型加检索入口**,入口没建好或没用好,一切都白搭。
结论
从 W2 的「别编」,到 W4 的「编不了」,到 W6 的「查得到」——这三步其实是一个问题的三个层次:怎么让模型只对真实数据负责。
RAG 的每一环(切分、embedding、存储、检索)都可能是瓶颈,但最值得记住的是:RAG 不改变模型的认知边界,只改变它获取信息的方式。库里没有的,它依然不知道——但至少,它现在知道怎么诚实地不知道。