每个 RAG 教程都遵循同样的套路:加载一个 PDF,切分成 chunk,对 chunk 做 Embedding,存入向量数据库;拿到用户查询后,对查询做 Embedding,找到最近的 chunk,塞进 Prompt,得到答案。演示能跑通,答案也准确,所以工程师往往就此以为难的部分已经过去了。
但是系统一旦上线到生产环境情况就不同了。用户提出的问题会跨越多个文档,相关上下文常常被切在 chunk 边界的两侧。检索返回的 chunk 在语义上和查询相似,含义上却是错的——词对了,意思不对。模型会自信地根据检索到的上下文作答,哪怕这些上下文已经过时、相互矛盾,或者缺了本该出现在 chunk 结尾之后三句话的关键限定条件。这是最糟糕的一种失败模式:答案听起来没问题,却没有任何迹象表明检索本身出了问题。
所以搭建一个 RAG 演示不难,难的是让它在真实的文档语料库、真实用户提出的真实问题面前依然可靠地工作。一半教程和生产环境之间的差距来自三处:文档如何被分块、Embedding 如何处理语义上的细微差别、检索如何判断什么才算相关。每一处都藏着容易被忽视、部署之后代价高昂的失败模式。
本文讨论的就是这些失败模式,以及能够避免它们的工程决策。
分块(Chunking):多数 RAG 系统丢信息的地方
分块最常见的教程做法是固定大小、带重叠区间的递归字符分割:
fromlangchain.text_splitterimportRecursiveCharacterTextSplitter
splitter=RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200
)
chunks=splitter.split_documents(documents)
支撑一个演示足够了。但当文档结构本身承载着字符边界照顾不到的语义时,它在生产环境里就会出问题。
上下文被切断。 一份技术规格写道:"支持的最大值为 500。该限制适用于系统运行在高可用(High-Availability,HA)模式且主故障转移配置处于激活状态时。"按 1000 个字符切分,"支持的最大值为 500"可能落进一个 chunk,"该限制适用于……"落进下一个。用户问"HA 模式下的限制是多少?",检索到的是前一个 chunk——写着 500,却没有那个关键限定条件。答案确实存在于语料库中,只是分块把两者之间的关系丢了。
标题孤立。 一份文档写着"## 安全配置\n\n所有连接都要求使用 TLS。"按字符数切分,标题和正文第一段可能落在不同 chunk 里。检索到内容段落时看不到标题上下文,"要求使用 TLS"这条 Embedding 体现不出它属于"安全配置"这一节——而这恰恰是用户问安全配置时需要的信息。
表格与列表。 Markdown 或 HTML 里的表格、结构化列表,经常被固定大小的分割器从行中间或条目中间切断。列标题落进一个 chunk,数据落进另一个,两边单独看都没法理解。
生产环境里更合适的分块策略有几种。
文档结构感知型切分(document-structure-aware splitting)按语义边界——标题、段落、章节——切分,而不是按字符数。Markdown 按
##
或
###
边界切,HTML 按
<h2>
、
<p>
、
<section>
边界切,chunk 跟着文档自身的结构走。
fromlangchain.text_splitterimportMarkdownHeaderTextSplitter
headers_to_split_on= [
("#", "document_title"),
("##", "section"),
("###", "subsection"),
]
splitter=MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
chunks=splitter.split_text(markdown_document)
# Each chunk now includes header metadata: {"section": "Security Configuration"}
用父级上下文丰富 chunk 是另一种办法:每个 chunk 存的不只是自己的内容,还有指向它的标题路径。检索到某个 chunk 时,注入 Prompt 的上下文会带上章节标题和子章节标题,模型也就拿到了 Embedding 空间本身承载不了的结构信息。
层级式分块(父子结构)则是把文档切成用于检索的小 chunk,并保留包含它们的更大的"父级"chunk。检索命中小 chunk 时,注入 Prompt 的是它的父级 chunk。检索因此是精确的——小 chunk 更准确地对应具体概念;上下文又是丰富的——父级 chunk 提供周边信息。
# Small child chunk for retrieval
child_chunk="The maximum supported value is 500."
# Parent chunk injected into prompt when child is retrieved
parent_chunk="""
## High Availability Configuration
When operating in high-availability mode with primary failover enabled,
the following limits apply:
- Maximum supported value: 500
- Minimum heartbeat interval: 100ms
- Failover timeout: 30 seconds
"""
父子结构实现和存储都更复杂,但对需要周边上下文的问题——多数真实问题都是这样——回答质量会好上一大截。
Embedding:语义相似度不是你真正要的东西
Embedding 把文本映射成向量,语义相近的文本在向量空间里彼此靠近;检索时找的是向量上离查询最近的 chunk。语义相似度和相关性能对上号的时候,这套办法很好用;对不上号的时候,它就失灵。
反义词问题。 "该系统不支持 IPv6"和"该系统支持 IPv6"的 Embedding 很接近,因为两句话都在谈 IPv6 支持,否定词在向量空间里只是一个小扰动。用户问"这个支持 IPv6 吗?",两个 chunk 可能都被检索到,也可能取到错的那个。模型接下来要么得去调和相互矛盾的上下文,要么更糟——直接从错误的 chunk 里作答那么结果肯定就是错的。
术语不匹配。 用户问的是"login",文档里用的是"authentication"和"sign-in"。这类术语的 Embedding 通常够接近,检索大多没问题,但用户用了文档里表达方式不同的领域术语时,边缘情况就会让检索变差。
特异性问题。 "如何配置连接池(connection pool)?"和"连接池的默认超时时间是多少?"返回的往往是同一批 chunk,因为具体问题的 Embedding 和大主题的 Embedding
靠得太近。超时值如果只出现在几个连接池相关 chunk 里的某一段,最相关的那个未必排在最前面。
几种应对 Embedding 局限性的办法值得一提。
混合检索——稠密(dense)加稀疏(sparse)——是最常见的一种。稠密检索(向量相似度)抓的是语义,稀疏检索(BM25 或 TF-IDF,关键词匹配)抓的是精确的词语匹配。两者结合,有时称为混合搜索(hybrid search),能同时覆盖语义场景和精确匹配场景。查询具体版本号、具体配置键名或具体错误代码时,关键词匹配比语义相似度管用得多。
fromqdrant_clientimportQdrantClient
fromqdrant_client.modelsimportSparseVector, NamedSparseVector
# Many vector databases now support hybrid search natively
# This example uses Qdrant's sparse + dense hybrid search
results=client.query_points(
collection_name="docs",
prefetch=[
models.Prefetch(
query=sparse_query_vector, # BM25 sparse vector
using="sparse",
limit=20,
),
models.Prefetch(
query=dense_query_vector, # Embedding dense vector
using="dense",
limit=20,
),
],
query=models.FusionQuery(fusion=models.Fusion.RRF), # Reciprocal rank fusion
limit=5,
)
查询扩展(query expansion)是另一条路:对用户查询做 Embedding 之前,先用同义词或相关词把它扩展开。比如"authentication"扩展成包含"login、sign-in、auth、identity verification",扩展后的查询落在语义空间里更丰富的区域。这一步可以用一次小型 LLM 调用生成查询变体,也可以靠一份针对领域术语的静态同义词映射表。
Embedding 模型的选择也值得单独考虑。不少团队默认用 OpenAI 的
text-embedding-ada-002
或
text-embedding-3-small
,但对医疗、法律、技术这类领域特定语料,领域专用或经过微调(fine-tuning)的 Embedding 模型往往明显优于通用模型。如果分块已经做得不错,检索质量却还是不理想,花时间在有代表性的查询样本上试试别的 Embedding 模型是划算的。
检索:规模化之后才会出现的失败模式
分块和 Embedding 决定的是相关内容能不能被找到,检索决定的是对某个具体查询,它是不是真的被找到了。有几种失败模式只有文档语料库变大、查询分布变多样之后才会显现。
Top-K 问题。 多数 RAG 实现按相似度检索 Top-K 个 chunk,K 个全部注入 Prompt。K 太小,问题需要多个 chunk 里的支撑事实时就会漏掉信息;K 太大,Prompt 上下文窗口会被边际相关的 chunk 填满,稀释掉真正有用的信号——对长文档而言,还可能把最相关的 chunk 挤出模型有效注意力的范围。
实际做法是先检索更多候选(Top-20 或 Top-30),再做重排序(re-ranking)。重排序用的是交叉编码器(cross-encoder)模型,它把(查询,chunk)当作联合输入直接给出相关性得分,不像 Embedding 相似度那样分别独立比较。交叉编码器算得慢——没法像 Embedding 那样预先算好——但区分相关和"看着相关"的能力强得多。组合下来就是:先用快速的近似最近邻(ANN)检索拿到候选集,再用交叉编码器重排序选出最终的 Top-K。
fromsentence_transformersimportCrossEncoder
reranker=CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
# Retrieve broad candidate set
candidates=vector_store.similarity_search(query, k=20)
# Re-rank with cross-encoder
pairs= [(query, chunk.page_content) forchunkincandidates]
scores=reranker.predict(pairs)
# Sort by re-ranking score, take top 5
reranked=sorted(zip(scores, candidates), reverse=True)[:5]
final_chunks= [chunkfor_, chunkinreranked]
时效性问题。 六个月前建好的向量库,里面的文档这期间可能已经改过了。Embedding 不知道文档过时了,检索照样以高置信度返回它,模型就照着过时信息作答。
缓解办法要靠元数据过滤:每个 chunk 存一份文档最后修改日期作为元数据,检索在相似度搜索前后按日期范围过滤。持续维护的文档,超过设定阈值的旧 chunk 直接排除或降权。
fromdatetimeimportdatetime, timedelta
# Filter to chunks updated in the last 90 days
cutoff=datetime.now() -timedelta(days=90)
results=vector_store.similarity_search_with_filter(
query=query,
filter={"last_modified": {"$gte": cutoff.isoformat()}},
k=10
)
虚假自信问题。 模型拿到检索的 chunk 就据此作答,哪怕这些 chunk 跟问题根本不相关——它不知道检索结果是差的,只是照着拿到的东西生成答案。这是对用户信任伤害最大的一种失败模式:答案听着权威,实际是错的。
缓解办法是在生成之前加一道相关性检查。可以对检索到的 chunk 与查询的相关性打分,最高分低于阈值就抑制生成;也可以在系统 Prompt 里明确写上"信息不足就说不知道"的指令,再评估模型是否会恰当地用上它。
SYSTEM_PROMPT = """
You are a documentation assistant. Answer questions using only the provided context.
If the context does not contain enough information to answer the question confidently,
respond with: "I don't have sufficient information in the available documentation to
answer this question reliably."
Do not speculate or answer from general knowledge when documentation context is insufficient.
"""

评估(Evaluation):多数团队会跳过的一步
没有评估的 RAG 系统,质量是个未知数。它可能对某些查询答得好,对另一些答得差,却没有系统化的办法去区分——直到用户报告了一个错误答案。
RAG 的评估分三块。
检索评估针对一组已知正确来源文档的测试查询,衡量正确文档出现在检索 Top-K 中的频率。Recall@5(正确 chunk 有没有出现在前 5 个结果里)和平均倒数排名(Mean Reciprocal Rank,正确 chunk 排名多靠前)是常用指标,建议在部署前用 100 到 200 条有代表性的查询跑一遍。
答案忠实度(answer faithfulness)看的是生成的答案有没有准确反映检索到的上下文,有没有加进 chunk 里不存在的说法。这个可以用 LLM-as-judge 的方式评估:把(查询、检索到的 chunk、答案)这个三元组交给一个模型,让它判断答案有没有得到上下文的支持,并打分。
FAITHFULNESS_PROMPT="""
Given the following context and answer, rate whether the answer is fully
supported by the context on a scale of 1-5.
Context: {context}
Answer: {answer}
Score (1=not supported, 5=fully supported):
"""
# Automate across your evaluation set
scores= []
forquery, context, answerineval_set:
score=judge_model.complete(
FAITHFULNESS_PROMPT.format(context=context, answer=answer)
)
scores.append(score)
print(f"Average faithfulness: {sum(scores)/len(scores):.2f}")
端到端正确性(end-to-end correctness)问的是对已知正确答案的查询,系统给出的答案对不对。这一项最难自动化,却也最有意义——哪怕只有 50 条人工核实过答案的小型评估集,只要在每次改动分块、Embedding 或检索配置前后都跑一遍,也能抓到回归问题。
把评估集当回归测试套件用。Pipeline 的任何改动——换一个 Embedding 模型、调整 chunk 大小、改系统 Prompt——都应该在评估分数上看出可衡量的差别。某项改动让平均忠实度上去了,检索召回率却下来了,或者反过来,这种取舍需要有意识地做决定,不是悄悄部署就完事。
Pipeline 就是产品
教程版本的 RAG 是四个步骤、没有错误处理的 Pipeline。生产版本是六个步骤的 Pipeline,每一步都有失败检测,全程带着元数据增强,每次部署前都跑一遍评估。
失败模式其实都能预见:忽视文档结构的分块在边界处丢上下文;只靠 Embedding 的检索漏掉精确匹配,也处理不好否定语义;没有重排序的检索返回一个被稀释的 Top-K,把最相关的 chunk 埋在里面;没有相关性把关的生成会拿着质量差的上下文给出自信满满的错误答案。
这些问题没有一个是无解的,每一个都对应着一项有明确取舍的工程决策:结构感知型分块要求理解文档格式;混合搜索要求维护稠密和稀疏两套索引;重排序会给检索步骤加延迟;评估要求搭建并维护一份测试数据集。
能把 RAG 系统真正做到生产可用的团队,是在部署前就把这些决策想清楚了,而不是等用户报告错误答案之后才去补。大部分教程跳过这些难点是因为演示跑通用不上它们;但要让系统真正靠得住,它们缺一不可。
速查表

by Nitesh Thakur