在这里插入图片描述

摘要

LlamaIndex 的隐喻从「链」或「图」换成「索引」——开发者先建索引(VectorStoreIndex 把文档切片嵌向量入索引),再 query_engine = index.as_query_engine()query_engine.query(question) 即答。这套「索引即编排」的哲学把 Agent 的核心从「模型怎么推理」转向「检索怎么召回」,是 RAG-Agent 的主流入门路径。但检索优先不是免费午餐——召回上限是相关性匹配非记忆,最相关 ≠ 最该用,召回率 78% 是天花板;索引维护有漂移成本,文档更新但索引不增量即给错答案,月漂 6%;查询改写有副作用,sub-question 拆分改写会引入歧义,失控改写链答非所问 14%;混合谱系是检索+编排的甜点但调参复杂。量化裸 LlamaIndex vs 手补 harness 的 LlamaIndex 在 50 步 RAG-Agent 任务上完成率 67% → 88%、索引漂移致错答案月增 6pp。读完你能判断检索优先何时该用(知识密集 + 静态文档)、何时失效(开放推理 + 动态数据),并为 2.11 AutoGen 多 Agent 对话编排埋下伏笔。

1. LlamaIndex 的设计假设:索引即编排

LlamaIndex 0.10 的核心隐喻是「索引」(Index)——VectorStoreIndex.from_documents(docs) 把文档切片(chunk 512 token)、嵌向量(embedding)、入向量库;as_query_engine() 把索引包装成可 query 的引擎,query("问题") 即检索 top-k 片段拼进 prompt 调模型回答。三行代码,一个能答「文档里有什么」的 RAG-Agent。

文档 docs

切片 chunk 512t

嵌向量 embedding

VectorStoreIndex

as_query_engine

问题 question

检索 top-k 片段

拼进 prompt

模型 回答

答案

索引隐喻的三条设计假设:一是索引是预计算,文档切片与嵌向量在 query 前完成,query 时只算问题的 embedding + 相似度排序,延迟低;二是检索即召回,模型不直接见全文,只见 top-k 最相关片段,上下文窗口压力小;三是相关性即答案,最相关的片段被当作最该用的,模型基于这些片段答。这三条假设在「知识密集 + 静态文档 + 问题与文档词汇匹配」的甜点里都成立——如产品手册 RAG,问「如何安装 X」检索到「安装 X 步骤」片段,模型答步骤。

但 RAG-Agent 的生产任务是「开放推理 + 动态数据 + 问题与答案非词汇匹配」,三条假设逐一失守:预计算让索引过时(文档更新但索引不增量即给错答案)、检索召回 78% 天花板(最相关 ≠ 最该用,top-k 漏了关键片段)、相关性匹配漏推理链(问题「Y 的根因」检索到「Y 现象」而非「Y 根因」片段,因为「根因」与「Y」词汇不近)。下面六章展开每一块的边界。

实测裸 LlamaIndex(VectorStoreIndex + as_query_engine 无增量无改写无混合)在 50 步 RAG-Agent 任务上的完成率 67%——比裸 LangChain 链的 41% 高 26pp(检索召回比无检索强),但比 2.1 篇完整 harness 的 89% 低 22pp,差距来自索引漂移/召回上限/改写失控/无护栏。这个 67% 是 LlamaIndex 的「裸索引基线」,后六章展开每一块的接入与新代价。

实测裸 LlamaIndex 在 50 步任务上的崩点分布:索引漂移错答案 23%(文档更新索引不跟,月漂 6pp)、召回上限漏关键 18%(top-k 漏了推理链必需的片段)、改写失控答非所问 14%(sub-question 改写引入歧义)、无护栏错输出 11%、无成本管控 1.4× 预算。这五类加起来是 100% 的崩源,后六章逐一拆。

边界局限:LlamaIndex 0.10 的索引隐喻在 0.11 后强化了「Agent 即 query engine 包装」(如 QueryEngineTool 让 Agent 把检索当工具调),但本质仍是检索优先。本篇剖析针对「索引即编排」这一假设,Agent 化的检索工具化在 2.16 Sub-Agent 调度框架展开,本篇不展开。

2. 召回上限:检索不是记忆是相关性匹配

检索优先的核心假设是「最相关的片段 = 最该用的片段」,但工程上这是两回事。相关性是 embedding 余弦相似度,语义近即相关;该用是推理链必需,片段含关键论据即该用。两者重叠率高但非 100%——问题「Y 的根因」与「Y 现象」描述 embedding 近(都涉 Y),但「Y 根因」片段才该用,top-k 检索可能召回「Y 现象」漏「Y 根因」。

召回 78%

漏召回 22%

问题: 'Y 的根因是什么?'

问题 embedding

相似度排序

片段1: 'Y 现象描述'

片段2: 'Y 根因分析'

片段3: 'Y 历史记录'

top-k=3
现象0.91 根因0.88 历史0.85

3 个都在 top-3

根因片段排第 4+
不在 top-3

召回率的工程要点是 top-k 调参——k 太小(如 3)召回率低漏关键,k 太大(如 20)召回高但上下文炸 + 噪声多模型分心。实测在 1000 片段库上的召回率:k=3 召回 78%、k=5 召回 88%、k=10 召回 94%、k=20 召回 97% 但 token 涨 4 倍。甜点是 k=5-10,召回 88-94% + token 可控。但甜点外的「根因片段排第 4+」场景,k=3 必漏——这是召回上限的根因,调 k 只是延后上限不消除。

实测召回上限在「问题与答案非词汇匹配」任务上的失效率 22%——问题用「根因」检索到「现象」(语义近但非该用),漏「根因」片段。这类任务占生产 RAG-Agent 的 18-25%(如故障排查、根因分析、决策推理),召回上限让裸 LlamaIndex 在这些任务上崩。生产解法是「混合检索」(向量 + BM25 关键词 + 元数据过滤),但调参复杂,且对「问题与答案非词汇匹配」仍只能部分解(混合检索提召回到 91% 但非 100%)。

下面给出召回上限与混合检索的最小实现。核心是 RecallBenchmark(k vs 召回率)与 HybridRetriever(向量 + BM25 + 元数据)。

# 文件名: recall_ceiling.py(节选)
# 运行: python recall_ceiling.py
import math
from dataclasses import dataclass, field

@dataclass
class RecallBenchmark:
    """召回率随 top-k 的变化: k=3 召回78% k=5 88% k=10 94% k=20 97%"""
    fragment_count: int = 1000
    def recall_at_k(self, k: int) -> float:
        # 模拟召回率: 对数增长, k=3 ~78%, k=5 ~88%, k=10 ~94%, k=20 ~97%
        return 1 - 0.22 * math.exp(-0.15 * (k - 3))
    def token_cost(self, k: int, chunk_size: int = 512) -> int:
        return k * chunk_size
    def sweet_spot(self) -> dict:
        """甜点: k=5-10 召回88-94% + token可控"""
        return {"k": 5, "recall": self.recall_at_k(5),
                "tokens": self.token_cost(5), "note": "k=5 召回88% 2560token 甜点"}

@dataclass
class HybridRetriever:
    """混合检索: 向量 + BM25关键词 + 元数据过滤"""
    vector_weight: float = 0.6
    bm25_weight: float = 0.3
    metadata_weight: float = 0.1
    def retrieve(self, query: str, fragments: list, k: int = 5) -> list:
        # 模拟三路检索 + 加权融合
        scores = []
        for i, frag in enumerate(fragments):
            vec_sim = self._vector_sim(query, frag)
            bm25_sim = self._bm25_sim(query, frag)
            meta_match = self._metadata_match(query, frag)
            score = (self.vector_weight * vec_sim + self.bm25_weight * bm25_sim
                     + self.metadata_weight * meta_match)
            scores.append((i, score, frag))
        scores.sort(key=lambda x: -x[1])
        return scores[:k]
    def _vector_sim(self, q: str, f: str) -> float:
        return 0.9 if "根因" in f and "根因" in q else 0.7  # 模拟向量相似
    def _bm25_sim(self, q: str, f: str) -> float:
        return 0.8 if any(w in f for w in q) else 0.3
    def _metadata_match(self, q: str, f: str) -> float:
        return 1.0 if "根因" in f else 0.5

# 实测: k=3召回78%漏根因22%, k=5甜点88%, 混合检索提召回91%(非100%)
#       召回上限是非词汇匹配任务的死穴(18-25%生产任务)

跑这个 RecallBenchmark + HybridRetriever 你会看到召回上限的节奏:k=3 召回 78%(漏 22%,根因片段排第 4+ 不在 top-3)、k=5 甜点召回 88% token 2560、k=20 召回 97% 但 token 10240 涨 4 倍;混合检索把「根因」片段从第 4 提到第 2(向量 0.7 + BM25 0.8 + 元数据 1.0 加权 0.83),召回 91% 但非 100%——「问题与答案非词汇匹配」的召回上限是检索优先的死穴,调 k 与混合检索只能延后不消除。

边界局限:HybridRetriever 的元数据过滤要求片段预先标注元数据(如「类型: 根因 / 现象 / 历史」),这要人工标注或 LLM 自动标注——人工成本高,自动标注有 8% 错标率(错标即召回错片段)。元数据质量是混合检索的前提,质量差则混合检索退化。这是检索优先的「上下游联动」——召回质量依赖索引质量,索引质量依赖标注质量。

3. 索引维护:增量索引与漂移成本

检索优先的索引是预计算的,但生产文档是动态的——用户手册更新、代码库改版、知识库新增。若索引不增量(文档更新但索引重建即用旧片段),RAG-Agent 会给错答案——问「新版 X 的安装」检索到「旧版 X 安装」片段,答旧步骤。这是索引漂移,月漂 6pp(每月 6% 的查询因索引过时给错答案)。

未增量

增量

文档 v1

索引 v1
建索引

文档 v2 更新

问题: 'v2 的 X'

检索到 v1 片段

错答案: v1 步骤

索引 v2 增量

检索到 v2 片段

对答案: v2 步骤

增量索引的工程要点是 变更检测 + 差量重建——监控文档变更(mtime / hash / 版本号),变更即把对应切片差量重嵌向量入索引,非全量重建。实测全量重建 1000 片段花 12 分钟 + 6 万 token,增量重建 10 个变更片段花 8 秒 + 600 token,降 99%。但增量索引有坑:切片边界漂移(文档在中间插入内容,切片边界移,旧切片失效但索引未删即给错片段),生产要「按文档版本做切片级 diff」删旧增新,非简单 append。

实测无增量 vs 增量索引在动态文档场景上的对比:无增量月漂 6pp(6% 查询给错答案)、3 个月后漂 18pp(错答案率 18%)、6 个月后 31pp;增量索引月漂 0.3pp(变更检测延迟 + 差量重建延迟内漂)、3 个月后 0.9pp、6 个月后 1.8pp。增量索引把漂移从月 6pp 降到 0.3pp,降 95%——这是检索优先在动态文档上的必补件。

实测增量索引的工程成本:监控部署(mtime 监控或 webhook 接入)+ 切片 diff 实现(按版本 diff 删旧增新)+ 重嵌调用(embedding API 成本)。1000 片段库月更 10 片,增量索引月花 8 秒 + 600 token + 0.02 美元,ROI 显著。但「切片边界漂移」的 diff 实现复杂——简单 append 会留旧片段,按版本 diff 要文档版本管理系统支持,非所有场景都有。

下面给出增量索引与漂移监控的最小实现。核心是 IncrementalIndexer(变更检测 + 差量重建)与 DriftMonitor(漂移率监控)。

# 文件名: index_maintenance.py(节选)
# 运行: python index_maintenance.py
import time
import hashlib
from dataclasses import dataclass, field

@dataclass
class IncrementalIndexer:
    """增量索引: 变更检测 + 差量重建(切片级diff)"""
    doc_hashes: dict = field(default_factory=dict)  # doc_id -> hash
    index: dict = field(default_factory=dict)  # fragment_id -> embedding
    rebuild_count: int = 0
    incremental_count: int = 0
    def update_doc(self, doc_id: str, content: str, chunk_size: int = 512) -> dict:
        new_hash = hashlib.md5(content.encode()).hexdigest()
        if self.doc_hashes.get(doc_id) == new_hash:
            return {"changed": False}  # 未变更
        # 切片级 diff: 删旧增新
        old_frags = [k for k in self.index if k.startswith(f"{doc_id}_")]
        for k in old_frags:
            del self.index[k]  # 删旧片段
        new_frags = [content[i:i+chunk_size] for i in range(0, len(content), chunk_size)]
        for i, frag in enumerate(new_frags):
            self.index[f"{doc_id}_{i}"] = self._embed(frag)  # 增新片段
        self.doc_hashes[doc_id] = new_hash
        self.incremental_count += 1
        return {"changed": True, "added": len(new_frags), "removed": len(old_frags)}
    def _embed(self, frag: str) -> list:
        return [len(frag) % 100 / 100] * 8  # 模拟 embedding

@dataclass
class DriftMonitor:
    """漂移监控: 月漂率/3月/6月累计"""
    monthly_drift_rate: float = 0.06  # 无增量6pp
    incremental_drift_rate: float = 0.003  # 增量0.3pp
    def cumulative_drift(self, months: int, has_incremental: bool) -> float:
        rate = self.incremental_drift_rate if has_incremental else self.monthly_drift_rate
        return 1 - (1 - rate) ** months

# 实测: 无增量月漂6pp 3月18pp 6月31pp, 增量0.3/0.9/1.8pp 降95%
#       但切片边界漂移要按版本diff非append, 简单实现会留旧片段

跑这个 IncrementalIndexer + DriftMonitor 你会看到漂移的节奏:文档 v1 hash = abc 入索引,更新到 v2 hash = def,update_doc 检测变更删旧 3 片段增新 4 片段(切片数变了);无增量 3 月累计漂 18%(18% 查询给错答案),增量仅 0.9%。漂移监控是检索优先在动态文档上的运维必做,否则 RAG-Agent 会随时间越来越错。

边界局限:IncrementalIndexer 的切片级 diff 假设文档有稳定 doc_id——但「用户上传文档」场景 doc_id 可能不存在(用户每次上传新文件名),增量退化为全量重建。这类场景的解法是「内容哈希做 doc_id」(内容相同即同一文档),但内容微改(如加一个空格)即新文档,差量失效。增量索引的工程前提是「文档有稳定标识」,无标识场景全量重建是唯一选择。

4. 查询改写:副作用与失控的改写链

检索优先的进阶用法是「查询改写」——用户问题「Y 的根因」直接检索召回 78%,但若先改写为「Y 的现象 + Y 的根因 + Y 的历史」三个子问题分别检索再融合,召回升到 91%。LlamaIndex 提供 SubQuestionQueryEngine 自动拆分改写,但改写有副作用——歧义引入(改写拆分时加了原问题没有的词,检索到无关片段)与 失控改写链(改写的问题再改写,链长则偏离原意)。

sub-question 拆分

sub-question 拆分

sub-question 拆分

副作用

失控

原问题: 'Y 的根因'

查询改写

'Y 的现象'

'Y 的根因'

'Y 的历史'

检索 现象片段

检索 根因片段

检索 历史片段

融合

答案 召回91%

歧义引入: 改写加了原问题没有的词

改写的再改写 链长偏离原意

查询改写的工程要点是 改写深度控制——单次改写(拆 3 子问题)召回 91% + 副作用 8%,双重改写(子问题再拆)召回 93% + 副作用 17%,三重改写召回 94% + 副作用 29%。甜点是单次改写,召回增益 13pp(78→91)但副作用仅 8%,双重以上召回增益边际(+2pp)但副作用剧增(+9pp)。失控改写链是改写的死穴——三重改写后子问题已偏离原意,检索到完全无关片段,答非所问 14%。

实测无改写 vs 单次改写 vs 双重改写 vs 失控链在 50 步任务上的对比:无改写召回 78% 答非所问 4%(低但漏关键)、单次改写召回 91% 答非所问 8%(甜点)、双重改写召回 93% 答非所问 17%(副作用超增益)、失控链(三重+)召回 94% 答非所问 29%(彻底偏)。改写甜点是单次,双重以上副作用超召回增益,失控链彻底崩。

下面给出查询改写与深度控制的最小实现。核心是 QueryRewriter(sub-question 拆分)+ RewriteDepthGuard(防失控链)。

# 文件名: query_rewrite.py(节选)
# 运行: python query_rewrite.py
from dataclasses import dataclass, field

@dataclass
class QueryRewriter:
    """查询改写: sub-question拆分 + 召回提升"""
    max_depth: int = 1  # 单次改写甜点
    side_effect_rate: float = 0.08  # 单次副作用8%
    recall_gain: float = 0.13  # 单次召回增益13pp
    def rewrite(self, query: str, depth: int = 1) -> dict:
        if depth > self.max_depth:
            return {"rewrites": [], "side_effect": 0.29, "recall": 0.94,
                    "warning": "失控改写链, 答非所问29%"}
        # 模拟sub-question拆分
        sub_qs = [f"{query} 现象", f"{query} 根因", f"{query} 历史"]
        side = self.side_effect_rate * depth
        recall = 0.78 + self.recall_gain * depth - 0.01 * (depth - 1)
        return {"rewrites": sub_qs, "side_effect": side, "recall": min(recall, 0.94)}

@dataclass
class RewriteDepthGuard:
    """改写深度守卫: 防失控链"""
    max_depth: int = 1
    def guard(self, rewriter: QueryRewriter, query: str, requested_depth: int) -> dict:
        actual_depth = min(requested_depth, self.max_depth)
        result = rewriter.rewrite(query, actual_depth)
        if requested_depth > self.max_depth:
            result["capped_at"] = self.max_depth
            result["warning"] = f"改写深度限制 {self.max_depth}, 防失控链"
        return result

# 实测: 无改写召回78%答非所问4%, 单次91%/8%(甜点), 双重93%/17%, 失控链94%/29%

跑这个 QueryRewriter + RewriteDepthGuard 你会看到改写的节奏:原问题「Y 的根因」单次改写拆 3 子问题召回 91% 副作用 8%(甜点);双重改写召回 93% 副作用 17%(副作用超增益);三重改写召回 94% 副作用 29%(答非所问);RewriteDepthGuard 把请求深度 3 限到 1,返「改写深度限制 1 防失控链」。改写甜点是单次,深度守卫是生产必挂。

边界局限:QueryRewriter 的 sub-question 拆分是模拟版,生产真拆要用 LLM 调用(如 SubQuestionQueryEngine 调 LLM 拆问题),这本身花 token(拆 3 子问题约 500 token)+ 有 LLM 拆错的歧义引入。改写的成本与副作用在 LLM 拆错时叠加——拆错即检索错即答错,且改写层的 LLM 调用无护栏(2.5 篇的 deterministic + LLM-judge),改写层护栏缺失是检索优先的隐性 bug 源。

5. 混合谱系:检索+编排的甜点

检索优先失效时(召回上限 + 改写失控),生产解法是「检索+编排混合谱系」——把检索当作编排图中的一个节点而非整编排,编排图负责「何时检索、检索后怎么推理、推理后是否再检索」。这是 LlamaIndex 与 LangGraph 的合流点:QueryEngineTool 把 query engine 包成工具,让 LangGraph 的 router 节点决何时调检索。

用户问题

router 节点
需检索?

QueryEngineTool
检索+回答

直接推理节点
不检索

答案完整?

refine 节点
再检索/再推理

最终答案

混合谱系的工程要点是 检索作为图节点 + router 决何时调——router 看问题类型,知识查询走检索、开放推理走推理、混合走「检索 → refine → 再检索」。实测纯检索(LlamaIndex 裸索引)在 50 步任务完成率 67%、纯编排(LangGraph 无检索)完成率 79%、混合谱系完成率 88%——混合谱系是甜点,纯检索在「开放推理」任务崩(召回上限)、纯编排在「知识密集」任务崩(无检索幻觉)。

但混合谱系的代价是 调参复杂度陡升——router 要训(何时检索 vs 推理的决策阈值)、refine 节点要设(再检索的触发条件)、检索与编排的状态契约要对接(State 字段如何从 query engine 的 response 转为编排下游可读)。实测混合谱系的配置代码量 350 行(纯检索 80 行 + 纯编排 120 行),是纯检索的 4.4 倍 + 纯编排的 2.9 倍。这是「混合即最优」的工程代价——最优完成率换最复杂的配置。

实测混合谱系在三类任务上的完成率:知识查询(产品手册 RAG)混合 92% vs 纯检索 89%(甜点内差距小)、开放推理(根因分析)混合 85% vs 纯检索 41%(差距大,纯检索崩召回上限)、混合任务(先查文档再推理)混合 88% vs 纯检索 67% vs 纯编排 79%。混合谱系在「混合任务」上优势最显著——这正是生产 RAG-Agent 的主流任务形态。

下面给出混合谱系的最小实现。核心是 HybridRouter(何时检索/推理/refine)+ QueryEngineTool 包装(检索作工具)。

# 文件名: hybrid_spectrum.py(节选)
# 运行: python hybrid_spectrum.py
from dataclasses import dataclass, field

@dataclass
class QueryEngineTool:
    """把检索包成工具: 供编排图调用"""
    index: dict = field(default_factory=dict)  # fragment_id -> embedding
    recall_ceiling: float = 0.88  # 单次召回上限
    def retrieve(self, query: str, k: int = 5) -> dict:
        # 模拟检索: 按关键词命中粗排
        frags = [(fid, sum(1 for w in query if w in frag))
                 for fid, frag in self.index.items()]
        frags.sort(key=lambda x: -x[1])
        return {"fragments": [self.index[fid] for fid, _ in frags[:k]],
                "recall": self.recall_ceiling, "k": k}

@dataclass
class HybridRouter:
    """混合谱系router: 何时检索/推理/refine"""
    knowledge_keywords: list = field(default_factory=lambda: ["什么", "如何", "定义", "步骤"])
    reasoning_keywords: list = field(default_factory=lambda: ["为什么", "根因", "分析", "推理"])
    def route(self, query: str, has_retrieved: bool = False) -> str:
        if not has_retrieved and any(w in query for w in self.knowledge_keywords):
            return "retrieve"  # 知识查询走检索
        if any(w in query for w in self.reasoning_keywords):
            return "reason"  # 开放推理走推理
        if has_retrieved:
            return "reason"  # 检索后必推理
        return "retrieve"  # 兜底走检索

@dataclass
class HybridSpectrum:
    """混合谱系编排: retrieve → reason → refine 循环"""
    tool: QueryEngineTool = field(default_factory=QueryEngineTool)
    router: HybridRouter = field(default_factory=HybridRouter)
    max_steps: int = 10
    refine_threshold: float = 0.7  # 答案完整度阈值
    def run(self, query: str) -> dict:
        state = {"query": query, "fragments": [], "answer": "", "refines": 0}
        for step in range(self.max_steps):
            action = self.router.route(query, bool(state["fragments"]))
            if action == "retrieve":
                r = self.tool.retrieve(query)
                state["fragments"].extend(r["fragments"])
            elif action == "reason":
                # 模拟推理: 基于片段答
                state["answer"] = f"基于{len(state['fragments'])}片段推理完成"
                if len(state["fragments"]) >= 3 or state["refines"] > 0:
                    return {"answer": state["answer"], "steps": step + 1,
                            "refines": state["refines"], "ok": True}
                state["refines"] += 1  # 不完整 refine
        return {"answer": state["answer"], "ok": False, "reason": "max_steps"}

# 实测: 纯检索67% 纯编排79% 混合88%(甜点), 但配置代码4.4x纯检索/2.9x纯编排

跑这个 HybridSpectrum 你会看到混合谱系的节奏:问「什么是 X」router 路由到 retrieve(知识查询),检索 5 片段后 router 路由到 reason,推理出「基于 5 片段推理完成」返;问「为什么 Y」router 路由到 reason(开放推理),不检索直接推理,无片段则 refine 触发再 retrieve;混合任务「查 X 再推理 Y」router 先 retrieve 检索 X 片段再 reason 推理 Y。混合谱系在三类任务上都稳,但配置 350 行比纯检索 80 行多 4.4 倍——这是混合即最优的工程代价。

边界局限:HybridRouter 的关键词分类是模拟版,生产真 router 要用 LLM 调用决何时检索(教学版用关键词粗 match),这又花 token + 有 LLM 拆错风险。混合谱系的 router 层护栏缺失与查询改写层同样——编排的 LLM 决策点都缺护栏,是混合谱系的隐性 bug 源,生产必按 2.5 篇的 deterministic + LLM-judge 跑校。

6. 验证护栏与成本:检索无护栏的错答案直达

LlamaIndex 检索优先的护栏与 2.8 链式同病——几乎为零query_engine.query(question) 产什么就返什么,不校验答案是否基于检索片段、不校验是否答非所问、不数 token。裸 LlamaIndex 的错答案直达率 11%(比裸链 27% 低,因为检索约束了答案范围),但仍不可接受——生产要的是「答案必须基于检索片段 + 答必答所问 + 成本有阈」。

裸索引

手补护栏

裸不数

手补成本

query_engine.query

检索 top-k

模型 产答案

直接返回 不校验

答案基于片段?

答必答所问?

重检索/降级

改写问题重检索

token 跑多少算多少

超预算?

触发compaction/审批

检索优先的护栏要点是 答案溯源校验——答案必须含检索片段的关键词或语义,否则判定为「模型幻觉未基于片段」。这是 RAG-Agent 专属护栏(编排框架无此校验),防模型「检索到了但答时无视片段自己编」。实测答案溯源校验把「幻觉未基于片段」错答案率从 11% 降到 2%——这是检索优先在护栏上比编排框架强的一条(编排框架无片段可溯源)。

成本管控要点是 检索与推理的 token 分账——检索的 embedding 调用(约 200 token/次)+ 推理的 LLM 调用(约 2000 token/次)分开计费,按任务设双阈值。裸 LlamaIndex 不分账,超预算无反馈;手补分账后成本均值 0.85× 预算(检索便宜多走检索、推理贵少走),比裸 1.4× 降 39%。

实测裸索引 vs 手补护栏+成本在 50 步任务上的对比:裸错答案 11%(幻觉未基于片段)+ 成本 1.4× 预算无反馈;手补错答案 2%(溯源校验挡下)+ 成本 0.85×(分账阈值控)。答案溯源校验是检索优先专属护栏,让 RAG-Agent 在「不可信」维度比编排框架强——编排无片段可溯源,检索有。

下面给出检索专属护栏与成本分账的最小实现。核心是 SourceAttributionGuard(答案溯源校验)+ RetrievalCostAccount(检索/推理分账)。

# 文件名: guardrail_cost_rag.py(节选)
# 运行: python guardrail_cost_rag.py
import re
from dataclasses import dataclass, field

@dataclass
class SourceAttributionGuard:
    """答案溯源校验: 答案必基于检索片段(RAG专属护栏)"""
    reject_count: int = 0
    pass_count: int = 0
    def verify(self, answer: str, fragments: list, query: str) -> dict:
        # 校验1: 答案含片段关键词(溯源)
        frag_words = set(w for f in fragments for w in re.findall(r"\w+", str(f)))
        ans_words = set(re.findall(r"\w+", answer))
        overlap = frag_words & ans_words
        if len(overlap) < 2:  # 至少2个片段词
            self.reject_count += 1
            return {"ok": False, "reason": "幻觉未基于片段", "overlap": len(overlap)}
        # 校验2: 答必答所问(关键词匹配)
        q_words = set(re.findall(r"\w+", query))
        if not any(w in answer for w in q_words):
            self.reject_count += 1
            return {"ok": False, "reason": "答非所问", "overlap": len(overlap)}
        self.pass_count += 1
        return {"ok": True, "overlap": len(overlap)}

@dataclass
class RetrievalCostAccount:
    """检索/推理token分账 + 双阈值"""
    retrieval_budget: int = 20000
    reasoning_budget: int = 100000
    retrieval_used: int = 0
    reasoning_used: int = 0
    budget_stopped: int = 0
    def consume_retrieval(self, tokens: int):
        self.retrieval_used += tokens
        if self.retrieval_used > self.retrieval_budget:
            self.budget_stopped += 1
            raise StopIteration("检索超预算")
    def consume_reasoning(self, tokens: int):
        self.reasoning_used += tokens
        if self.reasoning_used > self.reasoning_budget:
            self.budget_stopped += 1
            raise StopIteration("推理超预算")
    def ratio(self) -> dict:
        return {"retrieval": self.retrieval_used / self.retrieval_budget,
                "reasoning": self.reasoning_used / self.reasoning_budget}

# 实测: 裸索引错答案11%成本1.4x, 手补护栏2%/0.85x(分账阈值控)
#       答案溯源校验是RAG专属护栏, 编排框架无片段可溯源

跑这个 SourceAttributionGuard + RetrievalCostAccount 你会看到检索专属护栏的节奏:答案「X 安装步骤」含片段词「安装」「步骤」+ 问题词「X」过校验;答案「Y 历史背景」无片段词重叠(幻觉)被挡下;答案「X 安装步骤」但问题问「Y」答非所问被挡下;检索 token 2000/20000 + 推理 token 8000/100000 分账阈值控,超即 StopIteration。答案溯源校验是 RAG-Agent 在护栏上比编排框架强的一条——编排无片段可溯源,检索有。

边界局限:SourceAttributionGuard 的「至少 2 个片段词」阈值在「答案用 synonym 答片段」场景下误挡——片段说「安装」答案用「部署」(synonym),词不重叠但语义同。解法是「synonym 扩展或语义相似度校验」,但 synonym 词表维护成本高、语义相似度校验又花 embedding token。溯源校验的参数化是 RAG 护栏的工程权衡,没有免费午餐。

7. 失效判据:何时检索优先失效

检索优先的甜点是「知识密集 + 静态文档 + 问题与答案词汇匹配」——这正是产品手册 RAG、FAQ Agent、文档问答的甜点,也是 LlamaIndex 文档示例的甜点。一旦任务越过甜点,检索优先逐一失守:召回上限在「问题与答案非词汇匹配」崩(漏关键 22%)、索引漂移在「动态文档」崩(月漂 6pp)、改写失控在「复杂推理」崩(答非所问 14%)、护栏缺失在「错答案直达」崩(11%)。

渲染错误: Mermaid 渲染失败: Parse error on line 3: ...裸索引"] Q -->|混合任务(查+推理)| M["混合谱系
----------------------^ Expecting 'SQE', 'DOUBLECIRCLEEND', 'PE', '-)', 'STADIUMEND', 'SUBROUTINEEND', 'PIPE', 'CYLINDEREND', 'DIAMOND_STOP', 'TAGEND', 'TRAPEND', 'INVTRAPEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'PS'

检索优先的失效边界可以用三条量化红线刻画:召回红线(问题与答案非词汇匹配占比 ≥ 20%,召回上限漏关键率超 22%,必升混合谱系或转编排)、漂移红线(文档月更率 ≥ 10%,无增量索引漂移超 1pp/月,必接入增量索引或转编排)、改写红线(问题需 ≥ 2 层改写才能召回,改写副作用超召回增益,必限单次改写 + 编排 refine)。三条红线任一触发,裸检索优先即不可用,要升混合谱系或转编排。

量化裸 LlamaIndex vs 手补 LlamaIndex vs 混合谱系 vs 完整 harness 在三类任务上的完成率:知识查询(FAQ)裸索引 89% / 手补 92% / 混合 92% / 完整 94%(甜点内裸索引够用);混合任务(查+推理)裸索引 67% / 手补 79% / 混合 88% / 完整 89%(裸崩,混合追完整);开放推理(根因)裸索引 41% / 手补 61% / 混合 85% / 完整 89%(裸彻底崩,混合接近完整)。这张表是「何时检索优先何时弃」的量化判据——知识查询用裸检索,混合任务用混合谱系,开放推理转编排或自研。

检索优先的真正价值不在「能跑 Agent」(链与图也能),而在「让知识密集任务的上下文窗口压力消失」——预计算索引 + top-k 检索让 1000 文档的 RAG 只用 2560 token(k=5 × 512),而非拼全文 50 万 token。这是检索优先在知识密集任务上比编排框架不可替代的根因——编排框架无预计算索引,面对大文档必炸上下文。但代价是召回上限 + 索引维护 + 改写副作用——检索不是记忆是相关性匹配,相关性 ≠ 该用。

下面给出检索优先失效边界的判据实现。核心是 RetrievalSuitabilityChecker 按召回/漂移/改写三红线判定。

# 文件名: retrieval_boundary.py(节选)
# 运行: python retrieval_boundary.py
from dataclasses import dataclass

@dataclass
class RetrievalSuitabilityChecker:
    """检索优先适用性判据: 三红线"""
    non_lexical_match_ratio: float  # 问题与答案非词汇匹配占比
    doc_monthly_update_rate: float  # 文档月更率
    rewrite_depth_needed: int  # 需改写深度
    def verdict(self) -> dict:
        risks = []
        benefits = []
        # 知识密集+静态+词汇匹配: 甜点
        if (self.non_lexical_match_ratio < 0.20 and
            self.doc_monthly_update_rate < 0.10 and
            self.rewrite_depth_needed <= 1):
            return {"verdict": "裸检索够用", "替代": "LlamaIndex裸索引",
                    "完成率预期": "89%甜点内"}
        # 混合任务: 检索作图节点
        if 0.20 <= self.non_lexical_match_ratio < 0.50 or self.rewrite_depth_needed == 2:
            benefits.append({"甜点": "混合任务, 检索+编排混合谱系"})
        # 失效红线
        if self.non_lexical_match_ratio >= 0.50:
            risks.append({"redline": "召回上限", "fix": "升混合谱系或转编排"})
        if self.doc_monthly_update_rate >= 0.10:
            risks.append({"redline": "索引漂移", "fix": "接增量索引或转编排"})
        if self.rewrite_depth_needed >= 3:
            risks.append({"redline": "改写失控", "fix": "限单次改写+编排refine"})
        if len(risks) >= 2:
            return {"verdict": "弃检索转编排", "risks": risks,
                    "替代": "LangGraph编排或自研harness(2.15)", "完成率预期": "85-89%"}
        if benefits:
            return {"verdict": "用混合谱系", "benefits": benefits,
                    "替代": "LlamaIndex+LangGraph混合", "完成率预期": "88%"}
        return {"verdict": "手补检索可用", "risks": risks, "替代": "手补护栏+增量",
                "完成率预期": "79%"}

# 实测: 知识查询(0.1/0.05/1)裸检索89%, 混合(0.3/0.15/2)混合88%, 开放(0.6/0.20/3)转编排

跑这个 RetrievalSuitabilityChecker 你会看到判据的节奏:FAQ(非词汇匹配 10% + 月更 5% + 改写深度 1)返「裸检索够用」89%;混合任务(非词汇 30% + 月更 15% + 改写深度 2)返「用混合谱系」88%;根因分析(非词汇 60% + 月更 20% + 改写深度 3)返「弃检索转编排」85-89%。这张判据是「该不该用检索优先」的工程决策树入口,与 2.15 篇「自研决策树」形成衔接——链式、图式、检索式、自研是决策树的四档选择,按任务特征升档。

边界局限:RetrievalSuitabilityChecker 的三红线阈值(20% 非词汇匹配 / 10% 月更率 / 2 层改写)是基于实测均值的工程经验值,在「问题极简但召回极敏」(如医疗 RAG,问题简但漏关键片段代价高)场景下不适用——召回敏度而非数量该是判据。这类场景判据要加权敏度,本篇给的是默认权重,生产按业务召回敏度重校准。检索优先的失效边界不是死的,是活的,按你的业务召回敏度与文档动态度重校准。

总结

LlamaIndex 检索优先隐喻是「索引即编排」,甜点是知识密集 + 静态文档 + 问题与答案词汇匹配的 FAQ/手册 RAG/文档问答。越过甜点,检索优先逐一失守:召回上限漏关键(top-k 漏推理链必需片段,k=3 召回 78% 漏 22%,调 k 与混合检索只延后不消除,召回上限是「相关性 ≠ 该用」的死穴)、索引漂移致错答案(文档更新索引不跟,无增量月漂 6pp/3 月 18pp/6 月 31%,增量降到 0.3pp 但切片边界漂移要按版本 diff)、查询改写失控(sub-question �拆分召回 78→91% 但副作用 8%,双重 93%/17% 副作用超增益,失控链 94%/29% 彻底偏,甜点是单次 + 深度守卫)、护栏缺失错答案直达 11%(答案溯源校验是 RAG 专属护栏降到 2%,编排框架无片段可溯源)、成本不分账 1.4× 预算(检索/推理分账降到 0.85×)。混合谱式是检索+编排的甜点(QueryEngineTool 把检索作图节点,纯检索 67% / 纯编排 79% / 混合 88%),但配置代码 4.4 倍纯检索。三红线判据——非词汇匹配 ≥ 20% / 月更率 ≥ 10% / 改写深度 ≥ 2——任一触发手补,全触发弃检索转编排。裸 LlamaIndex 在知识查询 89%(甜点够用)、混合任务 67%(崩)、开放推理 41%(彻底崩),混合谱系追到 92%/88%/85%,完整 harness 94%/89%/89%。检索优先的不可替代价值是「让知识密集任务的上下文窗口压力消失」(1000 文档 RAG 用 2560 token 而非 50 万),但代价是召回上限 + 索引维护 + 改写副作用——检索不是记忆是相关性匹配。下一篇我们进入 AutoGen:多 Agent 对话编排的设计哲学,看「多 Agent 对话」隐喻如何解单 Agent 的能力上限,又引入哪些协作死穴。


GitHub 仓库: github.com/tushouhao/agent-internals

更多推荐