接手过的 RAG 方案越多,越容易产生一个感触:真正卡住项目进度的,常常不是模型选得不够强,而是“数据怎么进、怎么出、怎么校验”没有理清楚。大多数项目都会经历一个尴尬阶段:单独测检索感觉还行,单独测生成也不错,但把检索结果丢给模型以后,答案却总是不对味,而且出了问题根本不知道从哪一层开始查。我后来选择用 LlamaIndex 来做这套流程的底座,不是因为某个功能多么炫,而是它把“建库→召回→重排→生成”这个 RAG 主链路收敛成了标准组件,每个环节既可以默认跑,也可以随时替换成自己的实现。这篇文章就依照这个顺序,从我实际项目里的落地方案拆开讲,最后给出一条命令跑完整条流水线的入口。
先说明一下“一条命令”的含义,不是说所有代码都塞进一行 Shell,而是通过同一个启动命令,按固定顺序完成文档建索引、混合检索、重排筛选、模型回答。这个思路对两类读者最有用:一类是刚接触检索增强生成,想快速理解各个环节之间是什么关系;另一类是已经跑通 Demo,想往里加入混合检索、重排、评估和缓存,把它往能长期维护的方向推。
1. 一条命令背后:先把 RAG 的四个环节看清楚
1.1 建库、召回、重排、生成分别解决什么问题
可以把 RAG 想成一条生产线。建库是入库环节,把一堆 PDF、Markdown、网页内容清洗、切块、向量化,存到一个能快速查询的地方。召回是粗筛环节,面对用户问题时,从全量知识库里先挑出一批“看起来相关”的片段。重排是精筛环节,用更精细的判断逻辑,把粗筛结果里真正对回答问题有帮助的文本提到前面来。生成是包装环节,让大模型基于精筛后的少量文本,组织一段通顺、可读、带依据的回答。
这四件事看起来谁都能写,真正做起来难点全在边界上。建库切块的粒度会影响召回率,召回结果的质量又直接决定重排有没有意义,重排输出的候选顺序会影响模型最终参考哪些内容,最后由模型写出来的答案还要能被推回到原始文档,方便人查验。任何一个环节出了偏差,都会顺着链路往下游放大。
举个我在项目里遇到的例子:一开始只做向量召回,Top-10 里明明有正确答案,但正确答案排在第六第七,前面全是文档开头那段模板介绍。直接送到模型面前,模型自然会优先参考前面的内容,最后说“资料不足”。这其实不是生成模型的问题,是粗筛到精筛之间缺了重排那个动作。所以每次排查结果不准,我都会先问自己:问题是没召回到,还是召回了但排序不对,还是上下文给得不清楚?搞清楚这个,再动手调某个环节。
1.2 为什么选 LlamaIndex,而不是自己拼一堆胶水代码
自研 RAG 流程不算难,麻烦在于把文档解析、文本切块、向量存储、查询改写这些细节都做对,需要大量的重复劳动。LlamaIndex 给我的价值是“标准件”:它有统一的文档加载器、节点解析器、索引对象、检索器和查询引擎,默认行为能跑通,也允许我替换其中任意一层。
另一个关键原因,是 LlamaIndex 把“语境”这个抽象建得比较准确。文档加载之后会变成 Nodes,每个 Node 既保留文本内容,也可以带 metadata,比如来源文件名、页码、标题层级。后续不管是召回还是生成,这些元数据都能跟着文档一起流传,最终回答附上引用来源的时候就非常省事。如果自己手写这套,往往越到后面越会发现存在各种半结构化的隐式约定,维护成本比写第一版时高一大截。
这个选择也有代价。框架更新速度快,接口偶尔变动,需要锁版本。遇到问题去查资料时,别只看最新文档,还要确认版本对应关系。这一点我在后面会专门用一节讲。
1.3 一条命令的入口设计
项目里我习惯把主流程收敛成一个入口脚本,命令行参数尽量简单:数据目录、索引缓存目录、用户问题。核心启动命令长这样:
python run_pipeline.py \ --data ./docs \ --index ./index_cache \ --query "产品手册里提到的回滚机制是什么?"对应的脚本内部,只做四件事:
def main(data_path: str, index_path: str, query: str): index = get_or_build_index(data_path, index_path) retriever = build_fusion_retriever(index) nodes = retriever.retrieve(query) reranked_nodes = rerank_nodes(query, nodes, top_n=3) answer = generate_answer(query, reranked_nodes) print(answer)这样设计的好处是每个环节都能单独测。比如我今天只改了重排逻辑,就重新跑一遍完整命令,看答案变化;想单独看召回结果,就把generate_answer临时替换成打印函数。下面几节就沿着这个主流程,把每一步的具体做法、参数选择和踩坑心得展开说。
2. 建库:真正决定检索质量下限的一步
2.1 文档加载与清洗:别把 PDF 提取的乱码当宝贝
很多项目的检索效果差,不是因为 embedding 不好,而是文档加载阶段就丢了太多信息。我用 LlamaIndex 的文档加载器接入本地目录时,默认会把文档里的目录、页眉、表格、页脚都读进来。这些内容单独看无害,切块以后却很容易形成大量“看起来像一段话,实际上没信息量”的节点,检索时还经常因为重复率高而霸占榜单。
所以建库的第一件事不是切块,而是清洗。我的经验是先把文档按类型分流:PDF、Word、Markdown、HTML 分别用对应的解析器,然后统一做三件事:去掉页码和页眉页脚、合并被换行打断的段落、把表格转成键值对文本。PDF 尤其要注意表格,很多 PDF 解析出来的表格是乱序文本,直接切块后,表格含义基本丢失。
加载阶段还要记得写 metadata。每个节点至少带上file_name、page_label、section_title。有了这些字段,后面做答案溯源、按来源过滤、甚至按文档目录做召回路由,都会方便很多。不要把这一步看成附加功能,它是后续所有环节的信息底座。
2.2 切块策略:块大小和重叠度怎么选
切块是建库里最容易被低估的环节。块太大,语义太杂,向量表示不聚焦;块太小,单独一块承载不了完整信息,模型回答时还得拼多块,容易断章取义。实践中我首选基于句子的切分,以 token 数做上限控制。
我自己常用的初始参数是chunk_size=512、chunk_overlap=64。512 大概对应中文语境里 500 到 800 个汉字,足够覆盖一个完整技术点的说明;重叠 64 个 token,是为了避免关键信息恰好落在切分边界上被拆成两半。
from llama_index.core import Settings, SimpleDirectoryReader from llama_index.core.node_parser import SentenceSplitter Settings.embed_model = YourEmbeddingModel() Settings.llm = YourLLMModel() docs = SimpleDirectoryReader("./docs").load_data() node_parser = SentenceSplitter(chunk_size=512, chunk_overlap=64) nodes = node_parser.get_nodes_from_documents(docs)参数不是死的。如果是合同、论文这类段落逻辑很强的文档,可以把块调大一些,让每个节点尽量是一个完整的小节;如果是 FAQ、短问答这类碎片化文档,块调小反而更利于精准召回。我判断切得合不合适,会跑一个简单的抽检:随机挑 20 个节点,看它们是不是“切开后还能读懂”,以及“节点之间有没有明显重复”。
2.3 向量化与索引持久化:第一次建索引慢,之后要能秒开
向量化这一步,我踩过最大的坑是 embedding 模型前后不一致。今天用 A 模型建了索引,明天换 B 模型去查询,向量的维度或分布不一致,轻则报错,重则检索结果完全不可用。所以从第一天开始,我就在配置里把 embedding 模型名固定下来,并且把模型名写入索引缓存目录的名称里。
LlamaIndex 默认的索引可以持久化到磁盘,流程是“先看缓存目录有没有索引,有就直接加载,没有就重新建”。这样第一次跑要读文档、切块、向量化,后面每次启动都能跳过建库阶段。
import os from llama_index.core import StorageContext, VectorStoreIndex, load_index_from_storage index_cache = "index_cache" if os.path.exists(index_cache): storage_context = StorageContext.from_defaults(persist_dir=index_cache) index = load_index_from_storage(storage_context) else: docs = SimpleDirectoryReader("./docs").load_data() index = VectorStoreIndex.from_documents(docs, show_progress=True) index.storage_context.persist(persist_dir=index_cache)在首次建索引时我还会做一件事:记录一份“建库快照”,里面包含文档数量、节点数量、切块参数、embedding 模型名。将来文档更新了,我会换一个缓存目录或清掉旧缓存重新建,并对比新旧两版性能差异,而不是在原目录上不知不觉地覆盖数据。
3. 召回:从向量相似度到混合检索
3.1 向量相似度的局限:为什么有时候正确答案排不进去
向量召回擅长捕捉语义相关,但不擅长精确匹配。文档里的产品编号、报错码、函数名、化学式这类词,语义上很难用 embedding 表达,可它们恰恰是用户查询时最容易出现的词。比如用户搜“ERR-2047 是什么”,embedding 可能把问题理解成“错误码含义”,召回一堆通用错误处理说明,真正包含“ERR-2047”的那一页反而因为词频低、整体向量不突出而排在很后面。
这就是召回阶段不能只靠一路向量的原因。向量召回负责“语义找全”,关键词召回负责“字面命中”。两者结合,才能覆盖用户提问的两种常见路径:一种是描述意图但不带准确术语,另一种是直接报术语、编号、缩略语。
我在 LlamaIndex 里常用的做法是把VectorIndexRetriever和BM25Retriever组合成融合检索器。BM25 是经典的关键词检索算法,不需要额外训练,对短词命中非常敏感。
from llama_index.core.retrievers import ( BM25Retriever, QueryFusionRetriever, VectorIndexRetriever, ) vector_retriever = VectorIndexRetriever(index=index, similarity_top_k=8) bm25_retriever = BM25Retriever(index=index, similarity_top_k=8) fusion_retriever = QueryFusionRetriever( retrievers=[vector_retriever, bm25_retriever], similarity_top_k=5, mode="reciprocal_rerank", )QueryFusionRetriever会把各路结果做一个融合排序,默认的reciprocal_rerank模式对先后顺序比较稳定。我用它之后,最大的变化就是“精确术语类”的问题召回率明显上来了,而那些纯语义描述类的问题,也没有因为加入关键词检索而变差。
3.2 召回参数怎么调:Top-K、融合权重和查询改写
召回阶段我最常调的两个量:一是每路检索器的召回数量,二是融合后的最终候选数。如果最终进入重排的候选太少,比如只有 3 个,一旦召回本身有遗漏,重排再强也救不回来;如果太多,比如 20 个,重排和生成都会变慢,而且噪音会稀释真正相关的上下文。
个人习惯是先让每路召回 10 个左右,融合后保留 5 到 8 个,再交给重排。这样既给精排留了余地,又不至于把太多无关文本传给大模型。说得更直接一点:召回阶段的目的不是直接给答案,而是给下游留足“容错空间”。
还有一种常见情况是用户原始问题太短,比如只问“支付流程”,没有上下文说明从哪个模块、哪种角色视角出发。这时候再做一次“查询改写”会有明显帮助。可以让模型把原始问题扩展成更具体的检索子问题,然后再去跑融合检索。这个操作简单,但对模糊问题的效果提升非常明显,代价只是多一次模型调用。
3.3 怎么知道召回变好了:先做几十条最小可行评估集
调召回参数最大的风险,是凭印象觉得“好像变好了”。我后来给自己定了一条规矩:每次改召回策略之前,先准备一个最小评估集,至少二十条问题,每条问题标注好它对应的标准文档片段。然后跑一遍检索,看看正确答案出现在第几位。
不需要搞复杂指标,只用两个数就够:召回率@5和召回率@10,即正确答案出现在前 5 或前 10 里的比例。跑完记录到表格里,再改参数。你会发现“感觉”不靠谱,往往同一个改动对 A 场景有效,对 B 场景无效。
评估集的内容不在多,而在于覆盖典型查询模式。我在项目里会刻意放三类:一类是描述性问题,比如“订单超时是怎么处理的”;一类是精确术语,比如“某个配置项 CLIENT_ID 在哪里配置”;一类是模糊问题,比如“为什么会报错”。三类都能稳定召回,才说明召回层的基本盘是稳的。
4. 重排:把“看着像”变成“真正有用”
4.1 为什么召回之后还要再来一遍精排
很多人不理解,既然向量检索已经按相似度排过顺序,为什么还要单独做重排。原因在于:粗排用的双编码器模型,是把问题和文档分别编码成向量,再算相似度,这种方式速度快,但两个向量之间缺少细粒度的交互。而重排模型通常会让问题和文档做深度交互,判断“这段文本是不是真的能回答这个具体问题”。同样一句话,语义相近和“确实能解决问题”,是两回事。
举个例子,用户问“如何重置密码”,向量召回来的片段可能包含“密码安全规范”“密码重置按钮在哪里”“忘记密码后的三种方式”。从语义上看,这三段都相关,但论“回答这个具体问题”,第三种明显更有用。粗排很难区分这种差异,重排可以把更对症的文本排到最前面。
我通常会把重排视作生成质量的第一道保险。没有重排时,模型面前可能是 5 段“差不多相关”的文本;有重排后,模型面前是 3 段“对症”的文本。别小看这一步,生成结果的准确性和可读性都会有肉眼可见的差别。
4.2 在 LlamaIndex 里挂重排器的两种方式
一种方式是使用模型直接做重排。LlamaIndex 内置的LLMRerank会把候选节点批量送给模型,让模型按相关性排序并保留前几名。它不需要额外部署模型,适合候选量不大、重排时效要求不高的场景。
from llama_index.core.postprocessor import LLMRerank rerank_postprocessor = LLMRerank( top_n=3, choice_batch_size=5, )另一种方式接入本地开源重排模型。这类模型通常体积可控、中文效果好,推理开销也不算高。接入方式可以写一个自定义后处理器,把所有候选节点重新打分后返回排序结果。
from llama_index.core.postprocessor import BaseNodePostprocessor class LocalRerankPostprocessor(BaseNodePostprocessor): def _postprocess_nodes(self, nodes, query_bundle=None): scored = [] for node in nodes: score = my_rerank_model.score(query_bundle.query_str, node.get_content()) scored.append((score, node)) scored.sort(key=lambda x: x[0], reverse=True) return [node for _, node in scored]自定义后处理器的好处是灵活,你可以在里面加入自己的硬规则。比如:查询里带有某个产品型号时,强制把包含该型号的节点提到最前;或者过滤掉来自“常见问题汇总”这类低质量来源的节点。规则和模型可以一起作用。
4.3 重排参数:Top-N 不是越大越好,也不是越小越稳
重排后保留多少节点,我比较常用top_n=3。这个数字的考虑是:生成阶段需要的核心上下文往往就是两到三段,保留太少可能漏关键信息,保留太多又会让模型分心。选 3 个节点,同时要求这 3 个节点在重排前已经经过了混合召回的粗筛,既保证信息充足,又最大限度减少噪音。
还有一个容易忽略的参数:批次大小。调用模型重排时,如果一次丢进去太多候选节点,模型容易看花眼,评分变得不稳定。我自己习惯每次让模型只评 5 个左右,多批处理。虽然会多几次模型调用,但结果明显更可靠。
重排之后我通常还会加一道分数底线过滤,尤其是向量返回的相似度分数整体偏低时。可以用内置的相似度后处理器,或者在上面自定义处理器里加一个阈值判断:低于阈值的节点直接丢弃。原因很简单,重排看的是相对顺序,而阈值看的是绝对相关性,两者互补。
5. 生成:把答案讲清楚,而不是把资料堆出来
5.1 组装 QueryEngine:把检索和生成接到同一条链路上
前面几件事准备妥当之后,最后一步就是让模型基于精排结果生成答案。在 LlamaIndex 里,这一步通常用一个RetrieverQueryEngine把它们接起来。你可以把索引、检索器、后处理器全部交给它,也可以手动指定回答的组装方式。
from llama_index.core.postprocessor import SimilarityPostprocessor from llama_index.core.query_engine import RetrieverQueryEngine from llama_index.core.response_synthesizers import ResponseMode query_engine = RetrieverQueryEngine.from_args( retriever=fusion_retriever, node_postprocessors=[ SimilarityPostprocessor(similarity_cutoff=0.35), rerank_postprocessor, ], response_mode=ResponseMode.COMPACT, ) response = query_engine.query(query)这里有个细节值得展开:response_mode。默认情况下,模型会直接把所有候选节点拼进 prompt,节点一多,提示词很容易超长,同时模型也会忽略掉真正重要的部分。COMPACT模式会先把内容做一次压缩再交给模型,减少上下文浪费;TREE_SUMMARIZE则会把多段内容逐层概括。对大多数问答场景,我喜欢先用COMPACT,因为它信息损少、响应快;在需要综合多份文件给出结论时,再改用需要多步概括的模式。
5.2 提示词不要只写“请回答”,要加边界约束
生成阶段最常见的浪费,是让模型自由发挥。它没有被告知只能使用给定资料,于是凭自己的记忆补充常识,一旦文档与模型记忆冲突,答案看起来流畅但实际上是错的。我给生成阶段的提示词定了三条硬规矩:只能基于给定上下问回答、资料不足时直接说不足、回答后标注来源节点序号。
在 LlamaIndex 里可以自定义回答提示词模板,控制生成的时候把模板换进去。我常用的模板思想是:
from llama_index.core import PromptTemplate text_qa_template = PromptTemplate( "以下是检索到的资料片段:\n" "{context_str}\n\n" "请只根据以上资料回答下列问题,不要使用资料之外的内容。\n" "如果资料不足以回答,直接回答“资料不足”。\n" "问题:{query_str}\n" "回答:" )“资料不足时直接说不足”这条非常重要。它让系统敢于承认不知道,而不是硬编一个答案。很多项目为了追求回答率,强行让模型生成,结果就是幻觉比例飙升。我宁可在评测里多一条“unknows”,也不希望生产环境出现一本正经的胡话。
5.3 给重复提问加缓存:别让同一个问题反复烧钱
生成模型每次调用都有成本,但用户的问题其实高度重复。有人问“怎么重置密码”和“密码忘了怎么办”,虽然字面不同,语义接近,连接下来的回答也很接近。这时我建议在生成层加一层缓存。
做法很简单:第一次回答后,把“规范化后的问题 + 答案 + 来源节点集合哈希”一起存进本地缓存库;下次查询先算规范化问题,再算语义相似度或直接哈希命中。对完全一致的问题,直接返回缓存答案;对高度相近的问题,如果相似度超过阈值,也可以直接复用答案,但最好在回答里标注“来自相关历史回答”。
加了缓存之后,不仅省钱,还能让系统响应更快。不过要注意缓存失效的问题:当文档更新后,必须让涉及该文档的历史缓存失效,否则用户会一直看到旧答案。我的做法是把版本号加进缓存 key,文档一变,旧缓存自动作废。
6. 实战中的问题排查与调优速查表
6.1 高频报错速查:先看表象,再找根源
沿着这条链路跑久了,会遇到不少重复性报错。我整理了一份常用的排查表,基本能覆盖八成问题。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 检索阶段报维度不一致 | 建索引和查询用了不同的 embedding 模型 | 固定 embedding 模型,检查索引缓存 |
| 加载文档时出现大量空节点 | PDF 提取失败或文件已损坏 | 单独解析该文件,换解析器,观察原文件 |
| 模型回答与资料不符 | 提示词缺少约束,或上下文里有效信息太少 | 改提示词,先检查重排后的节点 |
| 响应速度很慢 | 候选节点太多,或重排次数太多 | 减少 Top-K,缩小重排批次 |
| 同一问题两次结果不同 | 模型温度过高,或检索结果不稳定 | 降低温度,固定随机种子,检查索引是否被覆盖 |
| 索引文件越存越大 | 每次运行都重新建索引并追加 | 检查缓存目录逻辑,重复使用已有索引 |
遇到问题,我坚持一个原则:永远从前端往后查。先看用户问题进入系统后,检索器到底召回了什么,再看重排之后剩下什么,最后才看模型生成了什么。大多数“回答不对”的根因,根本轮不到模型背锅。
6.2 我养成的七个调优习惯
调优这件事没有玄学,本质是控制变量。基于我用 LlamaIndex 做项目的经验,这七个习惯帮了我大忙。
- 固定一个小而全的评价集,二十条问题起步,覆盖语义问题、精确术语问题、模糊问题,每次改动都跑一遍。
- 先确认召回层有没有问题,再动提示词。如果正确答案根本没进候选,提示词写得再好也白搭。
- 一次只改一个变量。很多人同时调了 chunk、权重、Top-K、prompt,最后答案变好了,但不知道是谁的功劳,基线也因此作废。
- 每次改完记录结果,格式是“参数变化 + 召回率@5 + 召回率@10 + 示例答案”。不靠记忆,靠记录。
- 锁定依赖版本。LlamaIndex 升级频率高,接口变来变去,项目里必须固定版本号,避免线上无意间升级。
- 把失败样例留档。每次出现“答非所问”,把当时的问题、检索结果、重排结果存下来,这才是最有价值的数据库。
- 定期重建索引并检查效果。文档会更新,embedding 模型会变,索引也要有版本和重建节奏。
6.3 一点个人体会
我最初也是从 demo 跑通直接跳到上线,结果发现自己根本没有回答“模型为什么这么答”的能力。后来重新把重心压到建库和召回这两个环节上,答案质量反而稳定了不少。LlamaIndex 对我来说最大的价值,是它让我能在不重构代码的情况下,单独替换某一个环节做对比实验。这种可替换性,比某一个模型效果领先几个点重要得多。如果你也打算做更复杂的 RAG,我的建议是从这条最简单的主链路开始,把每个环节的输入输出都吃透,再往上加知识图谱、Agent、多轮记忆,路会顺畅很多。