系列文章第4篇,深入讲解如何对检索结果进行上下文压缩、如何构建让模型保持 grounded 的prompt,以及全流程评估方法。
本系列的前三部分探讨了生产环境 RAG 系统为何会失败,以及数据基础的质量如何直接影响后续所有环节。我们审视了文档摄入、解析、分块和元数据设计——这些层负责将原始信息转化为检索系统真正可用的形态。
随后我们深入到检索本身。我们看到了为何仅靠向量搜索往往不够、语义搜索与词义搜索如何互补,以及重排序如何将大量可能的匹配结果转化为少量高度相关的文档。
但即使检索再完美,如果 LLM 无法正确利用检索结果,也毫无意义。
这正是本部分要讨论的内容。
在第四部分中,我们从检索转向生成。我们将探讨系统在找到正确文本块之后发生了什么:如何在压缩上下文的同时不丢失关键信息,如何构建能够保持模型 grounded 的提示词,以及如何评估整个流程是否真正有效。
这些不是可选项的优化。它们是将检索转化为可信答案的必经之路。
生产级 RAG 架构系列
✅ Why Most RAG Systems Fail in Production: The Hidden Architecture Problems Behind AI Search
✅ Building a Production RAG Pipeline: Document Processing, Chunking, and Metadata Design
✅ Beyond Vector Search: Building Better RAG Retrieval with Hybrid Search and Reranking
✅ Scaling RAG Systems: Production Architecture, Performance, and Cost Optimization(本文)
✅ Evaluating Production RAG Systems: Metrics, Monitoring, and Common Failure Patterns
读完本部分后,你将理解如何将检索到的上下文转化为可靠的答案,以及如何衡量你的系统是否在随时间不断改进。
检索可以给你正确的片段。但这不意味着 LLM 应该看到所有检索到的东西。
"这个文本块是相关的"和"这个文本块应该放入提示词"之间是有区别的。一个文本块可能相关,但仍然太长、太嘈杂,或者充满了不相关的句子。如果你把所有检索到的东西都喂给模型,你要花更多钱、等更久,而且往往得到更差的答案。
这就是上下文压缩存在的原因。
一个典型的 RAG 流程可能会检索 10-20 个文本块。每个文本块可能有 100-300 个词。这轻松就是数千个 Token。
但答案往往只依赖几句话。
这些多余的内容可能是:
如果模型看到所有这些,它必须做额外的工作。它必须在你给的上下文中找出什么是重要的。这正是你的检索系统应该已经完成的工作。
这不仅仅是成本的问题,而是信号与噪音比的问题。
压缩改善三个方面:
最后一点是最重要的。当上下文更清晰时,模型捏造的空间更小。它需要调和的矛盾信息更少。它抓住错误句子的机会也更少。
假设有以下检索到的文本块:
Chunk 1:
"Customers can upgrade from Professional to Enterprise. Active invoices must be closed before upgrading. Contact billing if invoices remain open. For more information, see the billing policy."
Chunk 2:
"Billing support is available Monday to Friday. For payment issues, contact billing@example.com. Note that invoice processing may take up to 48 hours."
Chunk 3:
"Downgrading is allowed only if no active trials exist. See the cancellation policy for details. Customers on annual plans have different terms."
问题:"Enterprise 客户能否在保持未结发票的情况下直接从 Professional 方案升级?"
实际上只有一句话重要:
"Active invoices must be closed before upgrading."
其余的是上下文,但不是证据。压缩应该保留这句话,丢弃其余部分。
没有压缩的话,模型会看到所有三个文本块。它必须弄清楚哪部分是相关的。这是额外的认知负担。这就是幻觉开始产生的地方。
上下文压缩不是为了把文本变小而变小。它是保留证据、丢弃噪音。
有三种主要策略:
大多数生产系统先做过滤,再做提取,只在 Token 预算极其紧张时才使用摘要。
过滤是最便宜的压缩方式。你将每个文本块针对查询进行评分,丢弃低于阈值的所有内容。
这通常使用 cross-encoder 或轻量级重排序器来完成。原理很简单:如果文本块不够相关,就完全不发送给 LLM。
from sentence_transformers import CrossEncoder
compressor = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
def filter_chunks(query, chunks, threshold=0.5):
pairs = [[query, chunk["text"]] for chunk in chunks]
scores = compressor.predict(pairs)
filtered = [(chunk, score) for chunk, score in zip(chunks, scores) if score > threshold]
filtered.sort(key=lambda x: x[1], reverse=True)
return [chunk for chunk, score in filtered]
这是第一道防线。它移除整个无用的文本块。
提取更精确。不是丢弃整个文本块,而是只保留每个文本块中最相关的句子。
当一个文本块同时包含相关和不相关信息时,这很有用。你不想丢失相关部分,但也不想发送噪音。
一种简单的方法是将文本块拆分成句子,对每个句子评分,保留排名靠前的句子。
def extract_sentences(query, chunk, top_n=3):
sentences = chunk["text"].split(". ")
pairs = [[query, sentence] for sentence in sentences]
scores = compressor.predict(pairs)
ranked = sorted(zip(sentences, scores), key=lambda x: x[1], reverse=True)
top_sentences = [sentence for sentence, score in ranked[:top_n]]
return ". ".join(top_sentences)
这保留了证据,丢弃了文本块的其余部分。
摘要是最昂贵的选择。你让 LLM 将上下文重写为更短的形式。
当你有大量文本块、Token 预算紧张,或者上下文重复性高时,这很有用。
但它有代价。摘要可能会丢失细节。它可能引入错误。它可能改变原意。
这就是为什么大多数生产系统只在必要时才使用摘要。
def summarize_context(query, chunks, llm):
context = "\n\n".join([chunk["text"] for chunk in chunks])
prompt = f"""
Summarize the following context in relation to this query: "{query}"
Context:
{context}
Keep only the information that is directly relevant to answering the query.
Remove any redundant or unrelated information.
Summary:
"""
return llm.generate(prompt)
这很强大,但代价高昂。谨慎使用。
过滤应该始终使用。它既便宜又有效。如果一个文本块不相关,就不要发送它。
当块内容较长或包含混合内容时,应使用提取。它能在保留相关部分的同时不丢失结构。
当令牌预算紧张或存在大量相似块时,应使用摘要。它的成本较高,但可以节省大量令牌。
隐藏的问题:过度压缩
压缩可能走得太远。
如果压缩过于激进,可能会:
丢失重要细节,
丢失重要细节,
删除模型所需的上下文,
删除模型所需的上下文,
或破坏信息的结构。
或破坏信息的结构。
例如,如果你只从一个包含多个条件策略的块中提取一句话,模型可能会错过全貌。
这就是为什么压缩应该调优,而不是最大化。
监控压缩质量
你应该跟踪压缩如何影响指标。
忠实度提高了吗?
忠实度提高了吗?
答案相关性提高了吗?
答案相关性提高了吗?
延迟降低了吗?
延迟降低了吗?
如果压缩改善了成本和延迟但损害了质量,说明压缩过度了。
示例:完整压缩管道
def compress_context(query, chunks, llm=None, token_budget=2000):
# 步骤 1:过滤块
filtered = filter_chunks(query, chunks, threshold=0.4)
# 步骤 2:从每个块中提取句子
extracted = []
for chunk in filtered:
extracted_text = extract_sentences(query, chunk, top_n=3)
extracted.append({"text": extracted_text})
# 步骤 3:检查令牌预算
total_tokens = sum(len(chunk["text"].split()) * 1.3 for chunk in extracted)
if total_tokens > token_budget and llm:
# 步骤 4:超出预算时进行摘要
context = summarize_context(query, extracted, llm)
return [context]
return extracted
这是一个结合了三种策略的简单管道。
压缩并非总是正确的做法。
在以下情况下应该谨慎:
块中包含表格,
块中包含表格,
块已经很短了,
块已经很短了,
或上下文已经很紧凑了。
或上下文已经很紧凑了。
在这些情况下,激进压缩可能删除重要结构或细节。
上下文压缩不是可选的优化。它是确保 LLM 看到正确证据的方式,而不仅仅是一堆文本。
如果说检索是网,那么压缩就是那只去掉不需要的鱼的手。
目标不是让上下文尽可能小。目标是让它尽可能有用。
第十一章 — 提示词构建
好的提示词救不了糟糕的检索。差的提示词可以毁掉好的检索。
这就是整章的内容。
提示词是一切的汇聚点:查询、检索到的上下文、指令、输出格式和护栏。如果其中任何一个环节薄弱,答案就会薄弱。但如果检索本身已经出问题,即使最好的提示词也只会让错误的答案听起来更自信。
提示词的作用
提示词有三个职责:
让模型扎根——明确答案必须来自提供的上下文。
让模型扎根——明确答案必须来自提供的上下文。
构建答案结构——定义输出应该是什么样子。
构建答案结构——定义输出应该是什么样子。
设置护栏——告诉模型当上下文不足时该怎么做。
设置护栏——告诉模型当上下文不足时该怎么做。
听起来很简单,但大多数生产环境提示词至少在其中一个方面失败了。
为什么扎根很重要
最常见的失败模式是模型忽略上下文并用自己的知识回答。这就是"仅使用提供的上下文回答"这条指令不是装饰性的。它是 RAG 忠实度的核心。
没有这条指令,模型可能会:
混合不同文档中的策略,
混合不同文档中的策略,
或自信地陈述上下文中没有的内容。
或自信地陈述上下文中没有的内容。
这就是为什么扎根是首要优先级。
一个基础的 RAG 提示词结构
You are a helpful assistant that answers questions based ONLY on the provided context.
Context:
{retrieved_chunks}
Question:
{query}
Instructions:
- Answer using only the information in the context.
- If the answer cannot be found, say "I don't have enough information."
- Cite the source document and section when possible.
- Keep the answer concise and direct.
Answer:
这是最小可行形态。它告诉模型该做什么、不该做什么,以及如何处理不确定性。
一个好的提示词的组成
生产环境提示词通常包含以下组件:
系统角色——定义助手的行为。
系统角色——定义助手的行为。
上下文——检索到的块。
上下文——检索到的块。
问题——用户查询。
问题——用户查询。
指令——如何回答。
指令——如何回答。
输出格式——答案应该是什么样子。
输出格式——答案应该是什么样子。
护栏——当上下文不足时该怎么做。
护栏——当上下文不足时该怎么做。
每个组件都很重要。
系统角色设定基调。它告诉模型它是什么样的助手。
You are a helpful assistant that answers questions based ONLY on the provided context.
You do not use outside knowledge.
You do not invent information.
If the answer is not in the context, you say so.
这不是文字调味。它是塑造整个生成过程的约束。
上下文是检索到的块,通常经过压缩后。
你如何格式化上下文很重要。
一个常见模式是给每个块编号并包含元数据:
Context:
[1] Document: Pricing Policy, Section: Upgrading Plans
Customers can upgrade from Professional to Enterprise. Active invoices must be closed before upgrading.
[2] Document: Billing Policy, Section: Payment Terms
Invoices must be paid within 30 days. Failure to pay may result in service suspension.
[3] Document: Support Policy, Section: Contact
Billing support is available Monday to Friday. Contact billing@example.com.
这种格式使模型更容易引用来源,也方便后续调试。
问题应该清晰且与上下文分离。
Question:
Can Enterprise customers upgrade directly from the Professional plan while keeping active invoices?
不要把问题与上下文混在一起。保持分离。
指令告诉模型如何回答。
并涵盖边缘情况。
并涵盖边缘情况。
Instructions:
- Answer using only the information in the context.
- If the answer cannot be found, say "I don't have enough information to answer this question from the provided documents."
- Cite the source document and section when possible.
- Keep the answer concise and direct.
- Do not repeat the context verbatim.
注意缺失信息的明确指令。这很关键。
输出格式取决于你的用例。
Answer in 2-3 sentences.
对于结构化 API:
Answer in JSON format:
{
"answer": "...",
"citations": [
{"document": "...", "section": "..."}
]
}
对于技术助手:
Answer in bullet points.
Include code examples when relevant.
格式应该匹配产品,而非模型。
护栏是安全网。
它们告诉模型在以下情况下该怎么做:
上下文不足,
上下文不足,
问题模糊,
问题模糊,
或答案需要外部知识。
或答案需要外部知识。
Guardrails:
- If the context does not contain the answer, say "I don't have enough information."
- If the question is ambiguous, ask for clarification.
- Do not provide legal, medical, or financial advice.
- Do not speculate.
这些不是可选的。它们是区分知道自己有局限的系统和对错误答案自信满满的系统的关键。
def build_prompt(query, chunks):
# 格式化带元数据的上下文
context_parts = []
for i, chunk in enumerate(chunks, 1):
metadata = chunk.get("metadata", {})
doc = metadata.get("title", "Unknown")
section = metadata.get("section", "Unknown")
context_parts.append(f"[{i}] Document: {doc}, Section: {section}\n{chunk['text']}")
context = "\n\n".join(context_parts)
prompt = f"""
You are a helpful assistant that answers questions based ONLY on the provided context.
You do not use outside knowledge.
You do not invent information.
If the answer is not in the context, you say so.
Context:
{context}
Question:
{query}
Instructions:
- Answer using only the information in the context.
- If the answer cannot be found, say "I don't have enough information."
- Cite the source document and section when possible.
- Keep the answer concise and direct.
Answer:
"""
return prompt
这是基本形态。生产环境的提示词通常会添加更多护栏,但核心思想是相同的。
常见提示词错误
错误一:缺少基础指令
Bad:
Context: {context}
Question: {query}
Answer:
模型没有收到"保持在上下文中回答"的指令,它会用自己的知识来回答。
错误二:没有处理信息缺失的情况
Bad:
- Answer using only the information in the context.
如果上下文中没有答案怎么办?模型会去猜测。
错误三:没有输出格式
Bad:
- Answer the question.
模型不知道答案应该多长、应该用什么格式。
错误四:上下文过多
如果你一次性发送 10 个块而不压缩,模型就不得不在噪声中寻找信号。这就是幻觉开始出现的时候。
提示词应该像代码一样被测试。
构建一个小数据集,包含查询和预期答案。用这些数据运行你的提示词。检查:
模型是否保持 grounding? 模型是否保持 grounding?
它是否能处理信息缺失? 它是否能处理信息缺失?
它是否遵循输出格式? 它是否遵循输出格式?
它是否正确引用来源? 它是否正确引用来源?
这不是可选项,这是你发现提示词退化的方式。
提示词不是用来修复检索的地方,它是确保你已有的检索结果被正确使用的地方。
如果上下文好,好的提示词让答案更好。如果上下文差,好的提示词只是让错误的答案更清晰。
提示词不是魔法。它是一组指令。就像任何指令一样,只有基础扎实它才有效。
第十二章 —— 评估
无法测量就无法改进。
这就是整章的内容。
大多数 RAG 系统在生产环境中失败,不是因为组件坏了,而是因为没有人知道它们何时在变差。评估层将主观的"感觉更好"转化为客观的"确实更好"。
没有评估,你就是在盲目飞行。你改了分块策略,换了嵌入模型,调整了提示词。然后呢?你看了几个查询说"好像更好"?这不是工程,这是猜测。
为什么评估很重要
评估给你:
回归检测, 回归检测,
以及比较变更的方法。 以及比较变更的方法。
它把 RAG 从一个黑盒变成一个你真正可以改进的系统。
大多数生产系统追踪一小部分指标:
Faithfulness(忠实度)—— 答案是否基于上下文? Faithfulness(忠实度)—— 答案是否基于上下文?
Answer Relevancy(答案相关性)—— 答案是否回答了问题? Answer Relevancy(答案相关性)—— 答案是否回答了问题?
Context Precision(上下文精确度)—— 检索到的上下文中有多少是相关的? Context Precision(上下文精确度)—— 检索到的上下文中有多少是相关的?
Context Recall(上下文召回率)—— 检索是否找到了正确的内容? Context Recall(上下文召回率)—— 检索是否找到了正确的内容?
Latency(延迟)—— 每一步需要多长时间? Latency(延迟)—— 每一步需要多长时间?
Cost(成本)—— 每个查询消耗多少 token? Cost(成本)—— 每个查询消耗多少 token?
这些指标覆盖了检索和生成两个方面。
Faithfulness 衡量答案中的声明是否被上下文支持。
如果模型说的内容不在上下文中,忠实度就会下降。这个指标用来捕捉幻觉。
Context:
"Active invoices must be closed before upgrading."
Answer:
"Yes, customers can upgrade while keeping active invoices."
Faithfulness: Low (the answer contradicts the context)
高忠实度意味着模型保持在上下文中。低忠实度意味着它在编造或与上下文矛盾。
Answer relevancy 衡量答案是否实际回答了问题。
模型可能忠实但不相关。例如,它可以忠实地重复上下文而不回答查询。相关性捕捉这个问题。
Question:
"Can Enterprise customers upgrade directly from the Professional plan while keeping active invoices?"
Answer:
"Customers can upgrade from Professional to Enterprise. Active invoices must be closed before upgrading."
Relevancy: High (the answer addresses the question)
Answer:
"Active invoices must be closed before upgrading. For more information, contact billing."
Relevancy: Medium (partial answer, no direct yes/no)
Answer:
"Billing support is available Monday to Friday."
Relevancy: Low (does not address the question)
Context precision 衡量检索到的上下文中有多少是相关的。
如果你检索了 10 个块但只有 1 个相关,精确度就很低。这意味着你在浪费 token 并让模型困惑。
Retrieved: 10 chunks
Relevant: 2 chunks
Precision: 0.2
高精确度意味着你的检索是集中的。低精确度意味着你发送了太多噪声。
Context recall 衡量正确的内容是否被检索到。
如果答案存在于你的语料库中但检索没有找到它,召回率就低。这是检索问题,不是生成问题。
Total relevant chunks in corpus: 5
Retrieved relevant chunks: 3
Recall: 0.6
高召回率意味着你的检索找到了正确的内容。低召回率意味着你错过了它。
Latency 和 cost 是运营指标,但它们很重要。
Latency——每一步需要多长时间?检索时间、重排时间、压缩时间、生成时间
Latency——每一步需要多长时间?
Cost——每个查询消耗多少 token?输入 token、输出 token、每个查询的总成本
Cost——每个查询消耗多少 token?
如果你的系统准确但每个查询需要 10 秒,用户不会使用它。如果它准确但每个查询花费 1 美元,你无法规模化。
使用 Ragas 的示例代码
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precisi