LlamaIndex 合成 — 把召回的 Node 变成答案
这章讲什么: 在线查询的后半段——拿到一批召回的
NodeWithScore,怎么让 LLM 据此写出答案。难点是上下文窗口装不下所有块。先看PromptHelper怎么算 token 预算、怎么repack,再看几种合成模式(refine/compact/tree_summarize)在「调用次数 vs 质量」上的不同取舍,最后看RetrieverQueryEngine怎么把检索和合成编排成一次query()。
4.1 编排:一次 query() 发生了什么
RetrieverQueryEngine._query(query_engine/retriever_query_engine.py:202-215)就三步:
_query(q):
nodes = self.retrieve(q) # 检索(第03章) + node_postprocessors 后处理
response = synthesizer.synthesize(q, nodes) # 合成(本章)
return response
retrieve 里还会跑可选的 node_postprocessors(重排、相似度阈值过滤、时间衰减等),逐个 postprocess_nodes(retriever_query_engine.py:142-149)。
index.as_query_engine(**kw) 默认就是 RetrieverQueryEngine.from_args(retriever, ...),合成器默认 ResponseMode.COMPACT(indices/base.py:491-516、retriever_query_engine.py:71)。
4.2 核心难题:上下文窗口装不下
召回 10 个块、每块 500 token,加起来 5000 token,可能超过模型窗口,何况还要留空间给提示模板和输出。PromptHelper(indices/prompt_helper.py)就是管这件事的。它先算「可用上下文」:
可用上下文 = 总窗口 - 已填提示(模板+系统提示+工具) - 预留输出(num_output)
可用块大小 = 可用上下文 // 块数 - padding
真实实现:_get_available_context_size(prompt_helper.py:146-165,结果为负则抛 ValueError——注意它的 docstring(prompt_helper.py:156)写的是「clamped to be non-negative」,但代码实际是 prompt_helper.py:160-164 的 raise ValueError,即「负数当作配置错误直接报错」,而非悄悄夹到 0,以代码为准)和 _get_available_chunk_size(prompt_helper.py:178-230,把提示模板、系统提示、structured-LLM 的工具 schema 的 token 都算进「已填」)。
repack vs truncate
基于这个预算,PromptHelper 提供两个对偶操作:
| 操作 | 做什么 | 何时用 |
|---|---|---|
repack | 把多块拼起来,再按「可用块大小」重新切——尽量塞满每个块,减少块数 → 减少 LLM 调用 | compact / tree_summarize 前 |
truncate | 每块截断到能放下,丢弃超出部分 | 想一次调用塞下时 |
repack 的本质很朴素(prompt_helper.py:277-296):
# 示意,非源码:对应 PromptHelper.repack
splitter = self.get_text_splitter_given_prompt(prompt, llm=llm) # 块大小=可用上下文
combined = "\n\n".join(c.strip() for c in text_chunks if c.strip())
return splitter.split_text(combined) # 重新切成「尽量塞满」的大块
意义:10 个半空的小块 → repack 成 2 个塞满的大块 → LLM 调用从 10 次降到 2 次。
4.3 合成模式:质量 vs 调用次数的取舍
ResponseMode(response_synthesizers/type.py)列了全部模式,get_response_synthesizer(response_synthesizers/factory.py:38-)按模式造对应合成器。最常用三种:
refine — 逐块迭代精炼
思路:用第 1 块 + query 生成初答;把「初答 + query + 第 2 块」喂进 refine 提示 得到精炼答案;如此走完 N 块(type.py:7-15)。
块1 ─QA提示─▶ 答案v1
答案v1 + 块2 ─refine提示─▶ 答案v2
答案v2 + 块3 ─refine提示─▶ 答案v3 ...(N 块 → N 次 LLM 调用)
实现 Refine(response_synthesizers/refine.py:203)。每步用 DefaultRefineProgram 调 LLM(refine.py:79-129)。质量好但慢(调用次数 ∝ 块数)。
一个流式优化细节:开启 structured_answer_filtering 后,LLM 返回结构化的 StructuredRefineResponse,字段顺序故意把 query_satisfied 放在 answer 之前(refine.py:59-70)——因为 LLM 几乎总按字段声明顺序流式输出,于是头几个 token 就能读到「这块够不够答」,不满足就能提早放弃这块,不必等整段答案流完。
compact(默认)— 先 repack 再 refine
CompactAndRefine(response_synthesizers/compact_and_refine.py:13)= 先 PromptHelper.repack 把块打包成尽量少的大块,再走 refine。所以它比纯 refine 调用次数少(type.py:17-23)。打包时用 get_biggest_prompt([QA模板, refine模板]) 取较大的那个算预算,保证两种提示都放得下(compact_and_refine.py:50-59)。这是 query engine 的默认模式。
tree_summarize — 自底向上归并
TreeSummarize(response_synthesizers/tree_summarize.py:20)适合「总结类」问题。每轮:① repack 把块塞满;② 若只剩一块 → 直接出最终答案;③ 否则并发地对每块各生成一个摘要,再对这批摘要递归走同样流程(tree_summarize.py:77-153)。
块1 块2 块3 块4 块5 块6
└summary└summary└summary (并发,asyncio.gather)
summaryA summaryB
summary(root) ← 最终答案
相比 refine 的「串行链式」,tree_summarize 是「并行树式」,层数 ≈ log,延迟更低,适合大量块的归纳。
其它模式速查
| 模式 | 行为 | 取舍 |
|---|---|---|
SIMPLE_SUMMARIZE | 所有块拼一段,一次 LLM 调用 | 最省,超窗口就崩 |
GENERATION | 忽略上下文,纯 LLM 生成 | 无 RAG |
NO_TEXT | 只返回召回的 Node,不调 LLM | 调试 / 自己接合成 |
CONTEXT_ONLY | 返回拼接的上下文字符串 | 接外部 |
ACCUMULATE / COMPACT_ACCUMULATE | 对每块各答一份再拼接(后者先 repack) | 「逐块分别回答」场景 |
出处:response_synthesizers/type.py:25-57、factory.py:90-191。
4.4 synthesize 的入口逻辑
BaseSynthesizer.synthesize(response_synthesizers/base.py:232-310)统一处理:
- 召回为空 → 直接返回空响应(
base.py:245-265)。 - 取每个 Node 的
get_content(metadata_mode=MetadataMode.LLM)作为文本块(base.py:288-295)——呼应第 01 章:给 LLM 看的文本受excluded_llm_metadata_keys控制。 - 多模态时改用
get_response_from_messages,把 Node 转成 content blocks(base.py:274-286)。 - 最后把答案 + source nodes 封进
Response(base.py:300),让调用方能拿到「答案 + 引用了哪些 Node」。
4.5 巧妙之处
- token 预算显式建模:
可用 = 窗口 - 提示 - 预留输出,且把工具 schema、系统提示都算进提示占用(prompt_helper.py:178-230);算出的可用上下文一旦为负就直接raise ValueError(prompt_helper.py:160-164),把「窗口被提示+预留输出占满」当作配置错误显式暴露,而非默默截断。 - repack 把「少而满」当一等目标:直接拿 LLM 调用次数换钱/时延(
prompt_helper.py:277-296、tree_summarize.py:86)。 - 结构化输出的字段顺序当作流式控制信号:
query_satisfied在前,实现「无关来源提早放弃」(refine.py:59-70)。 - 同一套召回 Node,合成策略可热插拔:换
response_mode就在 refine / compact / tree 之间切,检索代码一行不改(factory.py:get_response_synthesizer)。
4.6 边界
SIMPLE_SUMMARIZE不做分块,块超窗口直接失败(type.py:25-29明说)。- refine 系列每块一次 LLM 调用,块多时慢且贵;compact 用 repack 缓解,tree_summarize 用并行缓解。
- 答案质量受召回质量约束:合成只能基于检索到的 Node,检索漏了就答不出(RAG 的固有边界)。
4.7 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 查询编排 | query_engine/retriever_query_engine.py | RetrieverQueryEngine._query, retrieve |
| 合成入口 | response_synthesizers/base.py | BaseSynthesizer.synthesize |
| 模式枚举 | response_synthesizers/type.py | ResponseMode |
| 合成器工厂 | response_synthesizers/factory.py | get_response_synthesizer |
| refine | response_synthesizers/refine.py | Refine, StructuredRefineResponse, DefaultRefineProgram |
| compact | response_synthesizers/compact_and_refine.py | CompactAndRefine._make_compact_text_chunks |
| tree summarize | response_synthesizers/tree_summarize.py | TreeSummarize.get_response |
| token 预算 | indices/prompt_helper.py | PromptHelper._get_available_chunk_size, repack, truncate |