跳到主要内容

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-516retriever_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-164raise 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-57factory.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-296tree_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.pyRetrieverQueryEngine._query, retrieve
合成入口response_synthesizers/base.pyBaseSynthesizer.synthesize
模式枚举response_synthesizers/type.pyResponseMode
合成器工厂response_synthesizers/factory.pyget_response_synthesizer
refineresponse_synthesizers/refine.pyRefine, StructuredRefineResponse, DefaultRefineProgram
compactresponse_synthesizers/compact_and_refine.pyCompactAndRefine._make_compact_text_chunks
tree summarizeresponse_synthesizers/tree_summarize.pyTreeSummarize.get_response
token 预算indices/prompt_helper.pyPromptHelper._get_available_chunk_size, repack, truncate