做RAG的朋友大概都经历过这个阶段:一开始用最朴素的"切块-向量化-检索-塞进提示词"跑通了一个演示,感觉大模型无所不能;等到真正把文档量堆到几千上万份、用户问题开始变得刁钻之后,召回出来的片段就开始答非所问,回答要么漏关键信息,要么把不相干的内容硬凑在一起。这时候你会发现,问题不在模型本身,而在"检索"这一环太糙了。Advanced-RAG(高级检索增强生成)就是针对这类真实痛点长出来的一套工程方法论,而 LlamaIndex 恰好是把这套方法论落成代码的一个趁手框架。这篇内容我打算把 Advanced-RAG 的几个关键改造位置讲透,再结合 LlamaIndex 的组件给出可以照着搭的骨架代码,适合已经跑通过基础 RAG、正被召回质量和回答质量折磨的开发者参考,也适合想系统理解 RAG 工程化的同学当一份路线图。
1. 朴素RAG的局限与Advanced-RAG的改造切入点
1.1 三段式流程在真实业务里为什么频频翻车
最基础的 RAG 流程可以概括成三个动作:把文档切成块、把块转成向量存进向量库、用户提问时拿问题向量去捞最相似的 Top-K 块拼进提示词。这个流程在演示阶段几乎从不失手,因为演示用的文档少、问题简单、答案往往就集中在一两个段落里。
但真实业务场景会同时踩中几个问题。第一是切分粒度两难:切得小,单个块语义不完整,模型拿到半句话没法回答;切得大,一个块里混了好几个主题,向量被平均掉,相似度计算失真。第二是单一向量检索的天花板:语义向量擅长模糊匹配,但对专有名词、编号、精确术语反而不如关键词检索,用户问"某个型号的参数"时,向量检索经常召回一堆语义相近但型号不对的段落。
第三是Top-K 的粗暴性:不管问题需要几段信息,一律取固定的 K 个块,多取的噪音会稀释上下文,少取的又可能漏掉关键证据。第四是查询与文档的表达鸿沟:用户的口语化提问和文档里的书面表述之间存在语义漂移,原始问题直接拿去检索,命中率天然打折。这几个问题叠加起来,就是很多人感觉"RAG 效果玄学"的根源。
1.2 Advanced-RAG的三处手术位置
业界把 Advanced-RAG 的改造点大致归到三个位置,这个划分方式我觉得特别实用,因为它直接对应了你在代码里该动哪一块。
| 改造位置 | 核心动作 | 解决的原问题 |
|---|---|---|
| 预检索阶段 | 更聪明的切分、层次化索引、加元数据、多表示索引 | 块粒度两难、语义被平均 |
| 检索阶段 | 混合检索、查询改写、多查询扩展、路由 | 单一向量召回不全、表达鸿沟 |
| 后检索阶段 | 重排序、上下文压缩、拼装顺序与预算控制 | Top-K 粗暴、噪音稀释 |
预检索是在建索引的时候就下功夫,把文档组织成更适合被检索的形态。检索中段是在用户提问到真正召回之间加一层处理,让查询更准、召回更全。后检索是在召回结果进入提示词之前做精加工,去噪、排序、压缩。
一个常见的误解是把 Advanced-RAG 等同于"加个重排序模型"。重排序只是后检索里的一环,如果你的切分和索引本身就有问题,重排序只能在垃圾里挑相对不那么垃圾的,救不回来。
1.3 什么场景才值得上这套东西
不是所有项目都值得把这三个位置全部改一遍。我的判断标准是看两个信号:回答里是否频繁出现"信息缺失"或"答非所问",以及文档是否具有结构复杂、术语密集、跨文档关联强这些特征。
如果你的知识库就是几十份格式统一的 FAQ,朴素 RAG 加一个稍微讲究点的切分就够用了,硬上混合检索和重排序只会增加延迟和维护成本。反过来,如果面对的是技术手册、合同条款、多版本产品文档这类内容,那么预检索的层次化索引和检索中段的混合策略几乎是必需品。先定位瓶颈在哪一段,再决定加哪一层,比一次性堆满组件要务实得多。
2. 预检索阶段:切分、索引与元数据的重建
2.1 切分粒度不是拍脑袋,先看文档的语义单元
切分这件事,很多人直接抄一个"chunk_size=512, overlap=50"就开跑,然后抱怨效果不好。我一般的做法是先抽样看文档的自然语义单元在哪里断。技术文档的自然单元往往是"一个小节",合同的自然单元是"一个条款",代码库的自然单元是"一个函数或类"。找到这个单元,切分参数就有了依据。
具体操作上,可以先用按标题层级切分的解析器,把文档解析成树状结构,再在叶子节点层面做二次切分。LlamaIndex 里的SentenceSplitter支持按 token 数切,MarkdownNodeParser、HierarchicalNodeParser则能直接把文档的标题结构转成节点层级。
from llama_index.core.node_parser import HierarchicalNodeParser, get_leaf_nodes # 按 2048 / 512 / 128 三层切,保留父子关系 parser = HierarchicalNodeParser.from_defaults( chunk_sizes=[2048, 512, 128] ) nodes = parser.get_nodes_from_documents(documents) leaf_nodes = get_leaf_nodes(nodes) # 真正参与向量化的最小块这里chunk_sizes的含义是层层嵌套:2048 的大块包含若干 512 的中块,512 包含若干 128 的小块。小叶子块保证检索精度,大块保证回答时有足够上下文——这其实就是后面要讲的父子块召回的物理基础。
2.2 父子块召回:用小块的精度换大块的视野
朴素 RAG 有个死结:检索要准,块得小;生成要有料,块得大。父子块(Small-to-Big)策略把这个死结解开了——用小块去算相似度,命中之后把它所属的大块喂给模型。
因为小块的向量更聚焦,语义不被稀释,相似度计算更准;而真正进入提示词的是大块,上下文完整。LlamaIndex 里对应的是AutoMergingRetriever或直接靠RecursiveRetriever走父子引用。
from llama_index.core.retrievers import AutoMergingRetriever from llama_index.core.storage.docstore import SimpleDocumentStore # leaf_nodes 建向量索引,全部 nodes 存进 docstore storage_context = StorageContext.from_defaults(docstore=SimpleDocumentStore()) storage_context.docstore.add_documents(nodes) base_retriever = index.as_retriever(similarity_top_k=6) retriever = AutoMergingRetriever( base_retriever, storage_context, simple_ratio_thresh=0.5 # 同一父块下过半叶子命中就合并成父块 )simple_ratio_thresh这个参数控制合并的激进程度。设成 0.5 表示一个父块下有一半以上的叶子被召回时,直接把父块整体返回。设太低会把不相关的兄弟块拽进来,设太高又失去了合并的意义。我在实际调的时候一般从 0.5 试到 0.6,看评估集上召回完整率的变化。
2.3 元数据过滤:让检索先收窄再算相似度
很多召回不准的根因不是向量不好,而是检索范围太大。用户问的是"2023 版接口文档里的超时配置",如果整个知识库所有版本、所有模块的文档都参与相似度计算,噪音必然多。
解法是给每个节点挂上结构化元数据,检索时先按元数据做过滤,再在小范围内算相似度。元数据可以是文档版本、所属模块、生效日期、文档类型这些字段。
from llama_index.core.schema import TextNode node = TextNode( text="超时配置默认 30 秒...", metadata={ "doc_type": "interface_doc", "version": "2023", "module": "network", } )配合MetadataFilters就能在查询时带上条件。这项改造收益非常直接,但前提是元数据抽取要靠谱。我的经验是能用文档路径、文件名解析出来的字段就别用模型去抽,规则稳定、零成本,模型抽取适合处理那些确实藏在正文里的属性。
3. 检索中段:混合策略、查询改写与路由
3.1 向量检索配关键词检索,谁也别瞧不起谁
语义向量和关键词检索是两种互补的能力。向量擅长"意思相近但用词不同"的匹配,关键词(BM25 之类)擅长专有名词、编号、代码符号这类精确匹配。Advanced-RAG 里几乎标配的做法是把两路召回结果做融合。
LlamaIndex 里可以用QueryFusionRetriever把多个检索器并起来,再用 RRF(倒数排名融合)合并排序。
from llama_index.core.retrievers import QueryFusionRetriever from llama_index.retrievers.bm25 import BM25Retriever vector_retriever = index.as_retriever(similarity_top_k=8) bm25_retriever = BM25Retriever.from_defaults( docstore=storage_context.docstore, similarity_top_k=8 ) fusion_retriever = QueryFusionRetriever( [vector_retriever, bm25_retriever], similarity_top_k=6, mode="reciprocal_rerank", num_queries=1, # 不做查询扩展时设 1 )mode="reciprocal_rerank"就是 RRF 融合。它的思路是不看两路返回的绝对分数(两者分数量纲不同,直接比没意义),只看在各自列表里的排名,排名越靠前加权越高。这个方式对异构检索器特别友好,不需要做分数归一化。
3.2 查询改写:把用户的口语翻译成文档的语言
用户提问往往很短、很口语,甚至带着指代和省略。直接把这种问题拿去检索,命中率会打折扣。查询改写(Query Rewriting)就是用模型把原始问题重写成更适合检索的表述,或者扩展成多个变体并行召回。
LlamaIndex 提供了HyDEQueryTransform(先生成假设性答案再拿答案去检索)、MultiStepQueryEngine等现成组件。我更常用的是多查询扩展:让模型基于原问题生成 3 个不同角度的检索查询,分别召回后融合。
from llama_index.core.query_engine import TransformQueryEngine from llama_index.core.indices.query.query_transform import HyDEQueryTransform hyde = HyDEQueryTransform(include_original=True) query_engine = TransformQueryEngine(base_query_engine, query_transform=hyde)HyDE 的思路值得说清楚:它让模型先"编"一段可能的答案,然后拿这段答案的向量去检索。因为答案的表述风格更接近文档,检索命中率往往提升。代价是多一次模型调用,延迟上升。这条链路适合那些对延迟不敏感、但对召回质量敏感的场景,实时对话类应用要权衡。
3.3 路由与子问题拆解:让不同问题走不同管道
现实中的查询五花八门:有的是闲聊,有的是元问题("你能做什么"),有的是需要跨文档汇总的复杂问题。一刀切地用同一套检索流程,体验必然参差。路由(Router)就是先判断问题类型,再分发到对应的处理管道。
LlamaIndex 的RouterQueryEngine用一组带描述的检索器做选择。描述写得准不准,直接决定路由效果,这一点非常吃经验——描述要覆盖该检索器的适用场景和边界,别写得太泛。
from llama_index.core.query_engine import RouterQueryEngine from llama_index.core.selectors import LLMSingleSelector from llama_index.core.tools import QueryEngineTool tools = [ QueryEngineTool.from_defaults( query_engine=vector_engine, description="适合查询概念解释、原理说明类问题" ), QueryEngineTool.from_defaults( query_engine=summary_engine, description="适合需要跨多篇文档汇总、对比的问题" ), ] router = RouterQueryEngine( selector=LLMSingleSelector.from_defaults(), query_engine_tools=tools )对于"把 A 和 B 分别是怎么做的对比一下"这种复合问题,单次检索必然漏。可以上SubQuestionQueryEngine,先拆成子问题分别检索,再汇总。这类改造在复杂问答场景收益明显,但链路长、成本高,要按场景开关。
4. 后检索阶段:重排序、压缩与上下文拼装
4.1 重排序模型解决的是"排序不准"而不是"召回不到"
向量检索用的是双塔结构,问题和文档分别编码,算内积。这种架构快,但精度有限,因为它没有让问题和文档在编码阶段产生交互。重排序(Rerank)用的是交叉编码器,把问题和每个候选块拼在一起喂进模型算相关性,精度高但速度慢,所以只适合对少量候选(比如 Top-20 到 Top-50)做精排。
流程上是两级:先用向量/混合检索粗召回一批,再用重排序模型精排出 Top-K 送进提示词。
from llama_index.postprocessor.sentence_transformer_rerank import ( SentenceTransformerRerank ) reranker = SentenceTransformerRerank( model="BAAI/bge-reranker-base", top_n=4 ) query_engine = index.as_query_engine( similarity_top_k=20, node_postprocessors=[reranker] )粗召回数量similarity_top_k和精排后保留数top_n要分开调。粗召回太少,重排序没有选择空间;粗召回太多,重排序延迟会上去。我一般粗召回 20 到 30,精排后留 3 到 5 个,实测这个区间在准确率和延迟之间比较平衡。
4.2 上下文压缩:把块里的废话裁掉
即使重排序选对了块,块里也可能掺着大量与当前问题无关的内容,比如章节前言、关联说明、版权声明。这些内容会占掉宝贵的上下文窗口,还可能误导模型。上下文压缩(Context Compression)就是让模型或者规则从块里只抽取与问题相关的句子。
LlamaIndex 有LongContextReorder(把最相关的块放在上下文两端,中间放次要的,缓解"中间信息丢失"问题)、SentenceEmbeddingOptimizer(按句子相似度裁剪)等后处理器。这类组件对长上下文模型特别友好——模型对上下文中间位置的信息利用率本来就低,把关键内容往两端挪是性价比很高的做法。
from llama_index.core.postprocessor import LongContextReorder query_engine = index.as_query_engine( similarity_top_k=20, node_postprocessors=[reranker, LongContextReorder()] )顺序很重要:重排序在前,重排完再把顺序调整。这两个后处理器可以串在同一个列表里,LlamaIndex 会按顺序执行。
4.3 拼装顺序与 token 预算控制
进入提示词的节点顺序和数量都需要有意识的设计。数量上,不是越多越好——塞进 10 个块,其中 6 个是噪音,模型的注意力反而被分散。顺序上,把最强的证据放在开头和结尾,中间放辅助材料。
另外要留出 token 预算给系统提示词和模型输出。一个稳妥的做法是先把上下文窗口减去输出预留(比如 1000 token),再反推最多能放几个块。这个计算最好写死在配置里,别指望模型自己"看着办"。我在一个项目里就因为没控制好拼接数量,长文档场景触发了截断,把最关键的证据段落截掉了,排查了半天才定位到预算问题。
5. 用LlamaIndex把这些零件组装成一条链路
5.1 先理清LlamaIndex的核心抽象
LlamaIndex 的抽象层次比较多,刚上手容易迷失。抓住这条主线就够用:Document → Node → Index → Retriever → QueryEngine。Document 是原始文档,Node 是切分后的检索单元(带元数据和关系),Index 是节点的组织方式(向量索引、关键词索引、树索引、摘要索引等),Retriever 负责从索引里捞节点,QueryEngine 把检索和生成串起来。
Advanced-RAG 的改造基本都发生在 Node 的构造方式(预检索)、Retriever 的组合方式(检索中段)、以及 QueryEngine 上挂的 postprocessor(后检索)。理解了这个分层,往哪里插组件就很清楚了。
5.2 一条完整链路的骨架代码
把前面讲的分散组件串起来,骨架大致是这样:
from llama_index.core import VectorStoreIndex, StorageContext from llama_index.core.node_parser import HierarchicalNodeParser, get_leaf_nodes from llama_index.core.retrievers import AutoMergingRetriever, QueryFusionRetriever from llama_index.retrievers.bm25 import BM25Retriever from llama_index.core.query_engine import RetrieverQueryEngine from llama_index.postprocessor.sentence_transformer_rerank import ( SentenceTransformerRerank ) from llama_index.core.postprocessor import LongContextReorder # 1. 层次化切分 parser = HierarchicalNodeParser.from_defaults(chunk_sizes=[2048, 512, 128]) nodes = parser.get_nodes_from_documents(documents) leaf_nodes = get_leaf_nodes(nodes) # 2. 建索引,节点关系和全量节点存入 storage storage_context = StorageContext.from_defaults() storage_context.docstore.add_documents(nodes) index = VectorStoreIndex(leaf_nodes, storage_context=storage_context) # 3. 混合召回 vector_retriever = index.as_retriever(similarity_top_k=20) bm25_retriever = BM25Retriever.from_defaults( docstore=storage_context.docstore, similarity_top_k=20 ) fusion_retriever = QueryFusionRetriever( [vector_retriever, bm25_retriever], similarity_top_k=12, mode="reciprocal_rerank", num_queries=1, ) # 4. 父子合并 auto_merging = AutoMergingRetriever( fusion_retriever, storage_context, simple_ratio_thresh=0.5 ) # 5. 重排序 + 顺序调整 postprocessors = [ SentenceTransformerRerank(model="BAAI/bge-reranker-base", top_n=5), LongContextReorder(), ] # 6. 组装 query_engine = RetrieverQueryEngine.from_args( auto_merging, node_postprocessors=postprocessors, )这个骨架把预检索、检索、后检索三段都覆盖了,每一段都可以单独做消融实验——这是它最大的好处。别一次性全上,一段一段加,每加一段跑一次评估,才知道哪一段真正在起作用。
5.3 组件顺序与依赖关系里容易忽略的细节
这里有几个实际踩过的点。第一,BM25 建立在 docstore 上,所以全量节点必须先写进 docstore,否则关键词检索会找不到数据源。第二,AutoMergingRetriever 依赖父子关系,如果用叶子节点单独建索引、没把父节点存进 docstore,合并逻辑会失效,它只会原样返回叶子块。第三,后处理器是有状态的,重排序模型的首次加载慢,最好在服务启动时预热,别等到第一个请求来了才加载。
另外 QueryFusionRetriever 里的num_queries如果设成大于 1,它会调用模型做查询扩展,等于在检索阶段又插了一次模型调用。这个调用是同步阻塞的,高并发场景要评估延迟影响,必要时可以把查询扩展挪到离线或者做缓存。
6. 评估、调参与踩过的那些坑
6.1 检索质量和生成质量必须分开评估
这是我最想强调的一点。很多团队看回答不准就调模型,其实问题在检索。分开评估的逻辑是:先看召回的块里有没有正确答案(检索指标),如果召回里根本没有,那再怎么调生成也没用;如果召回了但模型答错,才是生成的锅。
检索指标常用命中率、召回率、MRR。生成指标看回答的忠实度(有没有编造文档里没有的内容)和相关性。LlamaIndex 有内置的评估模块,可以基于一小批标注数据跑这些指标。数据量不用大,几十条高质量标注就能看出组件改动前后的差异。
6.2 搭一个够用的评估集比调参更重要
评估集就是一批"问题-正确答案-标准证据块"的三元组。做法是从真实日志里抽问题,人工标出答案所在的文档块。这件事听起来费事,但收益巨大:没有评估集,所有改动都是盲调。我见过太多团队反复改参数、凭感觉说"好像好了一点",最后发现只是换了几个案例的运气。
评估集要覆盖几类典型问题:单跳事实问答、跨文档汇总、含专有名词的精确查询、口语化提问。每类都要有,否则你在某类问题上的优化会把另一类的退化掩盖掉。
6.3 几个实打实的坑
坑一:切分把表格切碎了。技术文档里的参数表如果被按 token 切开,表头和内容分离,检索到的块全是孤儿行。解法是表格单独识别、整表作为一个节点,或者用chunk_size大到能容纳完整表格的切分器。
坑二:元数据乱加导致过滤失效。有人给每个节点都加一堆自动生成的标签,结果查询时过滤条件写不准,要么过滤掉有用的块,要么过滤形同虚设。元数据字段贵精不贵多,能可靠抽取的才加。
坑三:重排序模型和向量模型的语言不匹配。用中文文档库配了个英文为主的重排序模型,效果会莫名其妙地差。选型时确认模型和你的语料语言一致。
坑四:上下文拼装忽略了输入顺序的影响。前面提过,模型对上下文中间的信息利用率低。这个问题的表现是"证据明明在上下文里,模型却说找不到"。LongContextReorder能缓解,但更根本的是别把无关块塞太多。
坑五:评估指标看着涨了,实际体验没变。指标涨但体验不变,通常是指标和真实用户需求错位,比如你优化了单跳事实问答,但用户主要问的是需要跨文汇总的问题。每次优化前先确认你的评估集和真实业务分布一致。
6.4 从朴素到高级的渐进改造路线
如果让我给一个从零开始的团队排改造顺序,我会这么建议:第一步把切分改好,用层次化切分加父子合并,这是性价比最高的一步,能解决大部分召回完整性问题。第二步加混合检索,向量加关键词,解决专有名词和编号类查询。第三步加轻量的重排序,粗召回放宽、精排收窄。第四步根据场景决定要不要上查询改写和路由,这两步成本和延迟都更高,收益的场景也更有针对性。每一步都要配评估,确认有效再加下一步。
我自己在实际项目里的体会是,Advanced-RAG 的价值不在组件有多花哨,而在你清楚每一个组件解决的是什么具体问题。工具箱里东西越多越容易迷失,先诊断瓶颈在哪一段,再对症下药,比照着一张"最佳实践清单"全上要靠谱得多。这套东西本质上是一门工程手艺,没有放之四海皆准的参数,只有越调越清楚自己数据特性的手感。