你的 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,日志里打出一个 或者 ,整个程序直接崩在编码错误上。

💊 能直接抄的改法

入口固定加这两行;日志文件显式指定 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 条评测集怎么快速搭起来"。

说明:文中代码均来自实际能运行的问答系统,参数与逻辑为真;示意图为原理示意,已在图注标明。

posted @ 2026-09-20 00:26  橘和柠  阅读(9)  评论(0)    收藏  举报