☰
RAG实战:从文档索引到混合检索与生成约束的完整链路
2026/10/14 7:01:20 网站建设 项目流程

第一次正经做 RAG(Retrieval-Augmented Generation,检索增强生成)相关的项目,是被一个内部知识库问答需求逼上山的。某公司想把几百份产品文档、售后工单和技术规范整理成一个问答机器人,把客服那边重复到快麻木的"这个报错怎么处理""那个字段怎么配"问题分流掉。刚开始我想偷懒,直接拿通用大模型 API 硬上,结果非常惨——模型能把不存在的产品型号编得有模有样,甚至给出一段看起来完全合理、但实际不存在的配置命令。后来老老实实把 RAG 链路搭起来,才明白一句话:RAG 不是把文档"塞"给模型,而是把文档切成可检索的知识片段,在回答前先通过检索把最相关的证据找出来,再让模型基于证据生成。

这篇内容适合两类人:一类是刚接触 RAG、想系统理解整套链路(离线索引、在线检索、生成拼接、评估闭环)的开发者;另一类是已经跑通了一个 Demo,但发现检索不准、答案有幻觉、上线后效果不稳定的同学。下面我不按教科书顺序讲,而是按照真正动手搭建时会遇到的顺序来展开,每一步都带上我实测过的参数、踩过的坑和取舍逻辑。

1. 为什么偏偏是检索增强:大模型知识的边界与 RAG 的破局方式

1.1 大模型的"知识截止点"与私有知识盲区

任何一个预训练模型,知识上限都固定在其训练数据采集的时间点。训练完成后,模型对新产生的政策文档、内部系统变更、某私有平台特有的报错信息一无所知。更麻烦的是,越是垂直行业的私有知识,越不可能出现在公开语料里。

我在那个客服项目里遇到过最典型的情况:售后工单里有一种特殊的错误码,只在某个内部版本里出现过。模型没有见过这个错误码,被问到时就开始"合理推测",一本正经地编造了一个不存在的故障原因。这不是模型笨,而是它的知识库里根本没有这个信息。预训练模型本质上是在做"按概率接话",没有证据约束时,概率上最顺的话就会被生成出来。

1.2 幻觉的本质:生成过程缺少"证据约束"

很多人觉得幻觉是模型质量不行,换个更大的模型就能解决。实际上,大模型在"编造细节"这件事上,模型越大,编得越流畅,越难被察觉。原因在于生成流程是自回归的,每一步都在最大化下一个 token 的概率,并没有一个机制去校验"我当前说出来的内容是否真的有出处"。

RAG 的破局方式不在生成端硬堵,而在生成之前先把答案空间收敛。通过检索,把原本无边无际的概率分布,约束到与用户问题强相关的几个文档片段上。模型看到的上下文里已经包含了正确信息,它再做摘要和重组,编造的空间被大幅压缩。我曾经做过一个对比实验:同一批问题,无检索直接答,幻觉率大概是三成;接上 RAG 之后,在没有针对性调 Prompt 的情况下,幻觉率就能降到一成以内。这个差距就是"证据在场"与"证据缺失"的差距。

1.3 微调与 RAG 的取舍:不是二选一,而是分工

做这个项目时有人问我:为什么不直接微调一个模型?我把理由整理一下。微调的本质是把知识"烘焙"进权重里,但存在三个硬伤:

  • 更新成本高:文档每天在变,每变一次就重新训练一次,工程上不可接受。
  • 知识容量有限:一次微调能稳定注入的知识量级有限,塞几百份超长文档进去,很容易学到后面忘前面。
  • 可解释性差:微调后的回答很难追溯,用户问你"这个结论哪来的",你没法给出文档出处。

RAG 的优势在于知识外置、按需取用。文档更新只需要重新索引,回答时可以明确标注"依据哪份文档的哪一节"。我后来甚至见过一些项目把两者结合起来:RAG 负责提供事实和依据,微调负责适配问答风格和输出格式。这是后话,但对大多数团队来说,第一优先永远是 RAG,微调只有在你确实需要改变模型的表达习惯时才值得做。

2. 离线索引层的工程化:从原始文档到可检索的向量片段

RAG 链路里最先要解决的是"离线索引"问题。你得把一堆乱七八糟的原始文档,变成计算机可检索的结构化片段。这一步做不好,后面检索再花哨也没用。我见过太多团队把大量精力花在调模型上,最后发现根因是文档解析阶段就把信息弄丢了。

2.1 文档解析:最常见的失败点不在"切分",而在"读不完整"

原始文档格式五花八门:PDF、Word、Markdown、扫描件、PPT。解析阶段核心目标只有一个——把人类可读的内容完整、准确保留成纯文本。

最容易踩的坑有三个:

  • PDF 的表格和多栏排版:普通解析库遇到两栏论文或复杂表格,经常把内容按从左到右的物理顺序提取,导致表格跨栏错乱、语义断裂。我试过一份产品参数表,解析出来之后 A 列和 B 列的内容混在一起,检索时每次命中都是错的。
  • 页眉页脚污染:每个 PDF 都有页眉、页码、公司 Logo 说明文字,不清理掉就会混进正文里,被当成正经内容编码进向量库,检索时噪声极大。
  • 扫描件:必须接 OCR(光学字符识别),而且 OCR 识别错误没法完全避免,尤其是中文标点和类似字形的字符,后面需要在清洗阶段处理。

实操建议:PDF 优先用基于版面分析的解析工具,而不要用简单的文本抽取库。表格解析要单独验证——拿三份真实表格文档,肉眼核对解析结果,确认单元格内容没有串列。清洗阶段统一去掉页码、页眉、页脚、连续换行和多余空白。对 OCR 文档,我建议在清洗后做一次简单的"错别字高频词"替换,比如数字 0 被识别成字母 O 这类。

2.2 分块策略:块大小直接决定检索精度

分块是 RAG 的命门之一。块太大,单个片段包含的信息多,但噪音也多,检索回来命中不够精准,而且很费 token;块太小,单个片段语义不完整,检索回来内容碎片化,模型无法理解。

我一开始用定长字符切分,每 512 个字符一块,无重叠。实测效果很差:一段话可能被从中间切开,后半段内容没了上下文;有的块正好横跨两个章节,语义混杂。后来改成按语义边界递归切分,按文档结构层级(标题、段落、句子)逐级往下切,优先保完整语义单元,块目标大小设为 400 字符左右,然后允许上下浮动。

一个具体案例:某产品文档里有一段描述"当温度超过 85 度时系统自动降频",定长切分可能把这句话拦腰截断,"当温度超过"在上一块,"85 度时自动降频"在下一块。按语义切分则会把整句保留在同一块内,检索时命中率和上下文完整性明显提升。

除了切分方式,重叠窗口也很重要。我会在相邻块之间保留 30 到 60 个字符的重叠,防止恰好落在边界的语义内容被整体丢弃。伪代码大概是:

def recursive_chunk(text, max_chunk_size=400, overlap=50): # 先按标题结构拆,再按段落拆,最后按句子拆 units = split_by_heading_or_paragraph(text) chunks = [] buffer = "" for unit in units: if len(buffer) + len(unit) > max_chunk_size: # 先加入带重叠的块 chunks.append(buffer) buffer = take_tail(buffer, overlap) + unit else: buffer += unit if buffer.strip(): chunks.append(buffer) return chunks

分块参数没有绝对标准,取决于你文档的类型和检索的粒度。原则是:如果答案通常是一句话就能说清楚的,块可以略小;如果答案是某个流程或方案段,块要保证该流程完整。上线前拿真实问题做几轮抽样,观察命中的块是否包含答案核心,再反推调块大小。

2.3 Embedding 选型与向量检索基础

分块完成后,每个片段要喂给 Embedding 模型,变成一个向量。选 Embedding 模型的几个关键考量:

  • 中文效果:有些通用 Embedding 模型在英文上很好,中文却一般。要专门看中文语义相似度 benchmarks,或者自己构建一个领域小测试集来比。
  • 维度与成本:向量维度高,信息粒度更细,但存储和计算成本更高。实际项目里常用维度几百到上千。
  • 输入上限:Embedding 模型通常有 token 上限,分块不能超过这个限制,否则会被截断,信息缺失。

Embedding 出来后,需要存进向量数据库。单机跑测试用简单的numpy暴力相似度就能做,但数据量一上来必须上向量库。核心索引算法无非两类:HNSW(分层可导航小世界图)和 IVF(倒排索引)系。HNSW 检索快、召回好,但内存占用偏高;IVF 更省资源,但召回略弱,需要合理设定聚类数。

实操中有一个被忽略的点:相似度度量方式。常见的余弦相似度和内积在向量归一化后是等价的,但如果你选的内积模型没有对向量做归一化,结果就会偏。我建议统一在归一化之后用余弦相似度,并在索引建立时和写入时保持一致,避免离线评测和线上效果对不上。另一个点:Metadata 过滤。向量检索前先用文档类型、部门、时间范围等元数据做过滤,再在剩余集合里做距离计算,能大幅减少无关命中和计算开销。比如客服项目里,用户问的是售后问题,就先过滤掉研发规范类文档,这才不会被不相关但有语义重叠的内容干扰。

3. 检索层的调优空间:混合召回、重排序与查询改写

索引建好之后,真正的差距开始在检索策略上拉开。一个朴素实现是:query 转向量,在向量库里找 topK 个相似片段,拼进 Prompt。实测很快会发现:纯向量检索常常"语义沾边但关键词不中",尤其面对产品型号、错误码、专有名词时。

3.1 混合检索:稀疏召回与稠密召回的互补

稠密向量检索擅长捕捉语义,但有个明显弱点:它会把"ABC-1000"这种型号名和一个语义相近、但完全不是这个型号的句子关联起来,因为它理解的是整体语义而不是字面精确匹配。此时需要引入稀疏检索——BM25 这类传统关键词匹配。BM25 对精确词和短语的匹配召回非常强,两者互补。

工程上常用的融合方式是 RRF(Reciprocal Rank Fusion,倒数排名融合),思路很简单:对同一个 query,分别用稀疏和稠密各取一批结果,然后按排名倒数计算融合分,把双方的 top 结果按排名交叉合并。

def merge_with_rrf(sparse_hits, dense_hits, k=60): scores = {} for rank, hit in enumerate(sparse_hits): scores[hit.id] = scores.get(hit.id, 0) + 1 / (k + rank) for rank, hit in enumerate(dense_hits): scores[hit.id] = scores.get(hit.id, 0) + 1 / (k + rank) return sorted(scores.items(), key=lambda x: x[1], reverse=True)

这个做法的好处是不需要调权重的比例,RRF 是归一化的排名指标,天然适用两个尺度完全不同的检索器。实测混合检索相比纯向量,命中率能提高 10 到 20 个百分点,主要增益就来自精确匹配场景。

3.2 重排序:召回多、精排准,两阶段各司其职

召回阶段讲究高召回、不可漏检。但召回结果里会有不少"看着相关、实则不相关"的片段。比如用户问"重启后配置丢失怎么办",召回结果里可能有一条只是顺带提到"重启"这个词,但核心在讲日志。此时需要重排序模型(Reranker)做精排。

Reranker 用的是交叉编码器结构,query 和候选片段一起送入模型,直接计算相关程度分数。相比双塔式的 Embedding,它的计算量更大,但精度更高。工程实现上就是两阶段:先召回 30 到 50 个候选,再用 Reranker 取 top5 到 top10。

实操中效果最明显的调整是把"召回 10 个直接喂给模型"改成"召回 50 个、重排后取 top5"。这在一次内部评测中,把最终回答的正确率提高了约 8%。原因很朴素:候选池更大,Reranker 有更多好内容可选;而如果候选池只有 10 个,即使 Reranker 排得准,选择空间也摆在那里。

3.3 查询改写与多路召回

有个容易被忽略的事实:用户的提问方式往往不适合直接检索。典型问题包括指代不明、问题过短、口语化表达。比如用户问"那个重启的的问题解决了吗",这个 query 里适合检索的主题词只有"重启"。这时候让大模型把用户问题改写成适合检索的形式,或者生成多个子查询,多路召回后再合并,效果会有明显提升。

我实际用过一个简单的流程:先判断 query 里是否包含足够多的专有名词,如果太少,就让一个轻量模型做改写或扩写,生成 2 到 3 个检索 query,分别检索再进行 RRF 融合。还有一个相关技巧叫 HyDE(假设性文档嵌入)——先让模型生成一个假设答案,再用这个假设答案去检索。这样做在某些场景下有效,但要注意:如果 LLM 生成的假设答案是错的,会把检索带偏,不是银弹,需要对照测试。

综合下来,一个可靠检索层配置是:

  • 第一层:BM25 与向量检索并行,候选 50 条。
  • 第二层:RRF 融合,保留 top30。
  • 第三层:Reranker 精排,取 top5 到 top8。
  • 进入生成前,按 rerank 分数降序排列片段。

4. 生成层的约束机制:Prompt 组装、引用溯源与拒答兜底

检索做好了,不等于模型会老实回答问题。生成层是 RAG 链路的最后一公里,这里的问题通常出在"上下文组得对不对"和"模型是否受到了足够的约束"。

4.1 Prompt 组装:别把所有片段都塞进去

一个常见错误是不管三七二十一,把召回的所有片段全扔进 Prompt。要知道,上下文窗口是有限的预算,片段越多,无关内容越多,模型越容易分心,还白白拉高成本。

Prompt 组装有几个实操要点:

  • 只保留高相关片段,控制总 token:我一般把检索片段的总 token 控制在上下文窗口的 40% 到 50% 左右,剩下留给系统提示词、历史对话和模型输出。
  • 片段顺序按相关度降序:虽然模型是全局注意力,但离用户问题越近的地方往往利用率越高。把最强相关的片段放最前面或最后面,都比揉在中间好。曾做过对比:把最相关片段放在开头,回答正确率比随机顺序高出一截。
  • 明确标注每个片段的来源文档:让模型知道"这段来自《XX 操作手册》第 3 章",帮助它做交叉参考。
  • 系统提示词里说明游戏规则:不是直接给规则,而是明确"你只能依据给出的资料回答,资料不足时直接说不知道,不要编造"。

4.2 引用溯源与可解释性

客户场景里有个特殊需求:模型不能只给一个答案,必须说明依据。于是我在每个片段里都保留了文档 ID、标题和原文摘要,并在 Prompt 中要求模型在回答中的关键结论后面标注引用编号,例如[1]对应第一个检索片段。

实施后效果很好:一方面,模型的回答有据可循,幻觉率下降了;另一方面,用户看到引用后对答案的信任度明显提高。要让引用真实,必须明确告诉模型:不要编造引用编号,只能引用上下文里真实存在的编号。即使模型回答不了问题,也不许强行挂一个不存在的引用。

4.3 拒答与兜底策略:宁可说不知道,不要瞎说

在内部客服场景里,瞎说比说不知道的危害大得多。但模型被问到一个检索不到答案的问题时,天生倾向于强行回答。怎么让它学会"拒答"?

一个有效手段是相关度阈值。检索片段平均分数低于阈值时,直接返回预设的兜底话术,比如"我暂时没有在该知识库中找到相关答案,建议转人工处理"。另一个手段是 Prompt 中给模型"承认无知"的合法性,明确写出:"如果提供的资料中找不到答案,请直接回答'资料库中没有相关信息',不要推测或编造。"

阈值怎么定?不要靠拍脑袋。我在上线前专门构建了一个"应拒答问题集"和"应回答问题集",然后扫描不同阈值下两者的通过率,找出平衡点。目测经验值:归一化分数在 0.55 到 0.65 附近浮动,具体取决于你的检索器分布。注意一点:这个分布可能会随着文档更新而漂移,所以阈值也要定期重校。

5. 评估一个 RAG 系统:指标设计、失败模式与回归集

很多项目前面做了半天,却说不清"我的 RAG 到底比裸模型强在哪"。RAG 评估难就难在它不是一个模型,而是一条链路。检索错了,生成再好也无用;检索对了,生成也可能答非所问。所以评估必须拆开来看。

5.1 评估链路分段:检索质量与生成质量分开测

我实践的方案是定一套双层评估指标:

  • 检索质量:看最终采用的重排结果里,是否包含能正确回答用户问题的片段,以及这些好片段的排名是否靠前。常用指标是 nDCG(归一化折损累积增益)和 Recall@k。
  • 生成质量:看模型在给定检索片段后,是否忠实于片段内容作答,没有编造。常用维度是忠实度(faithfulness)和答案相关性(answer relevance)。

忠实度是幻觉问题的直接度量。实际操作中,我会随机抽 50 到 100 条测试 query,人工判断生成回答的每一句是否都能从前面对应的检索片段中找到支持。有一条找不到支持,就算一次幻觉。测试集不需要很大,但必须覆盖不同难度:常见问题、模糊问题、跨文档问题、应拒答问题。

5.2 失败模式定位:问题到底出在哪个环节

当系统回答错误时,不要急着调参数。先做定位。我通常按下面顺序排查:

  • 检索环节:把最终进入 Prompt 的片段打印出来。如果片段本身就不对,是召回没召到,还是重排把对的排下去了?分别看召回 top50 和重排 top5 的差异。召回没召到,问题在分块或 Embedding;重排排错了,问题在 Reranker。
  • 生成环节:如果片段正确,但答案错了,是 Prompt 约束不够,还是模型没读懂片段?把片段直接扔给模型让它做摘要,如果摘要都做不好,说明模型对长上下文的利用能力弱,可能需要调 Prompt 或减少片段数量。
  • 片段冗余:如果多个高度相似片段同时进入 Prompt,模型容易被带偏,需要做片段去重或 MMR(最大边际相关性)控制多样性。

5.3 持续回归:把评估做成自动化,而不是一次性任务

RAG 系统是不断演进的,文档会更新,用户的提问模式会变化,Embedding 或 Reranker 可能被替换。没有回归测试,任何一次调整都可能是开盲盒。

我在项目中落地了一个轻量回归机制:固定一套覆盖各项能力的测试集,每次改动(换模型、调参数、改分块策略)后跑一遍,记录检索指标和生成质量指标,对比历史结果。这套测试集不需要很大,80 到 150 条即可,关键是覆盖完整。跑一次可能需要几分钟,但换来的是"每次改动的效果可感知"。用 LLM 自动打分做初筛,人工抽检校准,性价比很高。

6. 生产环境里的 RAG:延迟、更新与成本的取舍

最后聊上线。实验室里跑通和线上稳定运行,之间差了一整层工程思维。

6.1 延迟拆解:检索作答各占多少

RAG 查询的延迟主要由三块构成:检索耗时、重排耗时、生成耗时。实际观察到的典型数值(以中等规模知识库、通用 GPU/CPU 服务为例):

环节常见耗时优化空间
向量检索(HNSW,百万级索引)20 到 80ms加缓存、换更稳定的索引参数
Reranker(cross-encoder)50 到 150ms减小候选集、用小模型
LLM 生成1 到 5 秒控制输出长度、更换更快的服务

生成是大头。如果用户体验要求秒级返回,可以从这几点挤时间:输出长度上限控制,答案不是越长越好;检索候选缓存,高频 query 直接缓存最终 top 片段结果,连检索带重排都省了;异步预取,当检测到用户正在输入时先做一次粗查询,但这需要小心处理权限与历史上下文。

6.2 索引更新:全量重建还是增量追加

知识库不是静止的。每周都有新文档、旧文档废除。处理更新有两种思路:

  • 增量更新:新文档切块、Embedding、写入向量库;废弃文档按 ID 删除。适合变化频繁的场景。
  • 定时全量重建:每天凌晨重建一遍索引。适合文档量不大、变化集中的场景,实现简单,不容易出错。

实操建议:即便采用增量更新,也要定期(比如每周)做一次全量重建,用来清理那些由于文档修订产生的残留脏数据。删除逻辑要谨慎:如果一个文档被更新而不是新增,老版本的向量块和陈旧的元数据都要同步处理,否则会出现"新旧版本并存、检索结果自相矛盾"的诡异情况。

6.3 成本控制:token 消耗与重排开销

RAG 的成本常常被低估。它不只是 Embedding 那一次费用,还包括每次查询时把检索片段喂进大模型的生成成本。为了把成本压到可控:

  • 认真做重排:把候选从 50 条压缩到 top5,表面上多花了一次 Reranker 调用,实际省掉了大量喂给 LLM 的无效 token。这是一个很容易忽略的杠杆。
  • 控制检索片段总长:我的经验是按"每个片段 200 到 300 token"的量级控制,每次只取 4 到 6 段,总 token 控制在 1500 以内,配合系统提示词和对话历史,既能保证答案质量,又不至于烧钱。
  • 缓存高频问答:把用户问过且质量高的回答缓存起来,命中缓存就不走完整链路。客服场景的重复性问题比例不低,缓存收益明显。
  • 对简单问题降级:如果 query 明显是"文档里某句话就能回答",可以直接走检索片段摘要,强制模型输出短答案,节省大量生成时长和费用。

我做这个客服问答项目时,最深的感触是:RAG 不是装好开源组件就能跑出效果的,它是一条完整的数据链路。从一个粗糙的"向量检索 + Prompt"起步,到把分块策略、混合召回、重排、引用溯源、拒答阈值、自动化评估全部串起来,最终上线后的大多数回答质量才真正让人放心。

最后分享一个调试技巧:给系统加一个"调试模式",把每次查询的实际检索片段、重排分数、最终 Prompt 都打印出来。这比任何指标都直观,能快速定位"是没找到、找错了、还是答错了"。我也是被连续几轮诡异的回答折磨之后,才意识到光看最终输出根本无从下手,把链路摊开之后,很多问题都变得一目了然。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询