11.人工智能实战:RAG 问答总是“答非所问”?从召回失败到重排优化的完整工程排查与解决方案
2026/5/8 21:08:30 网站建设 项目流程

人工智能实战:RAG 问答总是“答非所问”?从召回失败到重排优化的完整工程排查与解决方案


一、问题场景:模型不傻,但它拿到的上下文是错的

在做企业知识库问答系统时,很多人第一版架构通常是这样:

用户问题 ↓ Embedding 向量化 ↓ 向量数据库检索 TopK ↓ 拼接上下文 ↓ 大模型回答

这个链路看起来很标准,实际开发时也很容易跑通。

但上线测试后,经常会出现非常尴尬的问题:

1. 用户问 A,系统回答 B 2. 知识库里明明有答案,但模型说不知道 3. 检索出来的文档看起来相关,但真正答案不在里面 4. TopK 设置越大,回答反而越混乱 5. 模型生成很流畅,但事实错误很多

一开始我也以为是大模型能力不够,于是尝试:

换更大的模型 调 temperature 增加 max_tokens 加更长的 system prompt

结果发现效果并不稳定。

后来完整排查链路后才发现:

真正的问题不是“生成失败”,而是“检索失败”。

也就是说:

模型不是不会回答,而是你给它的上下文本来就不对。

这篇文章重点解决一个真实工程问题:

RAG 系统答非所问,如何从召回、切分、重排、上下文拼接四个层面系统优化。

二、真实问题复现:一个“能跑但不好用”的 RAG

先写一个最小可运行版本。

1. 安装依赖

pipinstallsentence-transformers faiss-cpu fastapi uvicorn numpy

2. 准备测试文档

docs=["公司报销制度:差旅住宿费一线城市每天不超过500元,二线城市每天不超过350元。","公司年假制度:员工入职满一年后享有5天年假,满三年后享有10天年假。","服务器上线规范:生产环境发布必须经过代码评审、自动化测试和灰度发布。","大模型部署规范:推理服务必须配置限流、熔断、监控和日志追踪。",]

3. 基础向量检索

importfaissimportnumpyasnpfromsentence_transformersimportSentenceTransformer model=SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")docs=["公司报销制度:差旅住宿费一线城市每天不超过500元,二线城市每天不超过350元。","公司年假制度:员工入职满一年后享有5天年假,满三年后享有10天年假。","服务器上线规范:生产环境发布必须经过代码评审、自动化测试和灰度发布。","大模型部署规范:推理服务必须配置限流、熔断、监控和日志追踪。",]doc_vectors=model.encode(docs,normalize_embeddings=True)doc_vectors=np.array(doc_vectors).astype("float32")index=faiss.IndexFlatIP(doc_vectors.shape[1])index.add(doc_vectors)query="一线城市出差住酒店最多报销多少钱?"query_vector=model.encode([query],normalize_embeddings=True)query_vector=np.array(query_vector).astype("float32")scores,ids=index.search(query_vector,k=2)forscore,idxinzip(scores[0],ids[0]):print(score,docs[idx])

这个例子可能正常召回报销制度。

但真实企业文档不是几句话,而是:

1. PDF 2. Word 3. Markdown 4. 飞书文档 5. Wiki 6. 工单记录 7. 历史会议纪要

文档一复杂,问题马上出现。


三、原因分析:RAG 答非所问通常不是一个问题

很多人把 RAG 效果不好归因于:

Embedding 模型不好

这只是其中一种可能。

实际工程里,RAG 问答失败通常来自五类问题:

1. 文档切分错误 2. 召回 TopK 不合理 3. 向量召回语义不准 4. 缺少重排 rerank 5. 上下文拼接太粗暴

四、问题一:文档切分不合理

文档切分是 RAG 的第一道关。

错误切分示例:

每 500 字强制切一段

这样会导致:

1. 一个完整条款被切断 2. 标题和正文分离 3. 表格语义丢失 4. 召回片段缺少上下文

例如原文:

第三章 差旅报销标准 一线城市住宿费每天不超过500元。 二线城市住宿费每天不超过350元。

如果切成:

chunk1: 第三章 差旅报销标准 一线城市住宿费每天 chunk2: 不超过500元。二线城市住宿费每天不超过350元。

用户问:

一线城市住宿费多少?

可能召回 chunk2,但 chunk2 没有“一线城市”的完整语义。


五、解决方案一:按结构切分,而不是按长度硬切

更合理的切分策略:

标题 + 段落 标题 + 条款 标题 + 表格行

示例代码:

defsplit_by_paragraph(text:str,max_chars:int=500):paragraphs=[p.strip()forpintext.split("\n")ifp.strip()]chunks=[]current=""forpinparagraphs:iflen(current)+len(p)<=max_chars:current+="\n"+pelse:ifcurrent:chunks.append(current.strip())current=pifcurrent:chunks.append(current.strip())returnchunks

如果有标题,可以把标题带入每个 chunk:

defattach_title_to_chunks(title:str,chunks:list[str]):return[f"{title}\n{chunk}"forchunkinchunks]

这样可以避免:

正文被召回,但不知道属于哪个章节。

六、问题二:只用向量召回容易“语义相似但事实不相关”

向量召回的优势是语义匹配,但它也有明显问题:

语义相似 ≠ 答案相关

例如用户问:

生产环境发布需要哪些步骤?

向量召回可能返回:

大模型部署规范:推理服务必须配置限流、熔断、监控和日志追踪。

这段和“生产环境”语义相近,但真正答案应该是:

服务器上线规范:生产环境发布必须经过代码评审、自动化测试和灰度发布。

这就是向量检索的典型问题。


七、解决方案二:混合检索 BM25 + 向量召回

工程上更稳的方案是:

向量召回 + 关键词召回

向量召回负责语义泛化,关键词召回负责精确匹配。

安装:

pipinstallrank-bm25 jieba

示例代码:

importjiebafromrank_bm25importBM25Okapi docs=["公司报销制度:差旅住宿费一线城市每天不超过500元,二线城市每天不超过350元。","公司年假制度:员工入职满一年后享有5天年假,满三年后享有10天年假。","服务器上线规范:生产环境发布必须经过代码评审、自动化测试和灰度发布。","大模型部署规范:推理服务必须配置限流、熔断、监控和日志追踪。",]tokenized_docs=[list(jieba.cut(doc))fordocindocs]bm25=BM25Okapi(tokenized_docs)query="生产环境发布需要哪些步骤"tokenized_query=list(jieba.cut(query))scores=bm25.get_scores(tokenized_query)ranked=sorted(enumerate(scores),key=lambdax:x[1],reverse=True)foridx,scoreinranked[:3]:print(score,docs[idx])

八、混合召回代码实现

defvector_search(query,top_k=5):query_vector=model.encode([query],normalize_embeddings=True)query_vector=np.array(query_vector).astype("float32")scores,ids=index.search(query_vector,top_k)results=[]forscore,idxinzip(scores[0],ids[0]):results.append({"doc":docs[idx],"score":float(score),"source":"vector"})returnresultsdefbm25_search(query,top_k=5):tokenized_query=list(jieba.cut(query))scores=bm25.get_scores(tokenized_query)ranked=sorted(enumerate(scores),key=lambdax:x[1],reverse=True)results=[]foridx,scoreinranked[:top_k]:results.append({"doc":docs[idx],"score":float(score),"source":"bm25"})returnresultsdefhybrid_search(query,top_k=5):candidates=vector_search(query,top_k)+bm25_search(query,top_k)seen=set()merged=[]foritemincandidates:ifitem["doc"]notinseen:merged.append(item)seen.add(item["doc"])returnmerged

九、问题三:没有 rerank,TopK 顺序不可靠

混合召回之后,会得到一批候选片段。

但这批候选片段只是“可能相关”,不代表顺序正确。

这时需要:

rerank 重排

重排模型会同时看:

query + document

判断它们的真实相关性。


十、解决方案三:加入 Cross Encoder 重排

安装:

pipinstallsentence-transformers

示例:

fromsentence_transformersimportCrossEncoder reranker=CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")defrerank(query:str,candidates:list[dict],top_k:int=3):pairs=[(query,item["doc"])foritemincandidates]scores=reranker.predict(pairs)reranked=[]foritem,scoreinzip(candidates,scores):new_item=item.copy()new_item["rerank_score"]=float(score)reranked.append(new_item)reranked=sorted(reranked,key=lambdax:x["rerank_score"],reverse=True)returnreranked[:top_k]

完整调用:

query="生产环境发布需要哪些步骤?"candidates=hybrid_search(query,top_k=5)final_docs=rerank(query,candidates,top_k=3)foriteminfinal_docs:print(item["rerank_score"],item["doc"])

十一、问题四:上下文拼接太粗暴

很多系统直接这样拼:

context="\n".join([doc["doc"]fordocinfinal_docs])

然后丢给模型。

问题是:

1. 没有来源标记 2. 没有相关性顺序 3. 没有控制总长度 4. 多个片段可能互相冲突

更合理的方式是:

defbuild_context(docs:list[dict],max_chars:int=3000):parts=[]total=0fori,iteminenumerate(docs,start=1):text=f"[资料{i}]\n{item['doc']}\n"iftotal+len(text)>max_chars:breakparts.append(text)total+=len(text)return"\n".join(parts)

十二、构造更稳的 Prompt

defbuild_prompt(query:str,context:str):returnf""" 你是一个企业知识库问答助手。 请严格根据【资料】回答用户问题。 如果资料中没有答案,请回答:根据现有资料无法确定。 不要编造资料之外的内容。 【资料】{context}【用户问题】{query}【回答要求】 1. 先给出直接答案 2. 再说明依据来自哪条资料 3. 不要输出资料中没有的信息 """

这样可以降低模型幻觉。


十三、完整 RAG 流程

defrag_answer(query:str):candidates=hybrid_search(query,top_k=8)reranked_docs=rerank(query,candidates,top_k=3)context=build_context(reranked_docs,max_chars=3000)prompt=build_prompt(query,context)return{"prompt":prompt,"references":reranked_docs}

十四、验证结果

优化前:

向量召回 Top3 命中率:65% 回答准确率:60% 经常答非所问

优化后:

混合召回 + rerank Top3 命中率:85%+ 回答准确率明显提升 错误回答减少

这里要注意:

RAG 优化不能只看模型回答,要先看检索命中率。

如果检索没命中,生成阶段再强也没用。


十五、踩坑记录

坑 1:只优化 Prompt,不看召回

很多人看到回答错了,就不断改 Prompt。

但如果上下文本来就是错的,Prompt 再好也救不了。


坑 2:TopK 设置越大越好

TopK 太小,容易漏答案。

TopK 太大,会引入噪声。

一般建议:

召回阶段 TopK = 8~20 重排后 TopK = 3~5

坑 3:文档切分只按长度

这会破坏语义结构。

建议优先按:

标题 段落 条款 表格行

坑 4:没有记录召回结果

RAG 系统必须把每次召回的文档记录下来。

否则用户说回答错了,你根本不知道模型看到了什么。


坑 5:把所有历史对话都塞进上下文

上下文越长,成本越高,噪声越多。

RAG 系统要控制:

有效上下文

不是堆得越多越好。


十六、适合收藏的 RAG 排查 Checklist

文档处理: [ ] 是否按结构切分 [ ] 是否保留标题 [ ] 是否处理表格 [ ] 是否保留来源 召回阶段: [ ] 是否有向量召回 [ ] 是否有关键词召回 [ ] 是否做去重 [ ] 是否记录召回结果 重排阶段: [ ] 是否使用 rerank [ ] 是否控制重排后 TopK [ ] 是否评估 TopK 命中率 生成阶段: [ ] 是否限制上下文长度 [ ] 是否要求基于资料回答 [ ] 是否允许回答“无法确定” [ ] 是否输出引用依据 评估阶段: [ ] 是否有测试问题集 [ ] 是否统计召回命中率 [ ] 是否人工抽检回答质量

十七、经验总结

RAG 系统最容易犯的错误是:

把问题都归因于大模型。

但真实工程里,RAG 的质量通常取决于:

1. 文档是否处理好 2. 召回是否准确 3. 重排是否有效 4. 上下文是否干净 5. Prompt 是否约束清楚

一句话总结:

RAG 不是“检索 + 生成”的简单拼接,而是一套信息筛选系统。

如果你的 RAG 系统总是答非所问,不要第一时间换模型。

应该先问:

模型到底拿到了什么资料?

十八、后续优化建议

可以继续增强:

1. 使用更强的中文 Embedding 模型 2. 引入 bge-reranker 等中文重排模型 3. 对表格做结构化解析 4. 建立离线评测集 5. 统计不同问题类型的召回命中率 6. 对高频问题建立缓存 7. 对长文档做父子 chunk 检索 8. 引入 query rewrite 改写用户问题

最后一句经验:

RAG 的核心不是让模型更会编,而是让模型只看正确资料。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询