Chapter 07

Retriever 与 Query Engine · 检索的精细控制

做到这一步数据已经索引好了,真正决定答案质量的是检索。top_k 设多少?要不要重排?多跳检索?路由?这一章把 Retriever/Postprocessor/QueryEngine 三层抽象用法讲透。

一、三层结构:Retriever / Postprocessor / Synthesizer

query ──▶ Retriever(召回 k 条) ──▶ Postprocessor(过滤/重排/扩充) ──▶ Synthesizer(喂 LLM 合成答案) ──▶ response

LlamaIndex 的 QueryEngine 就是这三步的容器。搞清楚每一步都能独立替换,RAG 调优才有章法:

from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core import get_response_synthesizer

retriever     = index.as_retriever(similarity_top_k=10)
postprocessors = [...]  # 马上讲
synthesizer   = get_response_synthesizer(response_mode="compact")

qe = RetrieverQueryEngine(
    retriever=retriever,
    node_postprocessors=postprocessors,
    response_synthesizer=synthesizer,
)
ans = qe.query("2025 Q3 营收")

二、Retriever 参数与模式

retriever = index.as_retriever(
    similarity_top_k=10,            # 召回多少 ↑多一些给 postprocessor 挑
    filters=metadata_filters,        # Ch6 讲的 MetadataFilters
    vector_store_query_mode="default",   # default/hybrid/sparse/text_search
    alpha=0.5,                      # hybrid 模式下稀疏/稠密权重(0=全稀疏,1=全稠密)
    sparse_top_k=20,                  # hybrid 模式下稀疏部分召回数
)

top_k 的选择哲学

"召回多 + 后处理筛"几乎总是比"精准少召回"好:

三、AutoRetriever:LLM 自己写过滤条件

用户问"Acme 在 2024 年的 NDA"——这里有两个 filter:party=Acmeyear=2024。AutoRetriever 让 LLM 看 schema 描述,自己构造 MetadataFilters:

from llama_index.core.retrievers import VectorIndexAutoRetriever
from llama_index.core.vector_stores import MetadataInfo, VectorStoreInfo

info = VectorStoreInfo(
    content_info="合同条款全文",
    metadata_info=[
        MetadataInfo(name="party", type="str", description="合同对方公司名"),
        MetadataInfo(name="year",  type="int", description="签约年份"),
        MetadataInfo(name="type",  type="str", description="合同类型:NDA/SOW/MSA"),
    ],
)

retriever = VectorIndexAutoRetriever(
    index=index,
    vector_store_info=info,
    similarity_top_k=5,
    verbose=True,     # 打印 LLM 生成的 filter,调试必开
)

nodes = retriever.retrieve("Acme 在 2024 年的 NDA")
# LLM 会自动生成 filter: party=="Acme" AND year==2024 AND type=="NDA"
生产经验:AutoRetriever 成败在 metadata_info 的 description——描述要让 LLM 能从自然语言里映射出字段名。"年份" "year" 都要在 description 里出现。先用 verbose=True 跑几十条线上 query,看哪些被错误过滤,回去优化 description

四、RecursiveRetriever:多跳下钻

Ch2 讲过 IndexNode 可以作为"桥"。RecursiveRetriever 就是遇到 IndexNode 时自动下钻到对应子索引继续检索:

from llama_index.core.retrievers import RecursiveRetriever
from llama_index.core.schema import IndexNode

# 上层索引:产品级
top_nodes = [
    IndexNode(text="产品 A 介绍", index_id="prod_a_index"),
    IndexNode(text="产品 B 介绍", index_id="prod_b_index"),
]
top_index = VectorStoreIndex(top_nodes)

# 子索引:每个产品的详细文档
sub_indices = {
    "prod_a_index": VectorStoreIndex.from_documents(prod_a_docs).as_retriever(),
    "prod_b_index": VectorStoreIndex.from_documents(prod_b_docs).as_retriever(),
}

recursive = RecursiveRetriever(
    "vector",
    retriever_dict={"vector": top_index.as_retriever(), **sub_indices},
    verbose=True,
)

nodes = recursive.retrieve("产品 A 的定价")
# ①上层命中 IndexNode(prod_a_index) ②自动跳到 prod_a_index 继续检索 ③返回细节 chunk

五、AutoMergingRetriever:小块命中、大块回答

理想的 RAG:embedding 用小块(512)命中准,但回答时给 LLM 更大的上下文(2048)。AutoMergingRetriever + HierarchicalNodeParser 就是这个组合:

from llama_index.core.node_parser import HierarchicalNodeParser
from llama_index.core.retrievers import AutoMergingRetriever
from llama_index.core.storage.docstore import SimpleDocumentStore

parser = HierarchicalNodeParser.from_defaults(chunk_sizes=[2048, 512, 128])
nodes = parser.get_nodes_from_documents(docs)

docstore = SimpleDocumentStore()
docstore.add_documents(nodes)  # 大中小三级都存

# 只把最小块(128)建向量索引
from llama_index.core.node_parser import get_leaf_nodes
leaf_nodes = get_leaf_nodes(nodes)
vector_index = VectorStoreIndex(leaf_nodes, storage_context=StorageContext.from_defaults(docstore=docstore))

base = vector_index.as_retriever(similarity_top_k=12)
retriever = AutoMergingRetriever(base, storage_context=vector_index.storage_context, verbose=True)

nodes = retriever.retrieve("怎么配置双因素认证")
# 如果 12 个小块里有 5 个属于同一个父块,自动合并返回父块

思路:如果召回的小块密集聚集在同一个父块里,说明答案就在那附近,与其给 LLM 几个碎片不如给它完整父块。

六、Postprocessor:对召回再加工

Postprocessor作用场景
SimilarityPostprocessor按分数阈值过滤低于 0.7 的垃圾召回
LLMRerankLLM 重排 top_k最后一公里精度
SentenceTransformerRerankBGE/Jina 本地重排模型生产主力 🔥
CohereRerankCohere 重排 API效果顶级但要钱
MetadataReplacementPostProcessor替换 text 为 metadata 中的字段SentenceWindow 还原上下文
PrevNextNodePostprocessor拉回相邻节点扩展上下文
TimeWeightedPostprocessor按时间衰减加权新闻/资讯场景
LongContextReorder把重要的放头尾(中间遗忘)长 context LLM 都有

重排(Rerank):RAG 的最强 ROI 工具

先说结论:加 rerank 几乎总是值得的。重排用的是 cross-encoder——把 query 和每个候选 chunk 一起送进模型,比双塔 embedding 精准得多。

# 本地 BGE rerank(推荐)
from llama_index.postprocessor.flag_embedding_reranker import FlagEmbeddingReranker

rerank = FlagEmbeddingReranker(
    model="BAAI/bge-reranker-v2-m3",   # 多语主力
    top_n=5,
    use_fp16=True,
)

# Cohere rerank(最强)
from llama_index.postprocessor.cohere_rerank import CohereRerank
rerank = CohereRerank(api_key="...", top_n=5, model="rerank-v3.5")

qe = index.as_query_engine(
    similarity_top_k=20,        # 多召回
    node_postprocessors=[rerank], # 重排后留 5
)

LongContextReorder:中间遗忘问题

LLM 对 context 里中间位置的信息记忆最差("lost in the middle" 论文)。LongContextReorder 把最重要的放头尾:

from llama_index.core.postprocessor import LongContextReorder

qe = index.as_query_engine(
    similarity_top_k=10,
    node_postprocessors=[rerank, LongContextReorder()],
)

七、Response Synthesizer:拼 prompt 的艺术

Ch5 介绍过 response_mode,这里更全:

mode逻辑适合
compact(默认)能装一起就一起,一次 LLM短回答、一般场景
refine逐节点精炼答案要非常严谨、慢、贵
tree_summarize二分归并总结、多节点合并
simple_summarize粗暴拼一起节点数少、上下文够
accumulate每节点独立回答,最后拼"列举每个节点提到的..."
compact_accumulateaccumulate + compact 合批列举型 + 成本意识
no_text只返回 nodes,不合成要自己处理答案

自定义 prompt 模板

from llama_index.core import PromptTemplate

qa_tmpl = PromptTemplate(
    """请根据下面的上下文回答问题。回答要求:
1. 只使用提供的上下文,不要臆造
2. 答案末尾列出引用的来源文件名
3. 找不到答案就回答"上下文中没有这个信息"

<context>
{context_str}
</context>

问题:{query_str}
答案:"""
)

qe = index.as_query_engine(text_qa_template=qa_tmpl)

八、RouterQueryEngine:多库分发

公司有 5 个不同领域的 index(产品/合同/HR/财务/技术)——问题来了要路由到对的 index。RouterQueryEngine 让 LLM 选:

from llama_index.core.query_engine import RouterQueryEngine
from llama_index.core.tools import QueryEngineTool
from llama_index.core.selectors import LLMSingleSelector, PydanticSingleSelector

tools = [
    QueryEngineTool.from_defaults(
        query_engine=qe_product,
        name="product_docs",
        description="产品使用手册、API 文档、版本发布说明",
    ),
    QueryEngineTool.from_defaults(
        query_engine=qe_contract,
        name="contracts",
        description="公司对外签订的所有合同、NDA、MSA",
    ),
    QueryEngineTool.from_defaults(
        query_engine=qe_hr,
        name="hr",
        description="员工手册、薪酬规则、休假政策",
    ),
]

router = RouterQueryEngine(
    selector=PydanticSingleSelector.from_defaults(),   # LLM 选 1 个
    query_engine_tools=tools,
    verbose=True,
)
ans = router.query("年假有多少天?")
# LLM 会选 hr,然后在 hr 索引上跑

要 LLM 选多个 tool(比如问题跨领域)换 PydanticMultiSelector。注意 description 越清晰越准——像给实习生写的任务说明,别写得像 DB 字段名

九、QueryFusionRetriever:多查询融合

用户的问题可能模糊。让 LLM改写成 N 个变体一起检索,结果融合:

from llama_index.core.retrievers import QueryFusionRetriever

fusion = QueryFusionRetriever(
    [vector_retriever, bm25_retriever],
    similarity_top_k=5,
    num_queries=4,                  # 原问题 + 3 个改写
    mode="reciprocal_rerank",
    use_async=True,
    verbose=True,
    query_gen_prompt="""原问题: {query}

请生成 {num_queries} 个语义相近但表达不同的检索查询,每行一个:""",
)

nodes = fusion.retrieve("怎么退款?")
# LLM 改写成:"退款流程" "如何申请退款" "退款需要的材料",四条各自检索,RRF 融合

十、Streaming 流式返回

qe = index.as_query_engine(streaming=True)
streaming_response = qe.query("解释双因素认证")

for token in streaming_response.response_gen:
    print(token, end="", flush=True)

# 或 FastAPI SSE
async def stream_chat(q):
    resp = await qe.aquery(q)
    async for token in resp.async_response_gen():
        yield f"data: {token}\n\n"

十一、一个生产级组合

from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core.retrievers import QueryFusionRetriever
from llama_index.core.postprocessor import SimilarityPostprocessor, LongContextReorder
from llama_index.postprocessor.flag_embedding_reranker import FlagEmbeddingReranker
from llama_index.retrievers.bm25 import BM25Retriever

# 1. 召回层(hybrid)
v = index.as_retriever(similarity_top_k=15)
b = BM25Retriever.from_defaults(nodes=nodes, similarity_top_k=15)
retriever = QueryFusionRetriever(
    [v, b], similarity_top_k=20, num_queries=1, mode="reciprocal_rerank",
)

# 2. 后处理层
postprocessors = [
    SimilarityPostprocessor(similarity_cutoff=0.25),
    FlagEmbeddingReranker(model="BAAI/bge-reranker-v2-m3", top_n=5),
    LongContextReorder(),
]

# 3. 合成层
qe = RetrieverQueryEngine.from_args(
    retriever=retriever,
    node_postprocessors=postprocessors,
    response_mode="compact",
    streaming=True,
    text_qa_template=qa_tmpl,
)

十二、常见坑

  1. top_k 太小又没重排:精度天花板就在那了——至少 10 召回 + rerank。
  2. AutoRetriever 的 metadata description 写得太简:LLM 生成错误 filter,全部查不到。
  3. rerank 模型没跟 embedding 匹配:BGE embed + BGE rerank 是一个家族,混用其他可能退化。
  4. Cohere rerank 忘了开 cutoff:top_n 截断后返回的都是假分数,要配合 SimilarityPostprocessor(cutoff=0.3)
  5. RouterQueryEngine 的 description 只写字段名:写成自然描述,LLM 才能匹配。
  6. QueryFusion 的 num_queries 设大(>5):收益递减,成本翻倍。
  7. refine 模式在长文档上慢到 30 秒:换 compact 或 tree_summarize。
  8. streaming 模式忘了异步 await:SSE 端点直接阻塞。
  9. SentenceWindow 忘了 MetadataReplacementPostProcessor:拿到的是小窗口不是完整上下文,白设计了。

十三、本章小结

记住:
① 标配组合:top_k=20 召回 + BGE rerank top_n=5 + LongContextReorder——这一组就能击败 90% 的默认 pipeline。
② AutoRetriever 把 metadata 过滤从手写变成 LLM 自动——注意 description 要写清楚。
③ RouterQueryEngine 处理多领域分发,QueryFusionRetriever 处理模糊问题改写——生产项目通常两者都要。
④ 小块命中、大块回答 → SentenceWindow 或 AutoMerging,既准又完整