RAG 实战:知识库问答总在胡编?根因往往不是模型,而是检索

给公司做了个知识库问答,上线第二天投诉就来了:问报销答考勤,问接口超时编出根本不存在的参数名。第一反应是模型太笨,换了个更贵的——几乎没变。直到把喂给模型的检索结果打印出来才醒悟:模型没胡编,它只是忠实地基于一堆错误资料在回答,问题从头到尾不在生成,在检索。这篇从这个答非所问的事故讲起,把朴素 RAG 为什么会废、怎么一步步把召回质量救回来讲透:debug 召回内容、结构化分块加重叠、选对中文 embedding、宽召回加 rerank 精排、给 prompt 立规矩、用 recall@k 量化,再到混合检索补上专有名词盲区。

我们给公司内部做了个知识库问答:把几百篇制度文档、技术手册灌进去,接上大模型,本意是让同事问一句"差旅报销额度是多少"就能直接得到答案。上线第二天,投诉就来了——有人问报销流程,它答出一段关于考勤的规定;有人问某个接口的超时配置,它一本正经编了个根本不存在的参数名。我的第一反应和所有人一样:模型太笨了,换个更强的。于是把底层模型从一个换到另一个更贵的,结果——几乎没变。

真正让我醒过来的,是那天我把"喂给模型的检索结果"打印了出来。用户问报销,系统从向量库里捞回来的前几段,压根就不是报销相关的内容,而是一堆词面上沾点边、语义上风马牛不相及的段落。那一刻我才明白:模型没有胡编,它只是忠实地based on 一堆错误的资料在回答。问题从头到尾不在生成,在检索。这篇就从这个"答非所问"的事故讲起,把一套朴素 RAG 为什么会废、该怎么一步步把召回质量救回来讲透——分块、embedding、检索、重排、prompt、评估,每一环都在决定模型到底拿到的是金子还是垃圾。

先认清:RAG 答非所问,锅到底在哪一环

RAG(检索增强生成)说白了就两步:先检索——从你的知识库里捞出和问题相关的片段;再生成——把这些片段连同问题一起塞给大模型,让它据此作答。绝大多数人的注意力都在"生成"这一端(换模型、调 prompt),但我后来踩坑无数次的经验是:线上 RAG 的答非所问,九成根子在"检索"这一端。检索捞回来的是错的,生成再强也只是把错误资料组织得更通顺而已。先把常见根因摊开看:

根因 典型表现 下刀方向
分块把语义切碎了 按固定字数硬切,一个完整规定被切成两半,谁都不完整 按语义/结构分块 + 重叠
embedding 选得不对 用了不适合中文或不适合该领域的向量模型,相似度算不准 换匹配语言/领域的 embedding
只取 topK 太少 topK=3,真正相关的那段排在第 8,根本没进上下文 粗召回放大 + 重排
缺少重排(rerank) 向量相似 ≠ 真正相关,粗召回里混进一堆噪声 加 cross-encoder 重排
原文里压根没有 知识库根本没收录这个信息,却硬要模型答 让模型据实说"不知道"

我那次的事故,前四条占全了:文档是按固定 500 字硬切的,一条报销规定被拦腰切断;用的 embedding 模型对中文不友好;topK 只取了 3;完全没有重排。检索这一端千疮百孔,我却一直在生成那端换模型——南辕北辙。

第一件事:别猜模型笨不笨,先把召回的内容打印出来

调 RAG 的第一原则,和调任何系统一样:先定位是哪一环出了问题,别凭感觉。而 RAG 定位问题的第一步,简单到很多人想不到——把检索召回的片段原样打印出来,人眼看一遍。如果召回的片段本身就不相关,那再怎么调 prompt、换模型都是白费;如果召回的片段明明很相关、模型却答歪了,那才轮到去查生成端。

# 调 RAG 的第一步:把召回内容连同相似度分数打印出来,人眼判断
def debug_retrieve(query, vector_store, top_k=5):
    results = vector_store.similarity_search_with_score(query, k=top_k)
    print(f"问题: {query}\n{'='*50}")
    for i, (doc, score) in enumerate(results, 1):
        # score 是相似度(或距离),先看分数分布合不合理
        print(f"[{i}] 相似度={score:.4f}")
        print(f"    来源: {doc.metadata.get('source', '未知')}")
        print(f"    内容: {doc.page_content[:120]}...")
        print('-' * 50)

# 关键判断:
#   召回内容和问题不相关        -> 检索端问题(分块/embedding/rerank),往下看
#   召回内容相关、但答案还是错  -> 生成端问题(prompt/模型),去查第五件事
debug_retrieve("差旅报销额度是多少", store)

这一步看着土,却是整套排查里最值钱的一步。我就是靠它,第一次亲眼看到"原来捞回来的根本不是报销那段",才从"换模型"的死胡同里掉头。RAG 是个流水线,任何一环错了,最后输出都会错;不把中间产物打开看,你永远在拿最贵的模型给最前端的分块错误擦屁股。

第二件事:分块——RAG 质量的地基,大多数人第一步就崴了

定位到检索端之后,第一个要查的就是分块(chunking)。文档不可能整篇塞进模型,必须切成小片段分别做 embedding、分别检索。怎么切,直接决定了每个片段是不是一个"语义完整的单元"。先看整条 RAG 流水线长什么样,分块在最前面,也最致命:

注意那条虚线:分块如果把语义切碎了,后面 embedding 再准、检索再强、模型再贵,都救不回来——因为每个片段本身就不是一个完整的意思了。最常见的错法,就是按固定字数硬切(我那次就是 500 字一刀),完全不管句子和段落边界,一条完整的报销规定被切成"前半句在第 7 块、后半句在第 8 块",检索时哪一块都不完整。正确的做法是尊重文本结构、并让相邻块有重叠:

# 反面:按固定字数硬切,不管语义边界
chunks = [text[i:i+500] for i in range(0, len(text), 500)]  # 一条规定可能被拦腰斩断

# 正面:递归按结构切(优先按段落/句子边界),并设置重叠
from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,           # 每块目标大小
    chunk_overlap=80,         # 相邻块重叠 80 字:避免边界信息被切断后两边都不全
    separators=["\n\n", "\n", "。", "!", "?", " ", ""],  # 优先在段落/句号处切
)
chunks = splitter.split_text(text)
# overlap 的意义:即便一个完整意思跨了两块边界,重叠区也能保证至少有一块包含完整信息

重叠(chunk_overlap)这个参数特别值钱却常被忽略:它让相邻两块共享一段内容,这样即使某个完整语义恰好落在切割边界上,重叠区也能保证至少有一块把它装全。光是把硬切换成结构化切分 + 重叠,我那个知识库的召回相关度就肉眼可见地好了一截——很多片段从"半句话"变回了"一条完整规定"。

第三件事:embedding 选不对,相似度全是错的

分块切好后,每一块都要用 embedding 模型转成一个向量,检索时再把问题也转成向量,算"谁和谁最接近"。这里有个我交过学费的坑:embedding 模型必须匹配你的语言和领域。我最初图省事用了个默认的英文为主的模型,中文文档的向量分布挤成一团,"报销"和"考勤"的向量离得近得离谱——相似度算出来全是错的,检索自然全乱。换成专门优化过中文的 embedding 模型后,相似度立刻拉开了差距。

# 用适配中文的 embedding 模型(例:BGE 系列),别用默认英文模型
from sentence_transformers import SentenceTransformer
import numpy as np

model = SentenceTransformer('BAAI/bge-base-zh-v1.5')  # 中文场景选中文 embedding

def embed(texts):
    # normalize 后,点积就等于余弦相似度,后续检索更直接
    return model.encode(texts, normalize_embeddings=True)

def cosine_topk(query, chunks, chunk_vecs, k=50):
    q = embed([query])[0]
    sims = chunk_vecs @ q              # 已归一化,点积=余弦相似度
    idx = np.argsort(-sims)[:k]        # 取相似度最高的 k 个作为"粗召回"
    return [(chunks[i], float(sims[i])) for i in idx]

这里埋了个伏笔:我特意取了 k=50 这么大一个数,而不是直接取最终要用的 5 条。原因是向量相似度只是"粗筛"——它快,但不够准。所以策略是先用它宽松地召回一批候选(50 条),再用更精准但更慢的手段从这 50 条里精挑出最相关的 5 条。这就引出了下一件、也是我那次提升最大的一件事:重排。

第四件事:加一层 rerank,召回质量直接上一个台阶

这是整套优化里性价比最高的一招。前面说向量检索是"粗筛",它的问题在于:向量相似 ≠ 真正相关。两段文字向量接近,可能只是因为用词风格像、主题域沾边,未必真能回答这个具体问题。rerank(重排)就是在粗召回之后,加一个专门判断"这段文字到底能不能回答这个问题"的模型——通常是 cross-encoder,它把"问题+候选片段"成对一起读,给出一个精准的相关性分数:

# 两阶段检索:向量粗召回 topK=50 -> cross-encoder 精排 -> 取 top5
from sentence_transformers import CrossEncoder

reranker = CrossEncoder('BAAI/bge-reranker-base')

def retrieve_with_rerank(query, chunks, chunk_vecs, coarse_k=50, final_k=5):
    # 1) 向量粗召回:快,宽松地捞一批候选
    candidates = cosine_topk(query, chunks, chunk_vecs, k=coarse_k)
    texts = [c for c, _ in candidates]

    # 2) cross-encoder 精排:把 (问题, 候选) 成对打分,这一步才是"真相关性"
    pairs = [[query, t] for t in texts]
    scores = reranker.predict(pairs)

    # 3) 按精排分数取前 final_k 条,送进 prompt
    ranked = sorted(zip(texts, scores), key=lambda x: -x[1])
    return ranked[:final_k]

为什么粗召回要放大到 50?因为真正相关的那段,在纯向量相似度排序里可能排到第 8、第 20——如果你一开始只取 top5,它根本进不了候选,rerank 再神也救不了一个没被召回的片段。所以是"向量宽召回(保证相关的进得来)+ rerank 精排(把相关的顶上去)"两段配合。我那个知识库加上 rerank 这一层之后,用户主观感受的提升是最大的——以前问报销捞回考勤,现在稳稳是报销那几段。这一招我后来逢人就推荐:RAG 效果不好,先别急着换模型或重做分块,先加一层 rerank 试试,往往立竿见影。

第五件事:prompt 要给模型"立规矩",别让它自由发挥

检索端救好之后,才轮到生成端。这一步的核心不是把片段一股脑丢给模型,而是用 prompt 给它立两条规矩:只准基于给的资料回答;资料里没有就明说不知道,不准编。很多"一本正经胡说"的案例,就是 prompt 没立规矩,模型在资料不足时擅自调用了自己的"想象力":

# prompt 立规矩:强制基于上下文 + 允许"不知道" + 要求标注引用来源
PROMPT = """你是企业知识库助手。请严格根据下面提供的【参考资料】回答用户问题。

要求:
1. 只能基于【参考资料】中的内容作答,不得编造资料里没有的信息。
2. 如果参考资料中找不到答案,直接回答"根据现有资料无法回答该问题",不要猜测。
3. 回答时在相关句子后用 [来源N] 标注依据,便于用户核查。

【参考资料】
{context}

【用户问题】
{question}

【回答】"""

def build_prompt(question, ranked_chunks):
    context = "\n\n".join(
        f"[来源{i}] {text}" for i, (text, _) in enumerate(ranked_chunks, 1)
    )
    return PROMPT.format(context=context, question=question)

这三条规矩里,第 2 条"允许说不知道"是最反直觉、也最重要的。能正确地回答"我不知道",比硬编一个看起来很专业的错误答案,价值高得多。知识库问答最大的信任杀手不是"答不出",而是"一本正经地答错"——用户被骗一次,就再也不敢信了。第 3 条带引用则让答案可核查,用户能点回原文确认,既增加信任,也方便你发现是哪一条召回片段把模型带歪了。

第六件事:别靠感觉,给召回质量一个能量化的尺子

前面几招做完,我犯过一个新错误:全靠"我自己试几个问题,感觉好多了"来判断效果。这非常危险——你试的那几个问题往往正好是你调过的,样本偏差极大,上线后照样翻车。RAG 一定要有一把能量化的尺子,哪怕很简陋。最朴素也最有效的办法:攒一个小评测集,几十条"问题 + 标准答案应该来自哪篇文档"的标注,然后量 recall@k——真正相关的文档,有没有出现在你召回的前 k 条里。

# 最朴素的离线评测:用标注好的小测试集量 recall@k
# eval_set: [(问题, 该问题答案应来自的文档ID集合), ...]
def recall_at_k(eval_set, retrieve_fn, k=5):
    hit = 0
    for question, gold_doc_ids in eval_set:
        retrieved = retrieve_fn(question, final_k=k)   # 返回 top-k 片段及其来源doc_id
        got_ids = {doc_id for _, doc_id in retrieved}
        if got_ids & gold_doc_ids:                      # 命中任意一个标准来源即算召回成功
            hit += 1
    return hit / len(eval_set)

# 每次改分块/换embedding/调rerank后,都跑一遍对比,用数字说话
print(f"recall@5 = {recall_at_k(eval_set, retrieve_with_rerank, k=5):.2%}")
# 改一处 -> 量一次 -> 留住涨的、回退掉的。别再用"我感觉变好了"做决策

有了这把尺子,每一次改动——换 embedding、调分块大小、加不加 rerank——都能用一个数字说话:涨了就留,跌了就回退。RAG 优化最怕的就是"凭感觉调参",今天动这里、明天动那里,最后自己都说不清到底哪个改动有用。哪怕只有三五十条标注,一个客观的 recall@k 也比纯主观靠谱一百倍。我那个知识库,正是靠这把尺子,才把"换中文 embedding""加 rerank""分块加重叠"每一项的真实收益量化了出来,不再是玄学。

把整套排查收成一棵决策树

把前面六件事串起来,下次再遇到 RAG 答非所问,照着这棵树走,基本不会跑偏:

这棵树的总开关,就是第一件事那句话:先打印召回片段,判断是"检索不相关"还是"检索相关但生成歪了"。这两条岔路的药方完全不同——前者往分块、embedding、rerank 上治;后者往 prompt 上治。认错了岔路,你就会在生成端使尽全力,而真正的病在检索端。

收口成几条 RAG 的铁律

  1. 先 debug 召回内容,再谈优化:把检索到的片段打印出来人眼看,先分清是检索问题还是生成问题,别一上来就换模型。
  2. 分块要尊重语义边界并加重叠:别按固定字数硬切,用结构化切分 + overlap,保证每块是个完整意思。
  3. embedding 要匹配语言和领域:中文用中文 embedding,专业领域优先选领域适配模型,别用默认英文模型。
  4. "宽召回 + rerank 精排"是性价比之王:向量召回放大到几十条,再用 cross-encoder 精排出最终几条,效果立竿见影。
  5. prompt 必须立规矩:强制基于上下文回答、允许并要求说"不知道"、带引用来源——能正确说不知道远胜一本正经地编。
  6. 用 recall@k 量化每次改动:攒个小评测集,改一处量一次,用数字而不是感觉来决定留还是回退。
  7. 知识库本身的质量是上限:原文没有的信息,再强的 RAG 也变不出来;该补文档就补文档,别指望检索和模型凭空生成。

几个特别容易踩的认知误区

这套经验分享给做 AI 应用的朋友时,有几个误区几乎人人都中过,值得专门点破。

第一个、也是最普遍的:"RAG 效果不好,换个更强的大模型就行了。" 这正是我开头交的学费。RAG 是"检索 + 生成"的流水线,如果检索捞回来的就是错的资料,那生成端的模型再强,也只是把错误资料组织得更通顺、更像那么回事——甚至更危险,因为它错得更有说服力。生成质量的上限,被检索质量死死焊住。

第二个误区:"分块嘛,定个字数切一切就行。" 分块是 RAG 的地基,地基歪了上面全白搭。固定字数硬切会把一个完整语义拦腰斩断,导致每个片段都不完整,检索时谁都匹配不准。尊重段落/句子边界、加重叠,这点改动小,收益却是结构性的。

第三个误区:"向量相似度高,就是相关。" 不一定。向量相似更多反映"主题/风格接近",未必能回答你这个具体问题。这正是 rerank 存在的意义——它用 cross-encoder 真正去判断"这段能不能答这个问题"。把向量召回当终点,是很多 RAG 召回噪声大的根源;把它当"粗筛的第一道",后面接 rerank,才对。

第四个误区:"上线试几个问题感觉不错,就算调好了。" 你试的问题有强烈的样本偏差,常常正好是你调过的。没有一个客观的评测集和 recall@k 这类指标,你的"调优"就是在黑暗里乱挥拳,改对改错全凭运气,还无法复盘。哪怕只有几十条标注,量化也远胜主观感受。

再补一招:混合检索,治向量检索的"专有名词盲区"

前面那套救活了大部分问题,但上线后我又撞见一类顽固的漏召回:用户问具体的型号、错误码、接口名、专有缩写时,向量检索经常翻车。比如有人问"ERR_5021 是什么意思",文档里明明有这一条,可向量检索就是召不回来。原因是 embedding 擅长捕捉"语义相近",却对这种必须精确匹配的字符串很迟钝——ERR_5021ERR_5012 在向量空间里几乎一样近,但它们是两个完全不同的错误码。

解法是混合检索(hybrid search):把传统的关键词检索(BM25,擅长精确匹配字面词)和向量检索(擅长语义匹配)两路结果融合起来,取长补短。语义类问题靠向量,精确名词类问题靠 BM25,两边各召回一批,再合并去重一起送进 rerank:

# 混合检索:BM25(精确字面) + 向量(语义),两路召回合并后统一交给 rerank
from rank_bm25 import BM25Okapi

bm25 = BM25Okapi([tokenize(c) for c in chunks])   # tokenize:中文需先分词

def hybrid_retrieve(query, chunks, chunk_vecs, k_each=30, final_k=5):
    # 1) 向量召回:抓语义相近的
    vec_hits = [c for c, _ in cosine_topk(query, chunks, chunk_vecs, k=k_each)]
    # 2) BM25 召回:抓字面精确命中的(型号/错误码/专有名词全靠它)
    bm_scores = bm25.get_scores(tokenize(query))
    import numpy as np
    bm_hits = [chunks[i] for i in np.argsort(-bm_scores)[:k_each]]
    # 3) 合并去重 -> 统一 rerank 精排,让两路候选公平竞争
    merged = list(dict.fromkeys(vec_hits + bm_hits))   # 保序去重
    pairs = [[query, t] for t in merged]
    scores = reranker.predict(pairs)
    return sorted(zip(merged, scores), key=lambda x: -x[1])[:final_k]

关键在那句"合并后统一 rerank":两路用的是不同的打分体系(BM25 分数和向量相似度没法直接比大小),硬融合分数很容易翻车,而把它们都当成"候选池",再用同一个 rerank 模型重新打一遍分,就把两边拉到了同一把尺子上公平排序。加上混合检索后,我那个知识库里"问错误码、问接口名"这类以前必漏的问题,召回率明显补齐了。它和前面那套不冲突,而是又一层补强:向量管语义、BM25 管字面、rerank 管最终排序——各司其职,才是一套扛得住真实提问千奇百怪的检索链路。

写在最后

回到开头那个"问报销答考勤"的尴尬。最终把它救活的,其实不是什么高深技术,而是把检索这条流水线的每一环都老老实实捋了一遍:分块从硬切改成结构化切分加重叠,embedding 换成中文适配的,加上一层 rerank 精排,再给 prompt 立下"基于资料、允许不知道、带引用"的规矩,最后用一个几十条的评测集把每项改动的收益量化下来。底层大模型自始至终没换——那个我一开始拼命想换掉的"笨模型",其实一点都不笨,它只是被我喂了一堆错资料。

这件事给我最深的体会是:RAG 的效果是"检索质量"和"生成约束"共同决定的,而其中检索质量往往是被忽视、却又是上限所在的那一半。大模型不是魔法,它只能基于你递到它面前的资料作答;你递错了,它就答错。下次你的知识库问答又开始一本正经地胡说,别急着怪模型、急着换更贵的——先把召回的片段打印出来看一眼,答案,往往就藏在那几段"驴唇不对马嘴"的检索结果里。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Docker 镜像瘦身实战:从 1.8GB 到 23MB,多阶段构建与分层缓存

2026-5-29 21:12:44

技术教程

缓存三连击实战:穿透、击穿、雪崩,一个热点 key 如何打挂数据库

2026-5-29 21:23:02

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索