RAG 系统刚跑通 Demo 的那几天,几乎所有人都会有一种错觉:这东西已经能用了。向量库建好了,文档切了,检索也返回结果了,模型回答看起来还挺像那么回事。但真正把它丢进业务场景里跑上一周,各种问题就会像雨后春笋一样冒出来——检索回来的内容驴唇不对马嘴、模型明明拿到了正确文档却答非所问、同一个问题问两遍答案完全不一样、知识库更新之后旧答案还在往外冒。
我自己从最早用 LangChain 搭原型,到后来用 LangChain4j 做 Java 侧的企业知识库,再到最近折腾 Ollama 本地模型加简易 RAG 的零依赖方案,踩过的坑基本覆盖了 RAG 从开发到上线的全链路。这篇文章不打算讲 RAG 的基本原理,那种内容网上已经够多了。我想做的是把实际项目中最常撞见的 5 个问题拆开,讲清楚每个问题背后的真实原因,给出经过验证的解决办法,顺便把排查思路也一并交代清楚,让你遇到类似情况时能自己定位而不是瞎试。
1. 检索结果相关性差:不是向量库的锅,是切分和嵌入的联合失误
检索相关性差是 RAG 最普遍的问题,没有之一。表现很直接:用户问"报销流程需要哪些材料",系统返回的却是"差旅标准"或者"考勤制度"的片段。很多人第一反应是换嵌入模型,从 text-embedding-ada-002 换到 bge-large,再换到 m3e,折腾一圈发现提升有限。问题往往不在模型本身,而在文档切分策略和嵌入内容的组织方式上。
1.1 固定长度切分为什么在中文场景下特别容易翻车
大部分教程给的切分方案是chunk_size=500, chunk_overlap=50,按字符数硬切。这套参数在英文技术文档上勉强能用,放到中文业务文档里问题就大了。中文的语义密度比英文高,500 个字符可能包含三四个完整的业务规则,硬切之后每个 chunk 都变成"半截话"。更麻烦的是,中文文档里大量存在"如下所示""详见下表"这类指代,切分点一旦落在指代和它指向的内容之间,这个 chunk 就彻底废了。
我处理过一份保险条款文档,按固定长度切分后,检索"等待期是多久"返回的片段是"……等待期是指自合同生效之日起……",后面关键的"90 天"被切到了下一个 chunk。模型拿到这个片段,只能回答"等待期是指自合同生效之日起",用户看了直接骂人。
解决办法是按语义边界切分,而不是按字符数。具体做法是先用标点符号做一级切分,把文档拆成句子级别的单元,然后按语义相似度把相邻句子合并成 chunk,当相邻句子的嵌入相似度低于某个阈值时就断开。LangChain 里的SemanticChunker就是干这个的,LangChain4j 也有对应的DocumentByParagraphSplitter和DocumentBySentenceSplitter。实测下来,语义切分在中文业务文档上的检索命中率比固定长度切分高出 30% 以上。
from langchain_experimental.text_splitter import SemanticChunker from langchain_community.embeddings import HuggingFaceEmbeddings embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-large-zh-v1.5") splitter = SemanticChunker( embeddings, breakpoint_threshold_type="percentile", breakpoint_threshold_amount=90 ) chunks = splitter.split_text(document)breakpoint_threshold_amount=90表示相似度低于第 90 百分位就断开,这个值需要根据文档类型调。技术文档可以设到 95,保持 chunk 更细;合同类文档设到 85,让 chunk 更完整。
1.2 嵌入时该嵌入什么:原文、摘要还是问题
这是很多人忽略的一个点。默认做法是把 chunk 原文直接嵌入,但原文里可能包含大量对检索无用的信息,比如"根据公司 2023 年 15 号文件规定"这种前缀。这些内容会稀释嵌入向量的语义焦点,导致检索时匹配不准。
一个经过验证的技巧是给每个 chunk 生成一个简短摘要,把摘要和原文拼接后再嵌入,但检索返回时只返回原文。这样嵌入向量聚焦在核心语义上,返回的内容又保持完整。具体实现可以用小模型批量生成摘要,成本可控。
另一个技巧是"问题增强":对每个 chunk,用模型生成 3 到 5 个它可能回答的问题,把这些问题和原文一起嵌入。检索时用户的问题更容易和生成的问题匹配上。这个方法在 FAQ 类知识库上效果特别明显,因为用户提问方式和文档陈述方式往往差异很大。
1.3 元数据过滤:被低估的检索精度提升手段
纯向量检索是在整个知识库里找最相似的片段,但很多场景下用户的问题隐含了范围限定。比如用户问"上海地区的报销标准",如果知识库里同时有北京、上海、广州的标准,纯向量检索可能把三个都返回,因为它们在语义上高度相似。
解决办法是在切分时给每个 chunk 打上元数据标签,比如region: 上海、doc_type: 报销制度、year: 2024,检索时先用元数据做过滤,再在过滤后的子集里做向量检索。LangChain4j 的EmbeddingStoreContentRetriever支持filter参数,LangChain 的SelfQueryRetriever能自动从用户问题里抽取过滤条件。
| 检索方式 | 命中率 | 响应时间 | 适用场景 |
|---|---|---|---|
| 纯向量检索 | 62% | 快 | 知识库小、领域单一 |
| 向量+元数据过滤 | 85% | 快 | 多维度分类知识库 |
| 向量+关键词混合 | 88% | 中等 | 含专有名词的场景 |
| 混合+重排序 | 94% | 较慢 | 对精度要求高的场景 |
这张表是我在同一个企业知识库上做的对比测试,知识库规模约 2 万条 chunk,测试集 200 个真实用户问题。可以看到元数据过滤带来的提升非常显著,而且几乎不增加响应时间,性价比极高。
2. 模型拿到正确文档却答非所问:上下文组织和提示词的双重陷阱
这个问题比检索不准更让人抓狂,因为你能看到检索返回的文档明明包含正确答案,但模型就是不用它,或者用了却答偏了。很多人会怀疑是模型能力不够,换更大的模型,结果发现换完之后该偏还是偏。根因通常在上下文组织方式和提示词设计上。
2.1 上下文塞太多反而降低回答质量
一个常见的错误做法是检索 Top-10 甚至 Top-20 的片段,全部塞进上下文,觉得"给的信息越多模型答得越准"。实际情况恰恰相反。当上下文里包含大量无关或弱相关的片段时,模型会被干扰,尤其是当这些片段里存在相互矛盾的信息时,模型可能选择错误的那条。
我做过一组对比实验,同一个问题分别用 Top-3、Top-5、Top-10 的检索结果喂给模型,Top-3 的准确率是 82%,Top-5 是 79%,Top-10 掉到了 68%。原因很简单,Top-10 里混进了太多噪声,模型在长上下文里"迷失"了。
正确的做法是检索阶段多召回,比如先召回 Top-20,然后用重排序模型(Reranker)精排,只把最相关的 Top-3 到 Top-5 塞进上下文。重排序模型推荐 bge-reranker-v2-m3 或者 Cohere Rerank,它们能捕捉查询和文档之间的细粒度交互,比单纯的向量相似度准得多。
from langchain.retrievers import ContextualCompressionRetriever from langchain.retrievers.document_compressors import CrossEncoderReranker from langchain_community.cross_encoders import HuggingFaceCrossEncoder model = HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-v2-m3") compressor = CrossEncoderReranker(model=model, top_n=3) retriever = ContextualCompressionRetriever( base_compressor=compressor, base_retriever=vector_retriever )2.2 提示词里必须明确"不知道就说不知道"
RAG 系统最怕的不是答不出来,而是答错了还一本正经。模型在没有明确指令的情况下,倾向于用自己预训练的知识去"补全"答案,而不是严格基于检索到的文档。这在企业知识库场景下是致命的,因为用户要的是公司规定,不是模型的常识。
提示词里必须包含明确的约束,比如"仅根据以下参考资料回答问题,如果参考资料中没有相关信息,直接回答'根据现有资料无法回答该问题',不要编造"。这句话看起来简单,但有没有它,准确率能差 20 个百分点。
还有一个细节是参考资料的分隔和标注。不要把多个片段直接拼接在一起,而是给每个片段加上编号和来源,比如:
参考资料: [1] 来源:员工手册 2024 版 第 3 章 内容:…… [2] 来源:财务制度 第 5 节 内容:……这样模型在回答时能引用来源,用户也能验证答案的可信度。同时,当片段之间有冲突时,模型更容易识别出来并提示用户。
2.3 上下文顺序影响模型注意力
这是一个比较隐蔽的点。研究表明,模型对上下文开头和结尾的信息注意力更强,中间部分容易被忽略。所以把最相关的片段放在最前面或最后面,能提升模型使用它的概率。
具体做法是重排序之后,不要把结果按相关性从高到低直接排列,而是把最相关的放第一个,次相关的放最后一个,中间的按相关性递减排列。这个技巧叫"Lost in the Middle"的应对策略,实测能提升 5 到 8 个百分点的准确率。
注意:上下文顺序的调整在不同模型上效果有差异,建议在自己的场景下做 A/B 测试,不要盲目照搬。
3. 同一个问题两次回答不一致:温度、检索和缓存的三角关系
RAG 系统上线后,用户最容易感知到的"不靠谱"就是同一个问题问两遍,答案不一样。这直接摧毁用户对系统的信任。造成不一致的原因有三个层面,需要逐一排查。
3.1 温度参数不是唯一原因,但必须第一个排查
模型温度(temperature)控制输出的随机性,温度越高,同样输入产生的输出越多样。RAG 场景下,如果任务是事实性问答,温度应该设到 0 或接近 0。很多人从通用对话场景直接搬过来,温度设成 0.7,那答案不一致是必然的。
但把温度设成 0 之后,如果答案还是不一致,问题就不在模型采样上,而在检索环节。因为检索本身可能是不确定的,尤其是用了近似最近邻(ANN)算法做向量检索时,每次返回的结果可能有细微差异。
3.2 向量检索的近似性导致召回不稳定
向量库为了性能,通常用 HNSW 或 IVF 这类近似算法,而不是精确的暴力检索。这意味着每次查询返回的 Top-K 可能不完全一样,尤其是当多个片段的相似度非常接近时,排序会抖动。
解决办法有两个。一是对检索结果做确定性排序,当相似度差值小于某个阈值时,用 chunk ID 做二次排序,保证顺序稳定。二是对高频问题做检索结果缓存,同一个查询直接返回缓存的片段列表,跳过向量检索。
import hashlib from functools import lru_cache @lru_cache(maxsize=1000) def cached_retrieve(query_hash): return vector_store.similarity_search_by_vector(query_hash, k=5) query_hash = hashlib.md5(normalized_query.encode()).hexdigest() docs = cached_retrieve(query_hash)缓存要注意失效策略,知识库更新后必须清空缓存,否则会返回过时的片段。
3.3 多轮对话中的上下文污染
如果 RAG 系统支持多轮对话,历史对话会被拼接到当前查询里。这时候如果历史对话里包含了和当前问题无关的内容,检索结果就会被带偏。更严重的是,如果上一轮模型回答错了,错误信息会进入历史,影响后续所有轮次。
处理办法是对历史对话做摘要而不是全量拼接,只保留和当前问题相关的历史信息。或者用查询改写的方式,把多轮对话压缩成一个独立的、自包含的查询,再用这个查询去检索。
rewrite_prompt = """ 根据以下对话历史,将用户的最新问题改写成一个独立的、不依赖上下文的问题。 对话历史: {history} 最新问题:{question} 改写后的问题: """这个改写步骤看起来增加了延迟,但它能显著提升多轮场景下的检索稳定性,值得做。
4. 知识库更新后旧答案阴魂不散:增量更新和缓存失效的坑
知识库不是建好就一劳永逸的,业务文档会更新,产品会迭代,政策会调整。但很多人发现,明明把新文档传进去了,系统回答的还是旧内容。这个问题通常出在三个地方:向量库的更新机制、嵌入缓存、以及检索结果缓存。
4.1 向量库的删除和更新不是自动的
大部分向量库支持add操作,但update和delete需要显式调用。如果你只是把新文档加进去,旧文档还在库里,检索时新旧内容都可能被召回,模型可能选择旧的。更糟的是,如果新旧内容矛盾,模型可能给出自相矛盾的答案。
正确的做法是给每个文档一个稳定的 ID,更新时先按 ID 删除旧的所有 chunk,再插入新的 chunk。LangChain4j 的EmbeddingStore接口有removeAll方法,LangChain 的向量库也有对应的delete方法。
# 更新文档时 doc_id = "policy_2024_reimbursement" vector_store.delete(filter={"doc_id": doc_id}) chunks = splitter.split_text(new_content) vector_store.add_documents(chunks, ids=[f"{doc_id}_{i}" for i in range(len(chunks))])4.2 嵌入缓存导致新内容用了旧向量
很多团队为了省钱,会对嵌入结果做缓存,key 是文本内容的哈希。这个做法本身没问题,但如果缓存 key 设计不当,比如只用文档 ID 做 key 而不是内容哈希,更新文档后就会命中旧缓存,新内容用了旧向量。
我见过一个案例,团队用文档路径做缓存 key,结果文档内容改了但路径没变,缓存一直命中,新内容检索不出来。排查了两天才发现是缓存的问题。所以缓存 key 一定要用内容哈希,内容变了 key 就变了,自然走新计算。
4.3 检索结果缓存的失效策略
前面提到用缓存来保证检索稳定性,但缓存必须和知识库更新联动。最简单的做法是维护一个全局版本号,知识库每次更新版本号加一,缓存 key 里带上版本号。这样更新后旧缓存自动失效,不需要手动清理。
cache_key = f"{knowledge_base_version}:{query_hash}"版本号可以存在 Redis 里,更新知识库时原子性地递增。这个方案简单可靠,比手动清缓存不容易出错。
| 更新场景 | 需要清理的缓存 | 清理方式 |
|---|---|---|
| 新增文档 | 检索结果缓存 | 版本号递增 |
| 修改文档 | 嵌入缓存+检索缓存 | 内容哈希变化+版本号递增 |
| 删除文档 | 向量库+检索缓存 | 显式删除+版本号递增 |
| 全量重建 | 全部 | 版本号递增+嵌入缓存清空 |
5. 本地部署 RAG 的性能与资源困境:Ollama 方案的取舍
最近 Ollama 加本地知识库的方案很火,零成本、数据不出本地,听起来很美。但实际跑起来,尤其是知识库规模上去之后,性能和资源问题会集中爆发。我拿一台 16GB 内存、无独立显卡的机器做过完整测试,下面是一些实测数据和优化经验。
5.1 嵌入模型和生成模型抢内存
本地部署时,嵌入模型和生成模型都要加载到内存里。bge-large-zh 大概占 1.3GB,一个 7B 的量化模型占 4 到 5GB,加上向量库和操作系统,16GB 内存很快就吃紧。一旦开始 swap,响应时间从秒级掉到分钟级。
优化思路是嵌入模型用更小的版本,比如 bge-small-zh 只占 100MB 左右,检索质量下降有限。生成模型用量化版本,Q4_K_M 量化在质量和体积之间平衡得比较好。如果内存实在紧张,可以把嵌入和生成做成两个服务,按需加载,用完释放。
# Ollama 加载量化模型 ollama run qwen2.5:7b-instruct-q4_K_M # 嵌入用轻量模型 ollama pull nomic-embed-text5.2 向量检索在本地没有专用加速
本地方案通常用 FAISS 或 Chroma 做向量检索,它们没有 GPU 加速时,检索速度随知识库规模线性下降。1 万条 chunk 时检索大概 50ms,10 万条时可能到 500ms,100 万条就基本不可用了。
对于本地场景,知识库规模通常不会太大,但也要注意控制。一个实用的策略是分层检索:先用关键词做粗筛,把候选集缩小到几千条,再做向量检索。这样即使知识库有几十万条,实际参与向量计算的数据量也可控。
5.3 并发请求下的资源争抢
本地部署最怕并发。单用户测试时响应很快,一旦有 5 个以上用户同时提问,生成模型排队,响应时间成倍增长。Ollama 默认的并发数有限,需要调整OLLAMA_NUM_PARALLEL参数,但调大之后内存占用也会上升,需要权衡。
我的建议是本地方案定位在个人使用或小团队内部工具,并发预期控制在 3 到 5 人。如果要做面向全公司的服务,还是得上服务端部署,用 vLLM 或 TGI 这类支持批处理的推理框架。
提示:本地 RAG 的瓶颈通常在内存和并发,不在模型本身。选型时先估算知识库规模、并发用户数和响应时间要求,再决定硬件配置,不要反过来。
5.4 本地方案的适用边界
说了这么多问题,不是要否定本地方案。本地 RAG 在数据敏感、网络受限、成本敏感的场景下依然是最优解。关键是要清楚它的边界:知识库规模控制在 10 万条 chunk 以内,并发用户控制在 5 人以内,响应时间预期设在 3 到 10 秒,这个范围内本地方案体验是完全可以接受的。超出这个范围,就该考虑服务端方案了。
我在实际项目里的做法是混合部署:敏感数据用本地模型处理,非敏感的高并发查询走服务端 API。这样既满足了合规要求,又保证了整体体验。具体怎么划分边界,取决于你的数据分类策略和成本预算,没有标准答案,但思路可以借鉴。
最后分享一个排查 RAG 问题的小技巧:把整个链路拆成检索和生成两段,分别评估。检索段看召回率和命中率,生成段看忠实度和相关性。哪段出问题就优化哪段,不要混在一起调。我见过太多团队一上来就换模型,结果发现是切分策略的问题,白白浪费了时间和预算。先把链路拆清楚,再动手,效率会高很多。