1. Query Engine 与 Response Synthesizer 在 RAG 链路中的定位
1.1 为什么检索做完了,答案还是不对
很多人搭 RAG 系统时,把八成精力砸在向量库选型、Embedding 模型对比、chunk 大小调参上,结果上线之后发现一个尴尬的事实:检索回来的文档明明是对的,但最终给用户的回答要么答非所问,要么把三段不相关的原文拼在一起,要么干脆开始“自由发挥”。这个问题的根源,往往不在检索端,而在检索之后的那个环节——Query Engine 和 Response Synthesizer。
用一句话概括这两个东西的分工:Query Engine 是“调度中心”,它决定一次查询要走哪些流程、拿哪些上下文、按什么顺序组装;Response Synthesizer 是“组装车间”,它拿到 Query Engine 递过来的上下文和提示词,真正调用 LLM 生成最终答案。检索负责“找对料”,这两个组件负责“用好料”。料再好,炒菜的手法不对,端上桌的菜照样难吃。
LlamaIndex 把这两个概念拆得很清楚,这也是它在 RAG 框架里比较受欢迎的原因之一。Query Engine 是一个面向用户的统一接口,你给它一个自然语言问题,它返回一个结构化或非结构化的响应;而 Response Synthesizer 是 Query Engine 内部真正干活的那个模块,它定义了“上下文 + 提示词 + LLM”三者如何组合。理解这一层,你才能明白为什么同一个向量库、同一个 LLM,换个response_mode效果能差出一大截。
1.2 适合哪些人深入这块内容
这篇文章主要面向三类人。第一类是已经跑通过基础 RAG demo、但回答质量始终上不去的开发者,你需要从“能跑”进阶到“好用”;第二类是在做企业知识库、智能客服、文档问答类产品的工程师,你们对答案的准确性、可追溯性要求高,必须精细控制生成环节;第三类是想深入理解 LlamaIndex 内部机制的人,Query Engine 和 Response Synthesizer 是绕不开的核心抽象。
如果你还停留在“向量检索 + 拼 prompt + 调 GPT”的阶段,那这篇文章会让你重新审视自己的架构。如果你已经在用 LlamaIndex 但只会写index.as_query_engine()这一行,那接下来的内容能帮你把这行代码背后的东西全部拆开。
2. Query Engine 的核心设计与选型逻辑
2.1 Query Engine 到底封装了什么
从接口层面看,Query Engine 就是一个query(str)方法,输入问题、输出响应。但这个简单接口背后封装了一整条流水线。以 LlamaIndex 的RetrieverQueryEngine为例,它内部至少包含四个关键角色:Retriever(检索器)、Node Postprocessor(节点后处理)、Response Synthesizer(响应合成器)、以及可选的 Query Transform(查询变换)。
Retriever 负责从索引里召回一批相关节点,这一步决定了“候选池”的质量。Node Postprocessor 是很多人忽略的一环,它负责对召回结果做二次筛选、重排序、去重、截断,比如SimilarityPostprocessor可以按相似度阈值砍掉低分节点,KeywordNodePostprocessor可以强制保留或排除包含特定关键词的节点。Response Synthesizer 则是最后一步,把筛选后的节点和用户问题一起喂给 LLM。
为什么要把这些拆成独立组件而不是写死在一起?核心原因是可替换性。检索策略会变、后处理规则会变、生成模式会变,如果全耦合在一个函数里,每次调整都要动核心代码。拆开之后,你可以像搭积木一样组合:今天用向量检索 + 相似度过滤 + refine 模式,明天换成混合检索 + 重排序 + compact 模式,Query Engine 的调用方式不变。
2.2 三种典型 Query Engine 的适用场景
LlamaIndex 里常见的 Query Engine 不止一种,选错了会直接影响体验。下面这张表是我在实际项目中总结的对比:
| Query Engine 类型 | 核心机制 | 适合场景 | 主要短板 |
|---|---|---|---|
| RetrieverQueryEngine | 检索 + 合成,标准 RAG | 通用文档问答、知识库 | 单轮检索,复杂问题召回不足 |
| SubQuestionQueryEngine | 把复杂问题拆成子问题分别检索 | 多跳推理、对比类问题 | 调用次数多,延迟和成本高 |
| RouterQueryEngine | 用 LLM 或规则路由到不同索引 | 多数据源、异构知识库 | 路由判断本身可能出错 |
我踩过的一个坑是:早期做一个产品文档问答,用户经常问“A 功能和 B 功能有什么区别”,用标准 RetrieverQueryEngine 召回的结果往往只覆盖其中一个功能,答案自然不完整。后来换成 SubQuestionQueryEngine,它会自动把问题拆成“A 功能是什么”和“B 功能是什么”两个子问题分别检索,再汇总生成对比答案,准确率提升非常明显。代价是每次查询的 LLM 调用次数从 1 次变成 3 次以上,延迟翻倍,所以只对复杂问题开启,简单问题还是走标准引擎。
2.3 查询变换:被低估的提效手段
在 Retriever 之前,其实还有一个可选环节叫 Query Transform。它的作用是在检索前对用户原始问题做改写或扩展。常见的有HyDEQueryTransform(先生成一个假设性答案,再用这个答案去检索)和MultiStepQueryTransform(把问题拆成多步)。
为什么这个环节重要?因为用户的提问方式和文档的表述方式往往存在“语义鸿沟”。用户问“怎么退款”,文档里写的是“订单取消与资金返还流程”,纯向量检索可能召不回最相关的段落。HyDE 的思路是让 LLM 先根据问题“编”一个可能的答案,这个答案的用词更接近文档风格,再用它去检索,命中率会明显提高。
注意:Query Transform 会额外增加一次 LLM 调用,对延迟敏感的场景要谨慎开启。我的做法是只对短问题(少于 15 个字)启用 HyDE,长问题本身信息量足够,直接检索效果就不差。
3. Response Synthesizer 与 response_mode 深度拆解
3.1 Response Synthesizer 的职责边界
Response Synthesizer 的输入是“用户问题 + 一批检索到的节点”,输出是最终响应。它的核心工作可以拆成三步:组装上下文、选择提示词模板、调用 LLM。听起来简单,但每一步都有讲究。
组装上下文时,节点不是随便堆进去的。每个节点有文本内容、有元数据、有相似度分数,Synthesizer 需要决定用多少节点、按什么顺序排列、是否截断超长文本。选择提示词模板时,不同response_mode对应完全不同的模板,有的要求 LLM 只根据上下文回答,有的允许它结合自身知识。调用 LLM 时,还要处理 token 超限、流式输出、多轮调用等问题。
这就是为什么我不建议直接手写 prompt 拼上下文。自己拼当然灵活,但你要自己处理 token 计算、节点排序、超限截断、多节点汇总,这些琐事一旦出错,排查起来非常痛苦。Response Synthesizer 把这些都封装好了,你只需要选一个合适的response_mode。
3.2 六种 response_mode 的实测对比
response_mode是 Response Synthesizer 最核心的参数,它直接决定了生成策略。下面这张表是我在同一个知识库(约 2000 个节点)上实测的结果,问题类型为“产品功能咨询”:
| response_mode | 生成策略 | 答案质量 | 延迟 | LLM 调用次数 | 适用场景 |
|---|---|---|---|---|---|
| refine | 逐节点迭代精炼答案 | 高 | 高 | N+1 | 答案需要综合多个节点 |
| compact | 先压缩再合并,减少调用 | 较高 | 中 | 1~2 | 通用默认,性价比高 |
| tree_summarize | 树状递归汇总 | 高 | 中高 | log N | 摘要类、长文档汇总 |
| simple_summarize | 所有节点截断后一次汇总 | 低 | 低 | 1 | 快速预览,不追求精度 |
| no_text | 只返回检索节点,不生成 | 无 | 极低 | 0 | 调试检索质量 |
| accumulate | 对每个节点分别生成再拼接 | 中 | 中 | N | 需要逐条列举答案 |
refine模式的质量确实好,因为它让 LLM 逐个看节点,不断修正和完善答案。但代价是每个节点都要调一次 LLM,10 个节点就是 11 次调用,延迟和成本都上去了。compact是我最常用的默认模式,它会把能塞进一个 prompt 的节点合并处理,塞不下的才分批,通常 1 到 2 次调用就能搞定,质量损失很小。
tree_summarize特别适合“把这份 50 页的报告总结成 500 字”这类需求。它把节点分组,每组生成一个中间摘要,再对中间摘要递归汇总,最终得到一个覆盖全面的总结。no_text模式在调试阶段非常有用,它不调 LLM,直接返回检索到的节点,你可以快速判断“是检索没召回,还是生成没用好”。
3.3 自定义 Response Synthesizer 的时机
内置的response_mode覆盖了大部分场景,但有些情况你需要自己写。比如你希望答案必须带引用编号,每个事实后面标注来源节点;比如你希望答案严格遵循某个 JSON schema 输出;比如你需要在生成前对节点做特殊的去重或合并逻辑。
自定义的方式是实现ResponseSynthesizer接口,或者更简单地,用get_response_synthesizer传入自定义的text_qa_template和refine_template。我做过一个法律文档问答,要求答案必须引用具体条款编号,就是在模板里强制要求 LLM 输出[条款X]格式,然后在后处理里校验引用是否真实存在于检索节点中。这个校验步骤很关键,因为 LLM 有时会编造引用编号。
4. 从零搭建一个可控的 Query Engine 实操
4.1 环境准备与索引构建
先明确一下实操环境:Python 3.10+、llama-index 0.10.x、一个本地 Embedding 模型(我用的是 BGE-small-zh)、一个可调用的 LLM 接口。索引构建这块不是本文重点,简单带过:把文档切分成 512 token 左右的 chunk,用VectorStoreIndex.from_documents建索引,持久化到本地。
关键点是元数据要带全。每个节点至少要有source(来源文件)、page(页码)、section(章节标题)三个字段。这些元数据在生成阶段可以用来做引用标注,在调试阶段可以用来定位问题。我见过太多人建索引时不带元数据,出了问题根本不知道召回的是哪一段。
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader from llama_index.core.node_parser import SentenceSplitter documents = SimpleDirectoryReader("./docs").load_data() splitter = SentenceSplitter(chunk_size=512, chunk_overlap=50) index = VectorStoreIndex.from_documents( documents, transformations=[splitter], show_progress=True ) index.storage_context.persist("./storage")4.2 组装带后处理的 Query Engine
直接index.as_query_engine()太粗糙,我们要手动组装。核心是三个部分:Retriever、Postprocessor 列表、Response Synthesizer。
from llama_index.core.query_engine import RetrieverQueryEngine from llama_index.core.postprocessor import ( SimilarityPostprocessor, KeywordNodePostprocessor ) from llama_index.core.response_synthesizers import get_response_synthesizer retriever = index.as_retriever(similarity_top_k=8) postprocessors = [ SimilarityPostprocessor(similarity_cutoff=0.72), KeywordNodePostprocessor( required_keywords=[], exclude_keywords=["草稿", "废弃"] ) ] synthesizer = get_response_synthesizer( response_mode="compact", structured_answer_filtering=False ) query_engine = RetrieverQueryEngine( retriever=retriever, node_postprocessors=postprocessors, response_synthesizer=synthesizer )这里有几个参数值得说明。similarity_top_k=8是召回数量,设太小可能漏掉关键节点,设太大则引入噪声并增加生成成本。我的经验是先用no_text模式跑一批测试问题,观察召回节点的相似度分布,如果第 5 名之后的相似度已经低于 0.7,那 top_k 设 5 到 6 就够了。similarity_cutoff=0.72是后处理阈值,低于这个分数的节点直接丢弃,这个值需要根据你的 Embedding 模型和语料特点来调,没有万能值。
4.3 参数调优的实测记录
我拿一个 300 篇技术文档的知识库做了一轮调参实验,测试集是 50 个真实用户问题,评估标准是“答案是否包含正确信息且无编造”。结果如下:
| 配置 | top_k | cutoff | response_mode | 准确率 | 平均延迟 |
|---|---|---|---|---|---|
| 基线 | 4 | 无 | compact | 68% | 2.1s |
| 调召回 | 8 | 无 | compact | 76% | 2.8s |
| 加过滤 | 8 | 0.70 | compact | 82% | 2.6s |
| 换模式 | 8 | 0.70 | refine | 86% | 6.4s |
| 最终选型 | 8 | 0.72 | compact | 84% | 2.7s |
可以看到,单纯增加 top_k 能提升召回,但会引入噪声;加上相似度过滤后准确率明显上升,因为低分节点往往是干扰项。refine模式确实能再提升几个点,但延迟从 2.7 秒涨到 6.4 秒,对交互式产品来说不划算。最终我选了 compact + 0.72 的组合,在质量和延迟之间取得平衡。
实操心得:调参不要一次改多个变量。先固定 response_mode,调 top_k 和 cutoff;找到最优召回配置后,再对比不同 response_mode。否则你根本不知道是哪个参数起了作用。
5. 常见问题与排查技巧实录
5.1 答案不完整或只覆盖问题的一部分
这是最常见的抱怨。排查思路是先用no_text模式看召回节点,如果召回节点本身就只覆盖了问题的一部分,那是检索问题,需要调整 chunk 策略或换 Embedding 模型;如果召回节点覆盖完整但答案不全,那是生成问题,通常是response_mode选得不对。
compact模式在节点较多时会把它们分批处理,如果批次划分不合理,可能导致某些节点的信息在汇总时被稀释。这时候可以试试refine模式,它逐个节点精炼,不容易漏信息。另一个技巧是在提示词模板里明确要求“请综合所有提供的上下文,不要遗漏任何相关信息”。
5.2 LLM 编造上下文中不存在的内容
RAG 最怕的就是幻觉。Response Synthesizer 的默认模板里通常有“如果上下文没有相关信息,请回答不知道”这类约束,但 LLM 不一定严格遵守。我的做法是双管齐下:一是在模板里用更强的措辞,比如“你只能使用以下上下文中的信息,任何超出上下文的陈述都视为错误”;二是在生成后做校验,用另一个 LLM 调用或规则匹配检查答案中的关键实体是否出现在检索节点中。
还有一个容易被忽略的点:如果检索节点本身包含矛盾信息(比如旧版文档和新版文档同时被召回),LLM 会倾向于“和稀泥”,给出一个模棱两可的答案。这时候需要在后处理阶段做去重和版本过滤,把过时节点排除掉。
5.3 延迟过高,用户体验差
延迟主要来自三个地方:检索、后处理、生成。检索通常是毫秒级,后处理如果用了重排序模型可能到几百毫秒,生成是大头,尤其是refine和tree_summarize这种多次调用的模式。
优化手段有几个:一是用流式输出,让用户先看到部分答案,感知延迟降低;二是对简单问题走轻量模式,可以用 RouterQueryEngine 先判断问题复杂度,简单问题用compact甚至simple_summarize,复杂问题才用refine;三是缓存,相同或相似问题的答案直接复用,这个在客服场景特别有效。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查动作 | 解决方向 |
|---|---|---|---|
| 答案答非所问 | 召回节点不相关 | 用 no_text 模式看召回 | 调 cutoff、换 Embedding |
| 答案不完整 | 生成时信息丢失 | 对比召回节点和答案 | 换 refine 模式、改模板 |
| 出现编造内容 | LLM 未遵守约束 | 检查模板约束强度 | 加强模板、加后校验 |
| 延迟过高 | 多次 LLM 调用 | 统计调用次数 | 换 compact、加缓存 |
| 引用编号错误 | LLM 编造引用 | 校验引用是否存在 | 后处理过滤无效引用 |
6. 进阶:把 Query Engine 和 Agent 结合起来
6.1 为什么需要 Agentic RAG
标准 Query Engine 是“一次检索、一次生成”的线性流程。但真实问题往往需要多步:先查概念定义,再查具体用法,最后对比差异。这种多跳需求,单次检索很难覆盖全。Agentic RAG 的思路是让 Agent 来决定“什么时候检索、检索什么、检索几次”,把 Query Engine 作为 Agent 的一个工具来调用。
LlamaIndex 提供了QueryEngineTool,可以把一个 Query Engine 包装成 Agent 可调用的工具。Agent 根据用户问题自主决定调用哪个工具、传什么参数。比如你有“产品文档索引”和“API 参考索引”两个 Query Engine,Agent 会根据问题类型自动路由。
6.2 工具封装的实操要点
封装成工具时,工具的description非常关键,Agent 靠它来判断什么时候用这个工具。描述要写清楚“这个工具能回答什么类型的问题”,而不是“这是一个查询引擎”。比如“用于查询产品功能、使用方法、常见问题,输入应为自然语言问题”就比“产品文档查询工具”好得多。
另一个要点是控制工具数量。Agent 面对太多工具会选择困难,我一般控制在 3 到 5 个以内。如果索引很多,先用 RouterQueryEngine 做一层路由,再把路由后的引擎封装成工具。
from llama_index.core.tools import QueryEngineTool, ToolMetadata tool = QueryEngineTool( query_engine=query_engine, metadata=ToolMetadata( name="product_docs", description="查询产品功能、使用方法、配置步骤。输入为自然语言问题。" ) )6.3 成本与效果的平衡
Agentic RAG 效果好,但成本也高。每次 Agent 决策是一次 LLM 调用,每次工具调用又触发一次 Query Engine(内部还有生成调用)。一个复杂问题可能触发 5 到 8 次 LLM 调用。我的策略是设置最大迭代次数(通常 3 到 4 次),超过就强制返回当前最佳答案。同时在 Agent 的提示词里明确“如果已有足够信息,直接回答,不要过度检索”。
实测下来,Agentic RAG 在多跳问题上的准确率比标准 Query Engine 高出 15 到 20 个百分点,但延迟是后者的 3 倍左右。所以我的建议是:简单问答走标准引擎,复杂分析类问题才走 Agent 路径,用 RouterQueryEngine 做前置判断。
7. 一些踩坑之后的个人体会
Response Synthesizer 的text_qa_template是可以完全自定义的,但不要一上来就大改。先用默认模板跑通,确认检索没问题之后,再针对具体问题微调模板。我见过有人检索配置一团糟,却花大量时间调提示词,最后效果还是不行,因为根子上召回就是错的。
response_mode的选择没有绝对优劣,只有适不适合。compact是很好的默认值,但如果你做的是“把多份文档汇总成一份报告”这类任务,tree_summarize的效果会明显更好。如果你做的是“逐条列出所有相关条款”,accumulate更合适。关键是理解每种模式背后的生成策略,而不是盲目抄配置。
最后分享一个调试技巧:把 Query Engine 的中间结果打出来看。LlamaIndex 的response.source_nodes包含了本次生成用到的所有节点及其相似度分数,response.metadata里可能有更多信息。每次答案不对,先看source_nodes,十有八九问题出在那里。这个习惯帮我省了大量瞎调参数的时间。