总结RAG系统的五大生产陷阱及对应解决方案,涵盖混合搜索、重排、查询重写、缓存和评估等优化策略。
在接触向量存储之前,要准确理解朴素 RAG 的失败之处。下面的每一个修复都对应其中之一:
修复不是更好的嵌入模型。而是一个管道:混合检索 → 重排 → 生成,每个阶段都有评估。
query
│
├─ Query rewriting ──► (decompose / expand / HyDE)
│
├─ Hybrid retrieval ──► BM25 (keyword) ──┐
│ └─ Dense (vector) ──┼─► merge (RRF) ──► top 50
│ │
├─ Reranking ──► cross-encoder scores the 50 ──► top 5 │
│ ▼
├─ Semantic cache ──► exact + embedding-similar past queries ◄─┘
│ │
└─ Generation ──► prompt with citations + guardrails ──► answer
每个阶段都是独立且可互换的。这就是重点所在——你可以今天用 BM25 + 重排器发布,下个季度换一个更好的嵌入模型,而无需触碰其余的部分。
固定大小分块加重叠是起点,不是策略。2026 年最佳规则:按语义边界分块,仅在块过大时才进一步分割。
from langchain_text_splitters import RecursiveCharacterTextSplitter
# Structure-aware: split on section boundaries first, then fall back to size
splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=120,
separators=["\n## ", "\n### ", "\n\n", "\n", ". ", " "],
keep_separator=True, # keep the heading attached to its chunk — crucial
)
三条规则能防止大多数分块失败:
保持标题与内容相关联。从中间某处开始的块会失去其主题。keep_separator=True 不仅是修饰性的——它让检索器能够匹配"退款政策是什么?"到关于退款的章节。
为每个块添加元数据:源文档、章节路径、页码、最后修改日期。你需要它来做引用([3] — refunds.md §2.1)和过滤检索("只有 3 月后更新的文档")。
永远不要在表格、代码块或 JSON 中间分割。这些格式比散文更密集,切割时会破坏语义。将它们提取为自己的块类型,加上 type: "table" 标签,让关键词搜索处理它们——按列嵌入表格比无用还糟。
对于有真实结构的文档类型(API 文档、财务报告、规范),考虑语义分块——在连续句子间的嵌入相似度下降处分割。这在索引时花费更多计算,在长文档的检索上获得明显更好的效果。
以下是 2026 年生产 RAG 的一个尴尬事实:纯向量搜索在最重要的查询上失败——精确标识符、代码、错误字符串、产品名称、数字。嵌入在 token 上是有损的;INV-88123 和 INV-88124 嵌入几乎相同,而精确匹配查询检索出噪声。
修复是混合搜索:运行 BM25(或你存储的全文索引)和稠密向量搜索,然后合并。稳健的合并是倒数排名融合(RRF)——无需分数校准:
def rrf_fusion(dense_hits: list[str], bm25_hits: list[str], k: int = 60) -> list[str]:
"""Merge two ranked lists of doc IDs. RRF needs no score normalization."""
scores: dict[str, float] = {}
for rank, doc_id in enumerate(dense_hits + bm25_hits):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank + 1)
return [doc_id for doc_id, _ in sorted(scores.items(), key=lambda x: -x[1])]
为什么不直接求和标准化分数?因为 BM25 和余弦相似度处于不同的尺度和分布——校准它们是一个完整的项目。RRF 避开了这个问题:由任一系统排名第 1 的文档获得巨大提升,而由两个都排名第 20 的文档仍然击败了由其中之一排名第 50 的文档。合并 top_k=50,然后将融合列表交给重排器。
对于完全匹配为主的语料库(代码、配置、错误文档),你可以进一步推进,提升关键词命中:如果 BM25 命中包含原始查询 token(如 ERR_4297 精确出现),它无论稠密分数如何都赢。经验法则:精确 token 命中几乎总是相关的;嵌入相似命中相关的概率约 60%。
这是管道中杠杆作用最大的单项升级,也是最常被跳过的。用于检索的嵌入模型是双编码器:它独立嵌入查询和文档,所以快速但粗糙。交叉编码器同时看查询和文档,输出相关性分数——精确度大幅提高,但太慢,无法在整个语料库上运行。这就是为什么它重排:用混合搜索检索 50 个,仅用交叉编码器对这 50 个评分,保留前 5 个。
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3") # strong default, ~100ms per pair
def rerank(query: str, docs: list[dict], top_n: int = 5) -> list[dict]:
pairs = [(query, d["text"][:8000]) for d in docs] # cross-encoders cap context
scores = reranker.predict(pairs)
return [d for _, d in sorted(zip(scores, docs), key=lambda x: -x[0])][:top_n]
重排 50 个,不是 5 个。整个要点是正确的段落可能被嵌入排名第 10,但被交叉编码器排名第 1。检索 3 个再重排 3 个纯粹是表演。
缓存每个 (query, doc) 对的重排分数。查询重复出现,略有变化;对对 → 分数的小型 LRU 缓存节省了峰值时大部分重排成本。
交叉编码器有上下文限制(大多数约 8K token)。截断文档一侧;不要将整个 10K token 块送给重排器。
在 2026 年,CPU 上带 ONNX 的重排器在 50 项候选集上每查询成本约 2-5ms——比你已在做的嵌入调用便宜。没有理由跳过这个阶段。
用户不会提出检索形的问题。他们说"那个关于 Q3 审计截止日期的事情是什么"——而你要检索关于审计报告中合规截止日期的内容。两个廉价技术修复大多数这种问题:
查询扩展 / HyDE:让 LLM 生成假设性答案或 3-5 个改写的查询,嵌入这些,并用所有它们检索(然后 RRF 合并)。成本:一个小 LLM 调用。收益:在模糊查询上很大。
分解:多部分问题("比较计划 A 和 B 的定价和退款")被分成子查询,独立检索,然后合并。单个整个问题的嵌入检索一团糨糊。
def expand_query(query: str, llm) -> list[str]:
prompt = (
"Rewrite this user query as 4 distinct search queries that would "
"each retrieve a different relevant passage. Return one per line.\n"
f"Query: {query}"
)
return [l.strip() for l in llm(prompt).splitlines() if l.strip()]
仅在有效益时这样做:如果查询包含精确标识符或短而具体,扩展增加噪声——直接发送到混合搜索。一个廉价分类器或一条规则(查询长度 < 5 token 或包含大写字母数字 → 跳过扩展)就行。
大多数 RAG 流量是重复的:相同问题被不同用户提出,或相同问题刷新后重新提出。两个缓存层,都便宜:
精确匹配缓存标准化查询 → 缓存答案加 TTL。获得明显的赢利。
语义缓存:嵌入传入查询并通过阈值上的相似度查找顶部 1 缓存查询(如使用好的嵌入的 0.92)。如果匹配,提供缓存答案。这是重排器式精确度重要的地方——0.90 相似度的错误匹配提供有信心的错误答案。在真实查询日志上测试你的阈值;怀疑时,偏向高阈值。
def semantic_cache_lookup(query_emb, cache, threshold=0.92):
best_id, best_sim = None, 0.0
for cid, cent in cache.entries():
sim = cosine(query_emb, cent)
if sim > best_sim:
best_id, best_sim = cid, sim
return cache.get(best_id) if best_sim >= threshold else None
缓存失效是难部分:当你的语料库更新时,引用改变文档的缓存答案变过时。用每个缓存答案存储源文档 ID,在文档更新时失效。如果你无法可靠地跟踪文档版本,保持语义缓存 TTL 短(小时,不是天)。
你无法调优你不衡量的东西。RAG 需要两个评估层,第一个是每个人都跳过的:
检索评估——正确的段落是否进入上下文?在黄金查询集上的指标(查询 → 相关文档 ID):
Recall@k:相关文档中,有多少在前 k 个?(从这里开始。Recall@5 是告诉你重排是否工作的数字。)
MRR(平均倒数排名):第一个相关文档排名靠前吗?惩罚"正确文档但排名第 8"。
nDCG@k:位置加权;在多个相关文档存在时有用。
def recall_at_k(retrieved_ids: list[str], relevant_ids: set[str], k: int = 5) -> float:
hits = set(retrieved_ids[:k]) & relevant_ids
return len(hits) / len(relevant_ids)
从真实生产查询构建黄金集,手工标注哪些文档包含答案——合成查询看起来很棒,抓不到任何东西。目标:在你触碰生成质量前 Recall@5 ≥ 0.85。你会发现分块和重排比任何嵌入模型交换更改变这个数字。
生成评估——答案是否基于检索上下文?每个答案两个检查,都用 LLM judge 便宜:
基础性 / 忠实性:答案中的每个事实声明都由提供的上下文支持(不是模型的先验)。评分 0-1。
引用正确性:如果答案引用 [3],文档 3 真的支持它附加到的声明吗?这捕捉模型引用了它实际未使用的文档的狡猾情况。
在每个语料库变化上在 CI 中放入检索评估——分块配置改变是一个你只会通过 Recall@5 漂移注意到的回归。
def rag_answer(query: str, index, reranker, llm, cache) -> str:
# 1. cache
if (hit := semantic_cache_lookup(embed(query), cache)):
return hit["answer"], hit["sources"], cached=True
# 2. query rewriting (skip for exact-identifier queries)
queries = [query] if looks_specific(query) else expand_query(query, llm)
# 3. hybrid retrieval + RRF merge
fused: list[str] = []
for q in queries:
dense = index.vector_search(embed(q), top_k=50)
bm25 = index.keyword_search(q, top_k=50)
fused = rrf_fusion(fused + dense, bm25) # merge iteratively
# 4. rerank 50 -> 5
top = rerank(query, [index.get_doc(i) for i in fused[:50]], top_n=5)
# 5. grounded generation with citations
context = "\n\n".join(f"[{i+1}] {d['text']}" for i, d in enumerate(top))
answer = llm(f"Answer using ONLY the sources. Cite as [n].\n\n{context}\n\nQ: {query}")
cache.store(query, answer, sources=[d["id"] for d in top])
return answer, [d["id"] for d in top], cached=False
这故意与存储无关——相同的形态适用于 pgvector、Qdrant、Weaviate 或 LanceDB。管道是架构;向量存储是一个细节,你可以在一天内替换。
不要为了"安全"就把整个文档放入 prompt。上下文膨胀降低答案质量并让你成本延迟——检索 5 个,重排,引用。
嵌入漂移在模型升级后:重新嵌入语料库或在过渡期间服务两个索引。在一个索引中混合嵌入模型会悄无声息地破坏检索。
混合搜索前的元数据过滤,不是后。在查询中按租户 / 日期 / 产品过滤,所以稠密和关键词路径都尊重它。
答案引用不匹配是生成 bug,不是检索 bug。如果引用错了但检索好,修复 prompt + 基础性评估,不是索引。
延迟预算:嵌入(10-30ms)+ BM25(<5ms)+ 重排(2-5ms)+ 生成(占主导)。如果你超过预算,更硬地缓存和重排 30 个而非 50 个——不要放弃重排器。
成本:语义缓存通常在第一天就为自己支付成本,适用于聊天密集的工作负载(内部工具的 60-70% 缓存命中率很常见)。
如果你从本文只吸取一件事:朴素分块和嵌入是 RAG 的"Hello World",不是生产系统。"在演示上工作"和"在生产中工作"之间的差距恰好是五个阶段——尊重结构的分块、带 RRF 的混合检索、交叉编码器重排、查询重写和语义缓存——由 CI 中的检索评估粘合。
从混合 + 重排器 + Recall@5 评估开始。仅这个组合就修复大多数生产检索失败,而且是一周末的工作。其余的是复合。
首次发布于 2026 AI 工程系列。欢迎在评论中反馈和更正。
如需进一步操作,你可以考虑屏蔽此人和 / 或举报滥用行为。