你的 RAG 为什么答不准:10 个症状对号入座,每处都给了能直接抄的改法
你的 RAG 为什么答不准:10 个症状对号入座,每处都给了能直接抄的改法
2026 年有个很矛盾的现象:79.8% 的大企业把 AI 智能体接进了生产系统,但只有 17% 能把一个场景的成功复制到三个以上。
智能体要回答业务问题,绕不开接企业知识库——也就是 RAG。而 RAG 这边同时被唱衰:"长上下文都百万 token 了,文档直接全塞进去就行,检索那套过时了。"
我拆了自己跑着的 3 个问答系统,结论是:RAG 没过时,过时的是"教程版 RAG"——只搭了"检索+生成"两个框,中间 10 个要拍板的地方全是默认值。
这篇不跟你讲原理,直接按症状讲:你的系统哪里不对劲,对应哪一行代码,怎么改。每一条都附真实代码和一段能直接抄的改法。
先对号入座:你的系统中了哪几条?
| 症状 | 大概是哪一环出了问题 |
|---|---|
| 换个说法问,就答不上来了 | 症状 3:只会认字面 |
| 库里明明有答案,它偏说没找到 | 症状 2 / 症状 4 |
| 问得越具体,答案越残缺 | 症状 1:切块切碎了 |
| 偶尔整条线都返回空,查不出原因 | 症状 4:分数被算成了 0 |
| 最相关的那段排不到第一 | 症状 5:没有重排 |
| 有时候带引用,有时候不带 | 症状 6:模型挂了悄悄降级 |
| 不敢信它给出的"置信度" | 症状 7:那个数字根本不是置信度 |
| 每句话都有出处,但整体答非所问 | 症状 8:门槛等于没设 |
| 调了参数,说不清是变好还是变坏 | 症状 9:没有评测集 |
| 本地跑得好好的,一上线就崩 | 症状 10:编码 / 索引 |

这张图怎么看:左边就是大多数教程带你搭出来的东西,也是被判"过时"的东西——它答不准很正常,因为从出生起就没做对过。右边才是能用的版本:同样两个框,中间藏着 10 个要拍板的地方,而没有一个是教程会告诉你的。
一、先回答那个吵了两年的问题:长上下文来了,RAG 还要吗?
把上百万 token 直接塞给模型,听起来确实省事。但有四个问题解决不了:
① 贵。 每次提问都带着几十万字进模型,按 token 计费;RAG 只把最相关的几段递进去。文档越多差距越大。
② 慢。 塞的内容越多推理越慢。知识库问答要的是秒回。
③ 更新不了。 文档今天改了一个数字,检索方案下一秒就生效;全塞式方案每次都得重新组织最新全文。
④ 说不清来源。 企业场景最要命的一条:答案必须能指回原文哪一段,用户才敢信。RAG 天然带引用,全塞式给不了。
结论:RAG 不会死,它会换个名字继续活(这两年流行的新词叫"上下文工程",干的还是同一件事)。它好不好用,从来不是模型说了算,是工程说了算——就是下面这 10 个地方。
二、一次问答要经过 6 步,症状出在哪一步?

这张图怎么看:从上到下就是用户问一句之后,系统内部发生的事——先把文档切成小段,给每段做个"关键词目录";提问时找出最像的几段,排个序,最后拼成答案、标明出处。
下面 10 个症状,就分布在这 6 步里。
三、10 个症状,逐个对号入座
症状 1:问得越具体,答案越残缺
病因:切块。 系统第一步就是把文档切块——切坏了,后面检索再准也没用。我两个项目里的参数:
# 项目A—— config.py
CHUNK_SIZE = 120 # 一块 120 个字
CHUNK_OVERLAP = 20 # 相邻两块重叠 20 个字
# 项目B—— config.py
CHUNK_SIZE = 200 # 一块 200 个字
CHUNK_OVERLAP = 30
注意:这两个数不一样,而且都没有任何依据,就是"看起来差不多"。几乎所有项目的这个参数都是这么拍出来的。
四种切法各有各的病:按字数切会拦腰斩断句子;块太小时检索到一句孤零零的结论、前提却在另一块;重叠太多会造出大量重复块,它们会一起被检索出来占坑(你以为拿到 3 条证据,其实是同一段话的三个拷贝);块里不带标题则最容易漏——"图书馆"三个字根本不在块里,问"图书馆几点关门"就匹配不上。
💊 能直接抄的改法
# 切块时把标题拼进每一块,命中率立刻上升 chunks.append({"doc": name, "text": f"【{标题}】\n{块正文}"})再加一条自查:随机挑 20 个问题,人工看一眼"答案所在的原文"是不是被切到了两块里。被切碎的比例超过 20%,就把块调大或改成按段落切。
症状 2:库里明明有答案,它偏说"没找到"
病因:问题被过滤没了。 中文检索前要分词,还要过滤掉"的、了、是"这类虚词:
# utils/retriever.py
STOPWORDS = {"的", "了", "是", "在", "和", "与", "及", "或", "吗", "呢", "啊",
"什么", "怎么", "如何", "为什么", "请问", "一下", ...}
def tokenize(text):
return [w for w in jieba.lcut(text)
if w.strip() and w not in STOPWORDS and len(w) > 1]
检索入口是这样的:
def retrieve(self, question, top_k=config.TOP_K):
tokens = tokenize(question)
if not tokens:
return [] # 一个词都不剩,直接返回空
用户问 "怎么办?":分词得到 ["怎么", "办"] → "办" 只有一个字被 len(w) > 1 过滤 → "怎么" 在停用词表里被过滤 → 一个词不剩 → 返回空 → "没有找到相关内容"。
用户以为"库里没有",真实原因是停用词表把问题吃干净了。这类问题不报错、不告警,只在某些问法上悄悄失效。
💊 能直接抄的改法
def tokenize(text): words = [w for w in jieba.lcut(text) if w.strip() and w not in STOPWORDS and len(w) > 1] return words or list(text) # ← 兜底:全被过滤光时退回单字自查动作:把
["怎么办", "如何申请", "为什么", "是哪个"]这几个问题丢进你的系统,能返回空就是中招了。
症状 3:换个说法问,就答不上来了
病因:只会认字面。 三种"找资料"的方式,适用边界差别很大:

这张图怎么看:关键是第二、三行——用户不会照着你的文档原文提问。他说"借书能借多久",你的文档写的是"借期为 30 天",一个词都对不上,关键词方法直接失效。
- 关键词匹配(TF-IDF):数关键词重合,零依赖,但只认字面
- 关键词加强版(BM25):同样数关键词,但考虑了"这个词稀不稀缺""这段是不是太长",通常更好
- 语义匹配(向量):按意思找,能扛住换说法,但要额外装模型
💊 能直接抄的改法
先做这个判断,再决定要不要上向量:
你的用户怎么提问 选什么 会用文档原词提问(内部规章、技术文档) 关键词方法够用 会换说法、说口语(对外客服、产品问答) 必须上语义匹配 自查动作:拿 10 个问题,每个再写一个"说法完全不同"的版本,两版都能答对才算过关。
另外别忘了:知识高度结构化(课表、组织架构)用知识图谱又快又准,散文式内容(制度、指南)才用 RAG。它们适合的知识形态不同,不是二选一。
症状 4:偶尔整条线都返回空,查不出原因 ★ 最隐蔽
病因:两路分数合在一起时被算成了 0。 双路检索(关键词一路、语义一路)是主流做法,但找到之后两路的分数根本不是一个量级:

这张图怎么看:问题进来后兵分两路,"按词找"认字面、"按意思找"认语义,最后在"合并打分"这一步汇合。问题就出在汇合这一步:BM25 的分数能到 10 几、20 几,余弦相似度永远在 0~1,直接相加等于让分数大的那一路说了算。所以要先归一化:
# utils/rag_enhance.py
def _normalize(values):
lo, hi = min(values), max(values)
if hi - lo < 1e-12:
return [0.0 for _ in values] # ← 病根在这一行
return [(v - lo) / (hi - lo) for v in values]
def retrieve(self, question, top_k=None):
...
s = self.alpha * bm_n[i] + (1 - self.alpha) * tf_n[i]
...
return [m for m in merged if m[0] > 0][:top_k]
看那个 hi - lo < 1e-12:如果这一次所有候选的分数恰好全相等(库很小、或大家只命中同一个词),归一化把所有值全压成 0。合并后全是 0,if m[0] > 0 筛完——一条都不剩,系统返回"未找到"。
我在另一个项目里,同一个逻辑写成了这样(全等时保持原值、不归一化):
# utils/model.py
if b.max() > b.min():
b = (b - b.min()) / (b.max() - b.min() + 1e-8)
同一个问题两种写法,其中一种会在边界情况下悄悄返回空。 这类 bug 最难查:只在特定提问下出现,表现是"没找到"而不是报错。
💊 能直接抄的改法
最省心的方案是 RRF——不看分数只看排名,天然没有量纲问题:
# 两路各自给出排名,按名次加分(k 一般取 60) score = sum(1.0 / (60 + rank_i) for rank_i in 各路中的名次)或者至少把全等分支改掉:
if hi - lo < 1e-12: return [1.0] * len(values)(全等说明都一样相关,不该归零)。自查动作:构造一次"所有候选分数都一样"的查询(比如问一个每篇文档都出现的词),看系统是不是返回空。
症状 5:最相关的那段,排不到第一
病因:没有重排。 标准做法是两步:先宽召回(多找几条),再精排(重新打分)。
RERANK_TOP_K = 5 # 先召回 5 条
CROSS_ENCODER_ENABLED = False
TOP_K = 3 # 最后取 3 条
真正的精排模型需要额外装库,装不上时我做了个降级版:
new_score = s + 0.5 * 词重叠率 + 0.3 * 开头命中率 + 0.1 * 长度分
两个要诚实面对的点:它叫 CrossEncoder 但它不是(是几个简单指标加权,文档里别写"用了 Cross-Encoder 重排");系数是拍的,而且重排后的分数和重排前不再是同一个刻度(加起来最大能到 1.9),后面如果还要拿分数做判断,这个分数已经失真了。
💊 能直接抄的改法
降级版可以用,但返回结果里要标清楚,别让下游误用:
return {"answer": text, "reranker": "lite", "scores_final": False}自查动作:准备 10 个问题,人工标出"正确答案在哪一段",看它排第几。排在第 1 的比例低于 70%,就该加重排了。
症状 6:有时候带引用,有时候不带,风格还忽变
病因:模型接口挂了,系统悄悄降级了。 先看默认配置:
GENERATE_MODE = "template" # 模板模式
模板模式"生成"答案的方式是——把检索到的原文原样拼出来:
def template_generate(question, retrieved, top_k=config.TOP_K):
if not retrieved:
return "抱歉,在知识库中没有检索到与问题相关的信息。..."
for score, chunk in retrieved[:top_k]:
lines.append(f"【来源 {chunk['doc']} · 相关度 {score:.3f}】")
lines.append(chunk["text"].strip())
所以严格说,这只有"检索"没有"生成",本质是个带排序的段落查看器。工程上完全说得通(不花钱、答案 100% 有出处、永不瞎编),但你不能在文档里写"采用大模型生成答案"。
真正坑的是这个:
except Exception as e:
logger.warning("LLM API 调用失败(%s),回退到模板生成", e)
return template_generate(question, retrieved) # ← 静默降级
模型接口一挂,系统悄悄换成复制粘贴,用户完全不知情。
💊 能直接抄的改法
降级必须明示,别让它静默发生:
return {"answer": text, "mode": "template_fallback", "llm_error": str(e)}前端看到
mode != "llm"就显示一行提示:"本次为原文摘录"。另外,prompt 里写"请只根据资料回答、不要编造"是必要的,但别指望它——真想压幻觉,靠事后校验:答案里的关键实体,是不是真的出现在检索到的段落里。
症状 7:不敢信它给出的那个"置信度"
病因:名字叫错了。 我的代码里给答案算了个"置信度":
top_score = float(retrieved[0][0]) # 第一名的相似度
mean_score = sum(s for s, _ in retrieved) / len(retrieved) # 平均相似度
conf = max(0.0, min(1.0, 0.6 * top_score + 0.4 * mean_score))
它叫 confidence,但里面没有任何"答案对不对"的信息,只是"找到的资料有多像"。这两者可以完全对不上:
- 找到三条长得差不多、但都答非所问的段落 → 置信度很高,答案是错的
- 相似度偏低但恰好命中关键句 → 置信度很低,答案其实是对的
💊 能直接抄的改法
数字可以留,但改名,别让它被误当成"答案可靠的程度":
return {"evidence_match": conf} # 证据匹配度,不是置信度更稳的做法:只展示引用、不展示数字。用户点得回原文,比看一个 0.73 有说服力得多。
引用本身一定要做,成本极低、收益最大:
cites.append({"id": i, "doc": chunk["doc"], "chunk": chunk["chunk"] + 1, "snippet": chunk["text"][:80]})
症状 8:每句话都有出处,但整体答非所问 ★ 翻车率最高
病因:门槛等于没设。 看这个过滤条件:
return [r for r in ranked if r[0] > 0][:top_k]
门槛是"大于 0",翻译过来就是:只要沾上一个词,就算找到了。

这张图怎么看:最右边那张图里,排名第 8 的候选相似度只有 0.003——基本就是碰上了一个"的"字——但 0.003 也大于 0,照样被当成"找到的资料"拼进答案。
于是用户问了一个知识库根本没覆盖的问题,系统不是回答"我不知道",而是用三段最不相关的原文拼出一个像模像样的回答。
这比模型瞎编更危险——因为每一句话都有出处,用户更容易信。
💊 能直接抄的改法
# ① 设绝对下限,别再用 0 MIN_SCORE = 0.12 hits = [r for r in ranked if r[0] > MIN_SCORE][:top_k] # ② 再看差距:第一名不够突出,说明整体都是噪声 if not hits or (hits[0][0] - hits[1][0] < 0.05): return "这个问题我这边没有靠谱的资料,建议换个问法/问一下 XX 方面。"拒答话术要给出路(告诉用户你覆盖什么),别只说"抱歉"。
自查动作:故意问 10 个"库里肯定没有"的问题,数数有几个被硬凑出了答案。超过 2 个就是中招。
症状 9:调了参数,说不清是变好还是变坏
病因:没有评测集。 我目前的评估长这样:
questions = [
("图书馆几点关门?", "图书馆"),
("校车多久一班?", "校车"),
... # 一共 5 个手写问题
]
for q, kw in questions:
hits = bot.retrieve(q, top_k=3)
ok = any(kw in c for c, _, _ in hits) # 关键词出现了就算对
5 个问题,命中率只有 0%、20%、40%、60%、80%、100% 六种取值。你把块大小从 120 调到 150,命中率从 60% 变 80%——这 1 个题的变化说明不了任何事。 而且它只测了"找资料",完全没测"写答案"。
💊 能直接抄的改法
先攒 30 条问答对,必须包含这四类(缺一不可):
问题类型 测的是什么 库里明确有答案 找得准不准、答案对不对 答案要跨好几段拼 切块有没有把答案切碎 问法和原文完全不同 换个说法还找不找得到 库里根本没有 该说"不知道"时说没说 指标至少两个:Recall@K(正确段落有没有被召回)、拒答正确率(该拒的拒没拒)。
30 条不多,但足够让你每改一个参数都知道是变好还是变坏。没有它,你所有的调参都是在赌。
症状 10:本地跑得好好的,一上线就崩
病因:都是算法之外的小事,但能当场要命。
索引什么时候建? 我的实现是每次启动重新切块、建目录。库小无所谓,文档一多每次启动等几十秒。要考虑把索引存文件、文档更新时只做增量。
编码。 三个项目入口都带着这段:
sys.stdout.reconfigure(encoding="utf-8")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
Windows 控制台默认 GBK,日志里打出一个 R² 或者 ℃,整个程序直接崩在编码错误上。
💊 能直接抄的改法
入口固定加这两行;日志文件显式指定
encoding="utf-8";索引落盘后启动时先检查文件是否存在、文档有没有变(用修改时间判断),变了才重建。
四、体检清单:按顺序过一遍(建议收藏)
| # | 症状 | 病因 | 一句话自查 |
|---|---|---|---|
| 1 | 问得越具体越残缺 | 切块 | 块里带标题了吗?答案有没有被切碎? |
| 2 | 库里有却说没找到 | 问题被过滤光 | 问一句"怎么办"试试 |
| 3 | 换个说法就废 | 只认字面 | 每个问题写个"另一说法"的版本 |
| 4 | 偶尔整条线返回空 | 分数全等被归零 | 问一个每篇都出现的词 |
| 5 | 最相关的排不到第一 | 没有重排 | 正确答案排第 1 的比例够不够 |
| 6 | 有时有引用有时没有 | 模型挂了静默降级 | 返回里带 mode 字段了吗 |
| 7 | 不敢信"置信度" | 名字叫错了 | 它是"资料像",不是"答案对" |
| 8 | 每句有出处但答非所问 | 门槛等于 0 | 故意问 10 个库里没有的问题 |
| 9 | 调参说不清好坏 | 没有评测集 | 攒 30 条,含"库里没有的" |
| 10 | 上线就崩 | 编码 / 索引 | 入口加 UTF-8、索引落盘 |
最后一句:如果你的 RAG"能跑但不好用",别急着换更大的模型——先把这张表过一遍,问题大概率在第 4 行或者第 8 行。而这两个,跟模型强不强一点关系都没有。
你的 RAG 中了哪几条? 评论区报个数,我看看哪个症状最普遍。
如果这篇帮你定位到了问题,点赞 + 收藏一下,下一篇写"30 条评测集怎么快速搭起来"。
说明:文中代码均来自实际能运行的问答系统,参数与逻辑为真;示意图为原理示意,已在图注标明。
浙公网安备 33010602011771号