上一篇《前端也能搞懂 RAG:用 JS 手写一条最小检索增强链路》把链路跑通了。但"能跑"和"跑得准"是两回事。 这篇记录我把链路接到真实文档后踩的四个坑------切块的两个极端、连接被重置、高分却答非所问。每个坑都附现象、排查、解法和背后原理。
写在前面:为什么"跑通"之后才是真正的开始
第一篇里,我用几段干净的示例文本就把 RAG 跑通了:算向量、比相似度、拼 prompt、调模型,一气呵成。
但那是"实验室环境"。真把一篇结构化的 markdown 技术文档喂进去,问题立刻冒出来:召回的内容驴唇不对马嘴、建库时接口直接报错、明明分数很高答案却跑偏。
这四个坑分别卡在 RAG 链路的几个位置:
- 坑 1、坑 2 在入口------文档怎么切块:切太碎丢上下文、切太大被稀释,两个极端都会拉低检索上限;
- 坑 3 在中间------把几十段文本一次性发给 embedding 接口,连接被重置;
- 坑 4 在出口------相似度分数很高,却不代表这段内容真能回答问题。
它们有个共同点:都不是代码语法错误,而是"看起来没问题、跑起来才暴露"的工程问题。这正是面试时最能体现"你真做过"的部分。
坑 1:文档切块不当,让 RAG 答非所问
这一坑要回答的问题:一篇 markdown 文档,到底该怎么切成"块"?
现象:召回一句光秃秃的标题
我把一篇带多级标题、表格、代码块的技术文档直接放进 RAG,检索回来的内容很不合理------经常召回一句孤零零的标题,或者半截没头没尾的正文。AI 拿着这种料,自然答得不知所云。
排查:不靠猜,打印每一块看
我没有凭感觉改代码,而是先写了个切块自测:node knowledge.js 把每一块的字数和前 40 个字打印出来。一眼就看到 93 段里一大半是坏块,集中在三类:
- 标题单独成块 :比如
## 1. 市面上的缓存策略概览,整块只有一句标题、没有正文。召回它等于拿到一句废话。 - 标题和正文被切散 :
### 3.1 为什么需要 KV Cache和它下面的正文成了两块。命中正文那块时,丢了"这段在讲啥"的上下文。 - 符号噪声被当正文 :目录、表格的管道符(
| 向量 | 变换 |、|---|)、代码块,被当成普通文本。这些符号 embedding 出来方向是乱的。
根因很快定位到:我最初用的是**"按空行切段"的朴素切法。它对付自然段落还行,但对结构化文档水土不服**------结构化文档的语义边界是"标题层级",根本不是"空行"。
解法:从"按空行切"改成"按标题聚合"
思路是顺着文档自己的结构切:
- 每遇到一个
#标题就开一个新块,把标题下面的正文都收进这一块; - 块首拼上标题路径 (如
章 > 节 > 小节),让每一块自带归属、能被独立理解; - 加代码围栏跟踪 :``````````` 内部的
#(比如 Python 注释)不能误判成标题------这是我改完第一版后发现代码块里的#污染了标题栈,专门补的一刀; - 超长小节再按句号二次切,避免一块塞进太多主题。
结果很直接:93 段噪声块 → 30 段干净块,每块都带章节路径。同一个问题,检索最高分从 0.4 级的噪声命中,提升到 0.7 级的精准命中。
拿个最小示例跑一遍(可复现)
口说无凭,贴一份会同时踩中上面三类坏块的最小文档,复制下来存成 sample.md 就能复现:
markdown
## 1. 缓存策略概览
## 2. KV Cache
### 2.1 为什么需要
推理时每一步都要重算历史 token 的 K 和 V,开销随长度上涨。
把 K、V 缓存下来,下一步直接复用:
```python
# cache 是个 dict,按层存
cache[layer] = (k, v)
```
### 2.2 命中率
| 场景 | 命中率 |
|------|------|
| 多轮对话 | 高 |
按空行切 (朴素切法)切出来一堆坏块:## 1. 缓存策略概览 单独成块(光标题没正文)、### 2.1 为什么需要 和它的正文被切散成两块、代码里的 # cache 是个 dict 这行注释可能被误当成标题、表格的 |---| 也单独成块。
按标题聚合则切成干净的两块,每块自带标题路径:
css
[块1] 2. KV Cache > 2.1 为什么需要
推理时每一步都要重算...把 K、V 缓存下来...(含 python 代码块)
[块2] 2. KV Cache > 2.2 命中率
| 场景 | 命中率 | ...
## 1. 缓存策略概览 这种纯标题被并进下文或跳过;代码块里的 # 因为有围栏跟踪,不再污染标题栈。这就是 30 段干净块的由来。
背后原理:块的质量 = 检索的天花板
RAG 检索的最小单位是"块"。块的质量直接决定检索质量 ,garbage in, garbage out。切块不是无脑按某个分隔符切,而要顺着文档的语义结构切,并让每一块携带足够上下文(标题路径),能被独立理解。
一句话总结:我把切块从"按空行"升级成"按标题聚合 + 带标题路径",还处理了代码块内
#的误判,把 93 段噪声块压到 30 段干净块,检索从 0.4 级噪声命中提到 0.7 级精准命中------切块质量是 RAG 检索质量的天花板,这步没做好,后面全白搭。
几个可能被追问的点:
- 为什么不把整篇文档当一个块? 检索粒度太粗,召回一大坨里相关的只有一句,既稀释相似度(正是下面坑 2 的问题)又浪费 token。
- 表格、代码块怎么办? 现在让它们留在所属小节内,靠周围正文提供语义。更讲究的做法是把表格转述成自然语言句子再 embedding,降低符号噪声。
- 还能更好吗? 能:超长块改用带重叠(overlap)的滑窗切,避免切口处语义断裂。我知道天花板在哪,这一版先用够用的方案跑通。
坑 2:片段切太大,相似度反被稀释
这一坑要回答的问题:块是不是越大、信息越全越好?
现象:最完整的那段说明文,分数反而最低
坑 1 解决了"切太碎",我一度顺势以为"那就尽量切大块、信息全一点更保险"。结果做检索实验时被打脸。
基准句"怎么退货",拿三个候选去打分:
| 候选片段 | 相似度 | 长度 |
|---|---|---|
| 退货 | 0.9462 | 2 字 |
| 退款时效是多久 | 0.7923 | 中 |
| 商品签收后7天内可无理由退货需保持包装完好 | 0.7077 | 一整句说明文 |
最完整、最该当答案的那段说明文,分数(0.7077)反而最低,比光秃秃两个字的"退货"(0.9462)低了一大截。
排查:长片段的语义焦点被稀释了
embedding 是把整段文字压成一个向量------一段话里塞的主题越多,这个向量越像各主题的"平均值",语义焦点越散。短问句"怎么退货"焦点极集中;而那段说明文里还混着"7天""无理由""包装完好"一堆次要信息,跟问句方向一对齐,相似度就被这些无关分量拉低了。
这跟坑 1 正好是两个相反的极端:坑 1 是切太碎(丢上下文),这里是切太大(焦点被稀释)。
解法:块大小有个"甜区"
- 一块只装一个主题,别把整节几百字塞进同一块;
- 坑 1 里"超长小节按句号二次切"就是为这件事服务的;
- 多大算合适没有银弹,要结合检索分数实测调------块太大就拆、召回丢了上下文就合。
背后原理:检索比的是"语义焦点",不是"信息量"
相似度高低取决于两个向量方向有多一致 ,而不是哪段信息更全。片段越长越杂,方向越偏离问句,分数越低。所以"知识库片段要尽量完整"是个直觉陷阱------完整 ≠ 好召回。
一句话总结:我实测发现一整句退货说明(0.7077)的检索分,反而低于两个字的"退货"(0.9462),因为长片段把语义焦点稀释了。切块大小有个甜区:太碎丢上下文(坑 1)、太大被稀释(本坑),要按检索分数实测去调。
举一反三:换个格式,"边界"就换个东西
坑 1 和坑 2 合起来其实是同一句话:切块要顺着文档的"语义边界"切------markdown 的边界恰好是标题层级。换个文档格式,边界就变了:
| 文档类型 | 语义边界在哪 | 典型的坑 |
|---|---|---|
| 纯文本 txt | 段落 / 句子,无显式结构 | 只能定长滑窗,容易切断句子 |
| Markdown(本文) | 标题层级 # |
代码块内的 # 被误判成标题 |
| 提取后比 md 更脏:跨页断句、页眉页脚、表格变乱码 | 得先清洗再切 | |
| HTML / 网页 | DOM 结构(h1/p)+ 去掉导航和广告 | 标签与噪声内容混进正文 |
| 代码 | 函数 / 类,而不是行 | 按行切会把一个函数劈成两半 |
说明:这次我只亲手做了 markdown 这一种,上表其余格式是同一原理的迁移、不是我都踩过------但"先找到该格式的语义边界、再顺着它切"这条原则是通用的。
坑 3:Embedding 批量请求被重置连接(ECONNRESET)
这一坑要回答的问题:几十段文本一次发给接口,为什么会断?
现象:换了知识库,建库直接报错
切块优化后,知识库从 21 段短文本换成 30 段更长的块。结果调用 embedding 接口直接报 fetch failed,底层错误是 ECONNRESET------连接被对端重置,向量根本建不起来。
排查:抓住"唯一变量"
- 先看错误类型 :
ECONNRESET是传输层连接被重置,不是接口返回的 4xx 业务错误。这说明请求根本没被正常处理完,而不是参数错了。 - 对照改动找单一变量 :代码一行没动,唯一的变化是
input数组变大了(段数变多 + 单段更长),单个请求体明显变大。 - 下判断:embedding 接口对单次请求的批大小/体积有上限,超了之后服务端直接断连,而不是优雅地返回一个错误码。
解法:分批 + 重试
两点改动就够了:
- 把
input数组分批------每批 16 条逐批请求,再把各批向量拼接起来; - 对瞬时网络错误重试一次(等 1 秒再发)。
js
// 伪代码:核心就两件事------分批、对瞬时错误兜一次
const BATCH_SIZE = 16
async function embedAll(texts) {
const vectors = []
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
const batch = texts.slice(i, i + BATCH_SIZE)
vectors.push(...await embedWithRetry(batch))
}
return vectors
}
async function embedWithRetry(batch, retried = false) {
try {
return await embed(batch)
} catch (e) {
if (!retried) { // 瞬时抖动,等 1 秒重试一次
await new Promise(r => setTimeout(r, 1000))
return embedWithRetry(batch, true)
}
throw e
}
}
背后原理:调外部接口的两个默认假设
调任何第三方接口,都要默认它有两件事:有体积上限 、会偶发抖动。分批把大请求拆小,绕开体积上限;重试兜住瞬时网络波动。这是调用第三方 API 的通用健壮性手段,不只 embedding 适用。
一句话总结:段数变多后 embedding 请求体过大触发了 ECONNRESET,我改成每批 16 条分批发送 + 失败重试一次,既绕开接口的批量体积上限,又兜住偶发的网络抖动。
几个可能被追问的点:
- 批大小 16 怎么定的? 经验值 + 留余量,远低于接口上限即可。要精确可以二分试出上限再打个折。
- 重试会不会有副作用? embedding 是幂等的(同样文本返回同样向量、不产生写操作),重试安全。如果是有副作用的写接口,就要加幂等键再重试。
坑 4:语义检索"高分 ≠ 能回答"
这一坑要回答的问题:相似度分数高,就代表这段能回答问题吗?
现象:干扰项的分数逼近真答案
我用"怎么退货"做检索,专门放了一个干扰项"怎么换货"。结果:
| 候选句 | 相似度 | 实际能不能回答 |
|---|---|---|
| 退货政策是什么 | 0.8081 | ✅ 是 |
| 怎么换货 | 0.8051 | ❌ 同领域,但不是一回事 |
两者只差 0.003。但换货 ≠ 退货,这是答非所问的内容,分数却几乎和真答案一样高。光看分数排序,根本分不开。
排查:这是我主动设计出来的实验
这个坑不是偶然撞上的,是我做控制变量实验时主动设计 的------基准句 vs 候选句打分,专门放了"同领域但不同事"的干扰项(换货、改地址)。实测发现:干扰项的分数能逼近真答案,仅靠分数排序无法区分"相关"和"能回答"。
解法:不是一招,是多层兜底
- Top-K + 相似度阈值:控制召回数量,过滤掉勉强相关的;
- 但阈值很难一刀切------这里真答案和干扰项只差 0.003,一刀切要么都留、要么都砍;
- 所以更关键的是 RAG 的 system 强约束 ------"只根据资料回答,没有就说不知道" + 低 temperature,让模型即使召回了边缘内容也不乱编。
我用奶茶店知识库实测过:问一个库里根本没有的问题,检索被阈值滤空后,AI 老实回"无法回答",而不是硬编一段。这就是多层兜底的价值。
背后原理:检索是 RAG 质量的天花板
相似度衡量的是"语义方向接近 ",但"语义接近"不等于"能回答这个问题 "。检索召回是 RAG 质量的天花板------召回错了,下游模型再强也救不回来。
这也是为什么不能把 RAG 当万能:它赢在私有知识和不幻觉,但只要检索召回不对,就全盘皆输。
一句话总结:我实测发现"换货"和"退货"只差 0.003、干扰项分数能逼近真答案,说明相似度高 ≠ 能回答。所以 RAG 不能只靠 Top-K,要叠阈值过滤 + 强约束 prompt + 低温兜底;而且检索质量是整个 RAG 的天花板,这是我对 RAG 局限最深的认知。
几个可能被追问的点:
- 阈值到底怎么定? 不是拍脑袋,是先把全量分数打印出来看分布找"断层",在明显的分数落差处划线。
- 还有更强的解法吗? 有:用 rerank 重排序模型 对 Top-K 结果二次精排(比纯向量相似度更懂"能不能回答");或混合检索(向量 + 关键词 BM25 互补)。
- RAG 和直接问模型怎么选? 我做过对比实验:私有知识问题 RAG 完胜(直接问会编、RAG 精准引用且标出处);库里没有的问题 RAG 拒答更安全;但公开常识题 RAG 反而更差------会因文档没写而拒答,不如直接问。所以 RAG 不是用得越多越好,要看问题类型。
结语:真正值钱的,不是这 4 个答案
这四个坑串起来,是 RAG 的一条质量链:
markdown
切块质量 → 建库稳定性 → 检索准确性
(坑1 太碎 / 坑2 太大) (坑 3) (坑 4)
但比"记住这条链"更重要的,是它们底下藏着的两条共同线索------这才是我想留给你的东西。
第一条:这四个坑,全和直觉相反。 "切大块信息更全、更保险"------坑 2 说不,焦点会被稀释;"分数高的就是对的答案"------坑 4 说不,换货和退货只差 0.003;"代码一行没动就不会出错"------坑 3 说不,光是数据变大就能把连接搞断。在 RAG 工程里,凭感觉拍的板,大多会被实测打脸。
既然直觉靠不住,第二条线索才是真正的主角:把中间状态打印出来,用眼睛看。 坑 1 打印每一块的字数和预览,坑 2、坑 4 打印检索分数的分布,坑 3 死盯住"唯一变量"。我没有一次是靠猜改对的------全靠把黑盒的中间产物摊开看:块长什么样、向量打几分、请求体多大。
这才是这篇文章真正想给你的: 面试官不会因为你背得出"换货 0.8051"而记住你,但会因为你一遇到检索翻车就说"我先把每块、每个分数打印出来看一眼",而对你高看一眼。具体的知识点会过时、会被问到死角,但"让黑盒变透明"这套排查方法,换个框架、换个场景照样好使。
至于 RAG 本身,一句话收尾就够:它没有魔法------模型再强,也只能基于你喂进去的料回答。 这四个坑说到底都在保证同一件事:喂进去的、取出来的,是干净的、是对的。
第一篇教你把链路跑通 ;这一篇想说的是:跑通只是起点,能把"为什么跑不准"一层层拆开看的人,才算真的会 RAG。
💡 原创声明
本文首发于我的个人博客 rjy92.github.io。如需转载请注明出处。 (掘金 / CSDN 同步链接待发布后补充。)