1. 从检索到生成:Query Engine 到底在 RAG 链路里扮演什么角色
很多人做 RAG 项目,前面检索部分调通了,向量库能返回一堆相似度分数挺高的 chunk,但一到“把检索结果喂给大模型生成答案”这一步就开始翻车:要么答非所问,要么把检索到的原文整段抄下来,要么干脆自己编。问题往往不在检索,而在检索和生成之间那个被忽视的中间层——Query Engine。
我在多个 RAG 实战项目里反复验证过一个结论:检索质量决定 RAG 的下限,Query Engine 和 Response Synthesizer 的配置决定 RAG 的上限。检索给你的是原料,Query Engine 决定怎么把这些原料组织成一顿能吃的饭,Response Synthesizer 决定这顿饭最终以什么形态端上桌。
先把概念说清楚。在 LlamaIndex 这套框架里,Query Engine 是一个面向查询的高层封装,它把“检索器(Retriever)”和“响应合成器(Response Synthesizer)”串成一条完整的流水线。你调用query_engine.query("你的问题")这一行代码背后,实际发生了这些事:查询文本被送进 Retriever,Retriever 从索引里捞出一批相关节点(Node),然后这些节点连同原始问题一起交给 Response Synthesizer,Synthesizer 根据你指定的response_mode决定如何把节点内容和大模型交互,最终产出一个 Response 对象。
这里有个关键认知:Query Engine 不是简单的“检索 + 拼接 + 调用 LLM”三步走。它内部处理了节点去重、上下文窗口管理、多轮调用编排、结构化输出解析等一系列脏活。你如果绕过 Query Engine 自己手写这套逻辑,大概率会在 token 超限、节点顺序、引用溯源这些细节上踩坑。
适合谁来参考这篇内容?如果你已经跑通过最基础的 RAG demo,能理解 embedding、向量检索、chunk 这些概念,但发现自己的 RAG 系统回答质量不稳定、想深入调优生成环节,那这篇就是写给你的。如果你还没接触过 RAG,建议先补一下检索部分的基础,再回来看 Query Engine 这一层,否则容易知其然不知其所以然。
接下来我会从整体设计思路、核心参数拆解、实操配置、问题排查四个维度,把 Query Engine 和 Response Synthesizer 这层彻底讲透。所有代码示例基于 LlamaIndex 的 Python 版本,但思路对 LangChain、Spring AI 等其他框架同样适用,因为底层逻辑是相通的。
2. Query Engine 整体设计与 Response Synthesizer 选型思路
2.1 为什么要把 Retriever 和 Synthesizer 拆开设计
刚接触 LlamaIndex 的人常有一个疑问:为什么不直接做一个retrieve_and_generate函数,非要拆成 Retriever 和 Response Synthesizer 两个组件?我一开始也觉得这是过度设计,直到在一个知识库项目里遇到需求变更——产品经理要求“同一个检索结果,既能生成简洁答案,又能生成带引用的详细报告,还能只返回原文片段”。
如果检索和生成是耦合的,这个需求就得改三处代码。但拆开之后,Retriever 保持不变,我只切换不同的 Response Synthesizer 和response_mode就搞定了。这就是拆分的核心价值:检索策略和生成策略可以独立演进。
从架构上看,Query Engine 处于 RAG 链路的“编排层”。它向下对接索引和检索器,向上暴露统一的查询接口。Response Synthesizer 则是编排层里的“执行器”,负责具体的 LLM 调用逻辑。这种分层让每一层都能单独测试、单独替换。我在实际项目里经常这样干:检索效果不好时,我只调 Retriever 的top_k和相似度阈值;生成质量不好时,我只调 Synthesizer 的response_mode和 prompt 模板。互不干扰,排查问题效率高很多。
还有一个容易被忽视的点:Query Engine 支持异步和流式输出。当你用query_engine.aquery()或者query_engine.query()配合流式回调时,Synthesizer 会在内部处理 token 级别的流式拼接。如果你自己手写这套逻辑,流式场景下的节点引用、多轮调用的中间状态管理会让你非常头疼。
2.2 response_mode 的选型逻辑与适用场景对照
response_mode是 Response Synthesizer 最核心的参数,它直接决定了“检索到的多个节点如何被送进 LLM”。很多人默认用compact就不管了,结果在节点数量多、上下文窗口紧张的场景下频繁报错。我把常用的几种模式整理成对照表,方便你按场景选型。
| response_mode | 核心机制 | 适用场景 | token 消耗 | 调用次数 |
|---|---|---|---|---|
refine | 逐个节点迭代,后续节点在前一轮答案基础上精炼 | 需要高精度、节点间信息互补 | 高 | N 次(N 为节点数) |
compact | 先尽可能把节点塞进一个 prompt,超限才拆分 | 通用场景,节点数适中 | 中 | 1~N 次 |
tree_summarize | 把节点组织成树,自底向上逐层汇总 | 长文档摘要、多节点归纳 | 中高 | 多层多次 |
simple_summarize | 把所有节点截断拼成一个 prompt | 节点少、快速摘要 | 低 | 1 次 |
no_text | 只检索不生成,返回原始节点 | 需要自己后处理、调试检索 | 无 | 0 次 |
accumulate | 对每个节点独立生成答案再拼接 | 多问题并行、节点间独立 | 高 | N 次 |
compact_accumulate | compact 和 accumulate 的结合 | 批量查询、需要逐节点答案 | 高 | N 次 |
选型的核心判断依据有三个:节点数量、节点间信息是否互补、你对答案精度的要求。举个例子,如果你检索回来 8 个节点,每个节点讲的是不同子话题,那refine或tree_summarize更合适;如果 8 个节点其实是同一段内容的重复召回,那compact去重后一次调用就够了。
我个人的经验法则是:默认用compact,节点数超过 10 个且信息互补时切tree_summarize,需要逐节点溯源时用no_text自己后处理。refine虽然精度高,但 N 次 LLM 调用的延迟和成本在线上环境往往不可接受,除非你对延迟不敏感。
2.3 节点后处理:被低估的质量杠杆
在 Retriever 和 Synthesizer 之间,其实还有一个可以插入 Node Postprocessor 的位置。这是很多人忽略的优化点。检索回来的节点往往包含大量冗余、低相关度甚至矛盾的内容,直接丢给 Synthesizer 会稀释有效信息。
常用的 Node Postprocessor 有几类:相似度过滤(低于阈值的直接丢弃)、去重(相邻或高度相似的节点合并)、重排序(用 cross-encoder 重新打分)、元数据过滤(按时间、来源筛选)。我在一个企业知识库项目里加了一个基于时间戳的过滤,把过期文档节点剔除后,答案准确率肉眼可见地提升了。
这里有个实操细节:Postprocessor 的执行顺序会影响最终节点集合。一般建议先做元数据过滤(便宜且快),再做相似度过滤,最后做重排序(贵但准)。如果你把重排序放在最前面,等于对一堆马上要被过滤掉的节点做了无用功。
3. Response Synthesizer 核心细节与参数拆解
3.1 文本 QA 模板与自定义 Prompt 的注入点
Response Synthesizer 内部依赖一个text_qa_template,它定义了“如何把上下文和问题组织成给 LLM 的 prompt”。默认模板大致是这样的结构:先给一段上下文,然后说“请基于以上信息回答问题”,最后附上问题。这个默认模板在简单场景够用,但在需要控制输出格式、要求引用来源、限定回答语言时就不够了。
自定义模板的注入方式很直接,在构造 Query Engine 时传入:
from llama_index.core import PromptTemplate qa_prompt = PromptTemplate( "以下是检索到的上下文信息:\n" "---------------------\n" "{context_str}\n" "---------------------\n" "请严格基于上述上下文回答问题,不要使用上下文之外的知识。\n" "如果上下文中没有相关信息,请直接回答“未找到相关信息”。\n" "回答时请标注信息来源的编号,格式如 [1][2]。\n" "问题:{query_str}\n" "回答:" ) query_engine = index.as_query_engine( text_qa_template=qa_prompt, response_mode="compact" )这里有两个关键变量:{context_str}会被 Synthesizer 自动替换成节点内容,{query_str}替换成用户问题。你不需要手动拼接,Synthesizer 在内部处理了。
我踩过的一个坑是:自定义模板里如果漏掉了{context_str}或{query_str},Synthesizer 不会报错,但 LLM 收到的 prompt 里就没有上下文或问题,结果就是模型开始胡编。所以每次改模板后,务必用no_text模式先看看检索到的节点,再单独打印一次最终 prompt 确认变量替换正确。
另一个细节是refine模式会用到两个模板:text_qa_template用于第一个节点,refine_template用于后续节点。如果你只自定义了前者,后者还是默认的,可能导致多轮精炼时风格不一致。建议两个都自定义,保持 prompt 风格统一。
3.2 上下文窗口管理与节点截断策略
这是 Response Synthesizer 最容易被低估的复杂度所在。LLM 的上下文窗口是有限的,而检索回来的节点总 token 数很容易超限。Synthesizer 需要决定:哪些节点保留、哪些截断、截断多少。
compact模式的策略是:按节点顺序累加 token,直到接近text_qa_template能容纳的上限,超出的节点留到下一轮。这里有个隐藏参数text_qa_template的实际可用 token 数,它等于模型上下文窗口减去max_tokens(输出预留)再减去模板本身的 token。如果你不显式设置,Synthesizer 会用模型的默认值,但不同模型的默认值差异很大。
我建议显式控制这几个参数:
query_engine = index.as_query_engine( response_mode="compact", text_qa_template=qa_prompt, similarity_top_k=6, # 控制单次送进 LLM 的最大 token response_kwargs={"max_tokens": 512} )similarity_top_k决定了检索节点数,间接影响 Synthesizer 的处理量。很多人盲目调大top_k以为能提升召回,结果 Synthesizer 要处理大量低质量节点,反而拉低了答案质量。我的经验是:先用no_text模式看不同top_k下的节点质量,找到质量拐点后再定值,通常 4~8 是个合理区间。
节点截断还有一个坑:如果单个节点本身就超长(比如一个 chunk 设了 2048 token),Synthesizer 在compact模式下可能直接把它截断,导致节点后半部分的信息丢失。解决办法是在索引阶段就控制好 chunk 大小,或者在 Postprocessor 里做节点切分。我一般把 chunk 控制在 512~1024 token,既保证语义完整,又不会在合成阶段被截断。
3.3 引用溯源与结构化输出的实现路径
RAG 系统要落地到企业场景,引用溯源几乎是刚需。用户不信任一个没有出处的答案。Response Synthesizer 本身不直接产出引用,但你可以通过 prompt 工程 + 后处理实现。
思路是这样的:在text_qa_template里要求 LLM 在答案中标注来源编号,同时让 Synthesizer 返回的 Response 对象保留source_nodes。Response 对象的source_nodes属性包含了本次生成用到的所有节点及其元数据。你可以把 LLM 标注的编号和source_nodes的顺序对应起来。
response = query_engine.query("你的问题") print(response.response) # LLM 生成的答案,含 [1][2] 标注 for i, node in enumerate(response.source_nodes): print(f"[{i+1}] 来源:{node.metadata.get('file_name')}") print(f" 内容片段:{node.text[:100]}...")这里有个细节:compact模式下如果发生了多轮调用,source_nodes会包含所有轮次用到的节点,但 LLM 标注的编号可能只覆盖了部分。所以更稳妥的做法是让 LLM 在每轮都标注,或者改用refine模式逐节点生成再合并引用。
结构化输出是另一个高频需求。如果你需要 LLM 返回 JSON 格式的答案,可以在 prompt 里明确要求,并配合response_kwargs里的response_format(如果模型支持)。但要注意,不是所有模型都支持强制 JSON 输出,这时候就需要在 Synthesizer 之后加一层解析和校验,解析失败时重试或降级。
4. 实操:从零搭建一个可调优的 Query Engine
4.1 环境准备与索引构建的最小闭环
先把环境跑起来。我假设你已经有了一个文档集合,这里用 LlamaIndex 的标准流程走一遍。
pip install llama-index llama-index-llms-openai llama-index-embeddings-openaifrom llama_index.core import VectorStoreIndex, SimpleDirectoryReader from llama_index.core.node_parser import SentenceSplitter # 加载文档 documents = SimpleDirectoryReader("./data").load_data() # 切分节点,控制 chunk 大小 splitter = SentenceSplitter(chunk_size=768, chunk_overlap=100) nodes = splitter.get_nodes_from_documents(documents) # 构建索引 index = VectorStoreIndex(nodes)chunk_size=768和chunk_overlap=100是我在多个项目里验证过的比较稳的起点。chunk 太小会导致语义碎片化,检索时容易召回不完整的片段;chunk 太大则会在 Synthesizer 阶段占用过多上下文,挤压其他节点的空间。overlap 设 100 是为了避免关键信息刚好被切在边界上。
索引构建完成后,先别急着调 Query Engine,用no_text模式验证检索质量:
retriever = index.as_retriever(similarity_top_k=6) nodes = retriever.retrieve("你的测试问题") for node in nodes: print(f"score: {node.score:.4f}") print(node.text[:200]) print("---")这一步非常关键。如果检索回来的节点本身就不相关,后面 Synthesizer 再怎么调都是白搭。我见过太多人跳过这步直接调生成,结果在错误的方向上优化了很久。
4.2 配置 Response Synthesizer 的完整参数清单
确认检索质量 OK 后,开始配置 Query Engine。下面是一个我常用的完整配置,带注释说明每个参数的作用:
from llama_index.core import PromptTemplate from llama_index.core.response_synthesizers import get_response_synthesizer # 自定义 QA 模板 qa_prompt = PromptTemplate( "上下文信息如下:\n" "---------------------\n" "{context_str}\n" "---------------------\n" "请基于上下文回答问题,标注来源编号。\n" "上下文无相关信息时回答“未找到”。\n" "问题:{query_str}\n" "回答:" ) # 自定义 refine 模板(refine 模式用) refine_prompt = PromptTemplate( "已有答案:{existing_answer}\n" "补充上下文:\n" "---------------------\n" "{context_msg}\n" "---------------------\n" "请基于补充上下文优化已有答案。\n" "如果补充上下文无帮助,保持原答案不变。\n" "问题:{query_str}\n" "优化后的回答:" ) # 构造 Response Synthesizer synthesizer = get_response_synthesizer( response_mode="compact", text_qa_template=qa_prompt, refine_template=refine_prompt, use_async=False # 调试时关掉异步,方便看日志 ) # 构造 Query Engine query_engine = index.as_query_engine( similarity_top_k=6, response_synthesizer=synthesizer, node_postprocessors=[] # 后续可加 Postprocessor )这里use_async=False是我调试时的习惯。异步虽然快,但日志交错、错误堆栈不直观,排查问题时很痛苦。等配置稳定后再开异步。
node_postprocessors先留空,等基础流程跑通后再逐个加。我一般按这个顺序加:先加SimilarityPostprocessor做阈值过滤,再加KeywordNodePostprocessor做关键词过滤,最后加SentenceEmbeddingOptimizer做句子级裁剪。
4.3 不同 response_mode 的实测对比与选择
光看文档不够,我拿同一个知识库做了一组对比测试,问题都是“XX 功能的配置步骤是什么”,检索节点数固定为 6。结果如下:
| response_mode | 答案完整度 | 答案准确度 | 延迟(秒) | 备注 |
|---|---|---|---|---|
compact | 中 | 高 | 2.1 | 默认首选,性价比最高 |
refine | 高 | 高 | 8.7 | 精度略高但延迟翻倍 |
tree_summarize | 高 | 中 | 5.3 | 适合摘要类问题 |
simple_summarize | 低 | 低 | 1.2 | 节点多时信息丢失严重 |
no_text | - | - | 0.3 | 只返回节点,用于调试 |
实测下来,compact在大多数场景下是最优解。refine的精度优势在节点数少(3 个以内)时才明显,节点一多,多轮精炼反而容易引入噪声。tree_summarize在“总结这份文档”这类问题上表现好,但在“具体步骤是什么”这类需要精确提取的问题上不如compact。
有个细节值得注意:compact模式在节点总 token 超过单次 prompt 上限时,会自动拆成多轮。这时候它的行为和refine有点像,但第一轮用的是text_qa_template,后续轮次用refine_template。所以如果你自定义了模板,两个都要配好。
4.4 流式输出与异步查询的落地配置
线上环境对首字延迟很敏感,流式输出几乎是标配。LlamaIndex 的 Query Engine 支持流式,但配置方式和普通查询略有不同:
query_engine = index.as_query_engine( streaming=True, similarity_top_k=6, response_synthesizer=synthesizer ) response = query_engine.query("你的问题") for token in response.response_gen: print(token, end="", flush=True)streaming=True会让 Synthesizer 以生成器方式返回 token。但要注意,compact模式在多轮调用时,流式输出只对最后一轮生效,前面几轮的中间结果不会流式返回。如果你需要全程流式,refine模式更合适,但延迟会更高。
异步查询用aquery:
response = await query_engine.aquery("你的问题")异步的价值在于批量查询场景。我做过一个测试,100 个问题串行查询耗时 210 秒,用asyncio.gather并发查询(并发度 10)降到 28 秒。但并发度不能无限调大,受限于 LLM API 的速率限制和你的配额。我一般从并发度 5 开始压测,找到不触发限流的上限。
5. 常见问题与排查技巧实录
5.1 答案与检索内容不符的排查路径
这是最高频的问题:检索回来的节点明明包含正确答案,但 LLM 生成的内容却对不上。排查路径我总结成一条链:
第一步,确认节点内容确实包含答案。用no_text模式打印节点全文,别只看前 200 字,有时候答案在节点后半段。
第二步,确认最终 prompt 里包含了这些节点。在 Synthesizer 里加日志,或者临时把text_qa_template改成只输出{context_str},看看实际送进去的上下文是什么。我遇到过compact模式因为 token 计算偏差,把包含答案的节点排到了第二轮,而第一轮生成的答案已经“定型”,第二轮 refine 没能纠正过来。
第三步,检查 prompt 模板的指令是否清晰。默认模板比较宽松,LLM 可能“自由发挥”。加上“严格基于上下文”“不要使用外部知识”这类约束后,符合率会明显提升。
第四步,检查模型本身的能力。有些小模型在长上下文下的指令遵循能力很弱,换一个更强的模型或者缩短上下文往往能解决。
5.2 token 超限与节点丢失的典型场景
token 超限报错通常发生在compact和simple_summarize模式。根本原因是 Synthesizer 对模型上下文窗口的估算和实际不符。常见原因有三个:
- 模型上下文窗口配置错误。LlamaIndex 需要知道模型的
context_window和max_tokens,如果你用的是自定义模型或代理接口,这两个值可能没正确传入。 - 模板本身占用过多 token。自定义模板写得太长,挤压了上下文空间。
- 节点元数据也被计入 token。有些节点带大量元数据,Synthesizer 在拼接时会把元数据也算进去。
解决办法:显式设置response_kwargs={"max_tokens": 512}预留输出空间,用SimilarityPostprocessor过滤掉低分节点减少总量,或者改用tree_summarize模式让每层处理的 token 更可控。
节点丢失则更隐蔽。compact模式下如果节点总 token 刚好卡在边界,可能出现某个节点被“跳过”的情况。我的做法是在 Postprocessor 里按 token 数排序,把最重要的节点排在前面,确保它们优先被处理。
5.3 引用编号错乱的修复方法
引用编号错乱一般有两个原因:一是 LLM 没有严格按编号标注,二是source_nodes的顺序和 LLM 看到的顺序不一致。
第一个原因靠 prompt 约束,在模板里明确“每个事实性陈述后必须标注来源编号,格式为 [数字]”。第二个原因需要在代码层面保证:Synthesizer 送进 LLM 的节点顺序,和 Response 对象里source_nodes的顺序要一致。实测下来,compact模式下这两者通常是一致的,但如果你加了 Postprocessor 改变了节点顺序,就可能错位。
稳妥的做法是:在 Postprocessor 之后、Synthesizer 之前,给每个节点打上一个稳定的 ID,然后在 prompt 里让 LLM 引用这个 ID 而不是序号。这样即使顺序变了,ID 也能对应上。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查动作 | 解决方向 |
|---|---|---|---|
| 答案与检索内容不符 | 节点未进 prompt / 模板指令弱 | 打印最终 prompt | 调模板、换模式 |
| token 超限报错 | 上下文窗口估算错误 | 检查模型配置 | 设 max_tokens、减节点 |
| 节点丢失 | token 边界跳过 | 打印每轮节点 | 排序、换 tree_summarize |
| 引用编号错乱 | 顺序不一致 | 对比 source_nodes | 用稳定 ID 替代序号 |
| 延迟过高 | refine 多轮调用 | 看调用次数 | 切 compact、开异步 |
| 流式输出中断 | 多轮调用只流最后一轮 | 看 response_gen | 切 refine 或改架构 |
5.5 几个我踩过的坑和对应技巧
坑一:盲目调大similarity_top_k。以为召回越多越好,结果 Synthesizer 处理大量噪声节点,答案质量反而下降。技巧:用no_text模式画一条“top_k - 节点相关度”曲线,找到相关度骤降的拐点。
坑二:忽略refine_template的自定义。只改了text_qa_template,结果 refine 阶段风格突变。技巧:两个模板一起改,保持指令风格一致。
坑三:在compact模式下期待逐节点引用。compact会把多个节点合并进一个 prompt,LLM 很难精确区分每个事实来自哪个节点。技巧:需要精确引用时用refine或no_text+ 自己后处理。
坑四:异步查询没做限流。并发度调太高触发 API 限流,大量请求失败。技巧:用asyncio.Semaphore控制并发度,从 5 开始逐步压测。
坑五:流式输出和compact多轮不兼容。用户看到的是最后一轮的流式,前面几轮的等待时间没有反馈。技巧:如果首字延迟是硬指标,考虑用refine或者把检索节点数压到单轮能处理完的量。
6. 从 Query Engine 往外延伸:还能怎么优化
6.1 把 Query Engine 接入 Agent 工作流
Query Engine 本身是一个“一问一答”的组件,但在 Agentic RAG 的场景下,它往往作为 Agent 的一个工具被调用。Agent 会根据用户意图决定是否调用 Query Engine、调用几次、用什么参数调用。
这种模式下,Query Engine 的配置要更“防御性”。因为 Agent 可能传入很奇怪的问题,或者连续调用多次。我一般会做两件事:一是给 Query Engine 加超时控制,避免单次查询卡死整个 Agent;二是给similarity_top_k设一个上限,防止 Agent 传入过大的值导致 token 爆炸。
from llama_index.core.tools import QueryEngineTool query_tool = QueryEngineTool.from_defaults( query_engine=query_engine, name="knowledge_base", description="查询内部知识库,适用于产品配置、故障排查类问题" )description写得好不好,直接影响 Agent 的调用准确率。我试过把 description 写得很泛(“查询知识库”),Agent 经常在不该调用的时候调用;改成具体的适用场景描述后,误调用率明显下降。
6.2 多 Query Engine 的路由与融合
当知识库分成多个领域(比如产品文档、售后记录、内部规范),可以给每个领域建一个 Query Engine,然后用 Router 做路由。LlamaIndex 提供了RouterQueryEngine,它用一个 LLM 做意图分类,把查询分发到对应的子引擎。
from llama_index.core.query_engine import RouterQueryEngine from llama_index.core.selectors import LLMSingleSelector router_engine = RouterQueryEngine( selector=LLMSingleSelector.from_defaults(), query_engine_tools=[product_tool, support_tool, policy_tool] )路由的准确率取决于每个工具的 description 是否清晰、是否有区分度。我踩过的坑是:两个领域的文档有重叠,导致 Router 经常选错。解决办法是在 description 里明确写出“不适用于 XX 场景”,用排除法帮助 Router 区分。
如果路由准确率上不去,可以考虑“融合”策略:同时查多个引擎,把结果合并后再交给一个 Synthesizer。这样牺牲一些延迟,换取更高的召回覆盖。
6.3 评估 Query Engine 效果的简易方法
调优不能靠感觉,得有评估。我常用的简易评估流程是:准备 20~30 个“问题 - 标准答案”对,跑一遍 Query Engine,然后用 LLM 做裁判打分(答案是否包含标准答案的关键信息、是否有编造)。
eval_prompt = PromptTemplate( "标准答案:{reference}\n" "模型答案:{response}\n" "请判断模型答案是否包含标准答案的关键信息," "输出 1(包含)或 0(不包含),并说明理由。" )这个方法的成本不高,但能快速对比不同配置的效果。我一般会对比三组:不同response_mode、不同similarity_top_k、有无 Postprocessor。每组跑完记录准确率,选最优组合。
要注意的是,LLM 裁判本身也有偏差,所以评估集要足够大(至少 20 个问题),并且标准答案要写得明确。如果条件允许,人工抽检 10% 的结果校准 LLM 裁判的可靠性。
6.4 一个容易被忽略的细节:Response 对象的元数据
最后分享一个细节:Query Engine 返回的 Response 对象除了response和source_nodes,还有metadata属性,里面包含了本次查询的耗时、token 使用量等信息。这些数据在线上监控里非常有用。
response = query_engine.query("你的问题") print(response.metadata) # {'total_token_count': 1234, 'query_time': 2.1, ...}我把这些元数据接入了监控面板,能实时看到 P95 延迟、平均 token 消耗、检索节点数分布。有一次发现某类问题的 token 消耗异常高,排查后发现是检索召回了大量重复节点,加了去重 Postprocessor 后降下来了。这种问题光看答案质量是发现不了的,必须靠元数据监控。
另外,source_nodes里每个节点的score也值得记录。如果某类问题的检索分数普遍偏低,说明索引覆盖不够,需要补充文档或调整 embedding 模型。这些信号都是优化 RAG 系统的重要输入。
我个人在实际操作中的体会是,Query Engine 和 Response Synthesizer 这层看起来只是“调几个参数”,但真正决定 RAG 系统好不好用的,往往就是这些参数的组合和细节处理。检索决定能不能找到,合成决定找到之后能不能用好。把这一层吃透,你的 RAG 项目才算真正从 demo 走向可用。