做知识库问答这类项目,很多人的第一反应是把整份文档直接塞给大模型,让模型“读完全部再回答”。实际做一次就会发现,长文档要么把上下文窗口撑爆,要么关键信息淹没在大段无关注释里,模型回答得又快又自信,却和原文对不上。后来我做知识库这块业务,几乎全部改成了RAG——你问一个问题,程序先去文档里把相关段落捞出来,再把“问题+检索片段”一起交给大模型生成答案。整个过程像开卷考试时先翻到指定页码,再照着内容作答,模型不需要记住所有细节,只需要根据当场翻到的证据给出回答。
这篇文章就是围绕“RAG生成(大模型)”这条技术路线,把我从选型、切分、检索、Prompt设计到上线排错的经验完整梳理一遍。内容包括三种RAG范式怎么选、文档切块参数怎么定、混合检索和重排序的必要性、以及一个可以直接照抄的最小知识库问答实现。无论你是刚接触检索增强生成,还是已经搭过一套原型但效果不理想,这篇文章都能给你一套能落地的参考方案。
1. RAG的核心思路与方案选型
1.1 检索增强到底在增强什么
RAG(Retrieval-Augmented Generation,检索增强生成)听起来是个新概念,本质上解决的是大模型“不知道”和“记不住”的问题。通用大模型训练时看到的是公开语料,你让它回答公司内部制度、某本手册里的具体数值、或者某个私有系统的操作流程,它只能靠训练语料里的泛化知识硬编。这种硬编有两个致命弱点:一是知识存在时效性,训练完就定格了;二是模型会用“合理的语气”补出不存在的事实,也就是幻觉。
RAG的思路很直接:在生成之前加一个检索环节。用户提问后,系统先把这个问题和预先切好的文档片段做语义匹配,找出最相关的若干段落,然后把“用户问题+检索段落”一起拼接成Prompt,交给大模型生成最终答案。模型不再凭记忆回答,而是基于给定的证据作答。这个机制把“让模型记住”的难题,转化成了“让模型找到并引用”的工程问题,后者是可以用算法和工程手段解决的。
我最早接触RAG是处理某公司的内部运维知识库,几千份工单和操作手册,格式混乱、表述不一致。一开始试过直接把所有资料喂给模型做微调,效果差不说,每周新增的工单根本追不上。后来改成RAG之后,同样的问题,模型会在检索结果里找到对应的工单编号和操作步骤,再组织语言输出,准确率明显提升,幻觉也减少了很多。这也是为什么RAG成了目前大模型落地知识类场景的主流方案。
1.2 三种RAG范式如何选
RAG不是单一技术,而是方法论。根据工程复杂度,业内一般分成三档:
- 朴素RAG:检索→拼接→生成,一条直线下来,是入门最快、问题也最明显的方案。文档切分粗糙、检索召回质量不稳定、Prompt里经常混入不相关内容,答案质量完全取决于检索结果。
- 高级RAG:在检索前后加了一系列优化。检索前做Query理解、改写、扩展,检索时用混合召回和多路召回,检索后加Rerank重排序、过滤、去重。工程上多花了一些功夫,但召回准确率和答案质量会明显上一个台阶。
- 模块化RAG:把整个流程拆成独立模块,比如检索模块、缓存模块、记忆模块、路由模块、任务分解模块。模块之间可以自由组合,甚至允许迭代式检索、递归检索等复杂流程。灵活性最高,但也最考验架构能力。
做实际项目时,我的建议是不要一上来就上模块化RAG。绝大多数知识库问答场景,高级RAG已经是投入产出比最高的档位。先跑通朴素RAG,找到效果瓶颈在哪一环,再针对性地加优化,比盲目堆模块要靠谱得多。
| 范式 | 核心流程 | 适用场景 | 工程成本 |
|---|---|---|---|
| 朴素RAG | 检索 → 拼接 → 生成 | 原型验证、个人项目 | 低 |
| 高级RAG | Query优化 → 混合检索 → Rerank → 生成 | 生产级知识库问答 | 中 |
| 模块化RAG | 多模块编排、路由、递归、记忆 | 复杂业务、多跳问答 | 高 |
1.3 RAG和微调、长上下文怎么配合
有人会问:模型上下文窗口现在越来越大,直接把文档全塞进去不就好了,为什么还要RAG?这个问题我踩过坑。上下文窗口变大确实缓解了“装不下”的问题,但“装得下”不等于“找得到”。我实测过给模型塞一份几百页的说明书,问一个埋在章节深处的参数,模型经常答错,因为注意力分配被大量无关内容稀释了。这就像给你一本词典,你能查到某个词,但如果你必须从头到尾读完才能回答,效率会低得多。
RAG和微调也不是二选一的关系。我个人的理解是:RAG负责给模型提供“当下的、私有的、动态变化的知识”,微调负责让模型学会“某种特定的表达风格、输出格式、专业术语习惯”。比如你需要模型用固定模板输出故障工单摘要,那微调更合适;但如果你要的是“根据最新制度文件回答员工问题”,RAG是唯一正确选择,因为制度文件每周都在变。
真正的陷阱在于把RAG当成万能药。有些场景,比如模型需要执行复杂的工具调用或结构化推理,单纯靠RAG给资料是不够的。这时候需要把RAG和工具调用、任务拆解组合起来,让模型先把问题分解,再用多轮检索获取不同的证据片段。这种组合式用法,其实就是模块化RAG的雏形。
2. 文档切分与索引构建
2.1 切分参数的选择逻辑
RAG的检索质量,第一根柱子是文档切分。切分就是把长文档切成小块,向量化之后建立索引。切分看起来简单,里面的门道却不少。块的大小直接决定检索粒度和语义完整性的平衡。
块太小,比如每块128字符,检索到的片段往往只有半句话,语义被截断,模型根本看不出上下文;块太大,比如每块2000字符,虽然语义完整了,但一个块里包含了好几个主题,用户的某个问题只能匹配到块的一部分,生成时噪声极大。我做过一组对照实验,同一批问答数据,chunk_size从128调到2048,检索命中率先涨后跌,峰值出现在512左右。
切分时还有两个关键参数:overlap和分隔符。overlap是相邻块之间的重叠长度,一般设置为chunk_size的10%到20%。它的意义是避免一个完整的语义单元被切分边界“腰斩”,比如一段代码中间被切断,或者一句话被拆到两个块里。分隔符则决定了切分时优先在哪些位置断开。按换行符、句号、问号分段,比纯按字符数硬切要好得多;如果文档本身就是结构化的,比如Markdown或者HTML,优先按标题层级切分,效果提升非常明显。
还有一个容易忽略的细节:表格和代码块。普通文本切分算法遇到表格会把它拆得七零八落,遇到代码块会把注释和代码分开。我处理文档时会先做预处理,把表格转换成带表头的结构化文本,代码块单独成块并保留语言标签。这些细节单独看都很小,叠加起来对检索质量的提升非常可观。
2.2 向量化模型怎么选
切好的文档块要转成向量才能做语义检索。Embedding模型的选择,直接影响匹配效果。市面上的开源向量模型不少,比如bge系列、m3e、text2vec等,各有各的侧重点。谈选型之前,先说清楚评估维度:维度数(决定存储和计算开销)、最大输入长度(决定单次能处理的文本长度)、语义能力(对中文、领域术语的理解程度)、检索性能(在标准数据集上的Recall@K)。
我的实践经验是,偏向中文知识库场景,优先选择在中文语料上预训练过的模型,而不是直接用通用英文向量模型跑中文。很多英文模型对中文的语义理解会“降智”——明明两个句子说的是同一件事,语义向量就是匹配不上。另外,Embedding模型的选择要和检索方式配套。如果只用向量检索,那向量质量是命根子;如果做混合检索(向量+关键词),向量模型的压力会小一些,因为关键词召回已经兜底了一部分。
实际项目中,我在选型时会用一组业务真实问题做离线评测,而不是只看公开榜单分数。榜单分数高,不一定代表你的领域术语匹配得好。把20个典型问题拿去检索,看Top-5里有没有正确文档块,这个人工评测比任何指标都直观。
2.3 向量数据库与父子分块策略
向量化之后的索引存放在向量数据库里。开源工具里faiss是个轻量级库,适合单机小规模;qdrant、milvus这类是完整的向量数据库服务,支持元数据过滤、混合检索和水平扩展。选型考虑三点:数据量级、并发量、是否需要与已有存储系统联动。一般中小型项目,faiss加上内存索引就够用了;数据量上千万级、需要频繁更新和复杂过滤,再上服务化数据库。
索引构建环节有一个容易被忽视的高级技巧,叫作父子分块(Parent-Child Chunking)。核心思路是:检索时用小块(比如256字符),保证召回粒度精细;拼接上下文时用大块(比如2048字符),保证语义完整。做法是先把文档按大块切好,再把每个大块细分成多个小块,小块向量化用于检索,命中的小块回溯到它所属的大块,把大块内容作为上下文交给模型。这个策略对长文档问答特别有效,我在处理产品手册时实测,答案完整率比普通切分高了将近三成。
再补一个索引构建时的细节:元数据。给每个块打上来源文档、章节标题、页码、更新日期等标签。检索时先用元数据过滤缩小范围,再在结果里展示引用来源。这一步不只方便追踪溯源,还能在检索结果包含多个文档时,自动过滤掉过期版本。
3. 检索链路优化
3.1 混合检索:关键词与向量互补
向量检索擅长语义匹配,但也有明显短板:对专有名词、精确ID、编号和缩写不太敏感。比如用户问“AX-200故障码”,如果文档里写的是“AX200异常码”,向量模型不一定能匹配上,但BM25关键词检索一定能命中。反之,用户换个说法描述同一概念,比如“设备启动后反复重启”,关键词检索可能匹配不到含“重启”的同义表述,向量检索却能抓住语义。这两者正好互补,所以生产环境强烈建议做混合检索:BM25和向量检索并行,各自取Top-N,再合并去重。
合并之后的相关性排序是个问题。BM25给出的分数和向量相似度不是同一量纲,直接相加没有意义。我的做法是分别做Min-Max归一化,再按权重加权求和,权重初始设0.5和0.5,根据业务调优。如果业务里精确匹配的场景多,就把BM25权重调高;如果用户提问偏口语化、同义表述多,就偏向向量权重。
3.2 Query改写与扩展
用户的问题往往不像搜索引擎关键字那样规整,特别是口语化提问。直接拿原始问题去检索,效果常常一般。Query改写是高级RAG里性价比很高的优化点,核心是把用户问题转换成更适合检索的表述。
最简单的做法是让大模型把用户问题改写成一个标准的检索式。比如用户问“那个新来的同事怎么加不了门禁?”改写为“新员工门禁系统添加权限失败的解决方案”。改写后的句子通常更贴近文档的表述习惯,检索命中率会提升。更激进的是多路改写:让模型生成3到4个不同角度的检索式,分别去检索,再合并结果,覆盖更全面,但接口调用成本也会翻倍。
还有一种思路叫HyDE(Hypothetical Document Embeddings),先用模型根据问题生成一个假设性的答案段落,再用这个段落去检索。思路是:问题可能和文档用语不一致,但假设答案的表述会更接近目标文档。实测在开放域问答里提升明显,但在垂直领域需要谨慎,因为生成的假设答案如果跑偏,检索结果会跟着偏。我一般只在文档覆盖很全的场景用HyDE,其他时候用标准Query改写就够了。
3.3 Rerank的必要性和落地方式
检索阶段拿到的是“粗略相关”的候选,Top-K里经常混着一些看起来相关、实际无关的片段。如果直接把K个片段全部塞给大模型,一是浪费上下文,二是噪声干扰答案。Rerank就是在这时候上场的。
Rerank模型和Embedding模型最大的区别在于交互方式。Embedding是bi-encoder结构,查询和文档分别独立编码,速度快但精度有限;Rerank通常是cross-encoder结构,把查询和文档拼接后一起过模型,能捕捉更细粒度的匹配信号,精度高但速度慢。所以RAG流程里普遍的做法是:召回阶段用最快的embedding模型拉回Top-50,Rerank阶段再精排取Top-5。
Rerank之后的效果提升,我印象很深的一次是做一个政策问答系统,不加Rerank时Top-5的相关片段里往往混着2个无关项,导致模型回答里偶尔夹带错误信息;加了Rerank之后,无关项几乎清空,回答的忠实度测试通过率从75%左右提到了90%以上。速度上,Rerank单个查询耗时几十毫秒,对一个问答系统来说完全可接受。
3.4 检索结果的过滤与去重
检索返回的候选片段里,有些虽然在语义上与问题沾边,但内容已经过时或者来自低可信度来源。这时候元数据过滤就派上用场了。按更新时间过滤,优先返回新版本;按文档类型过滤,优先返回官方手册而非个人笔记。过滤规则应该在检索阶段就完成,而不是等结果出来后硬删,这样能省下不少Rerank的计算量。
去重同样不可忽视。混合检索合并后,可能出现两个片段内容高度重叠,比如同一份资料的两次重复收录。如果不去重,等于把同一个证据重复给了模型两次,既浪费上下文又可能导致模型过度偏向重复信息。我处理时会在合并结果后用文本相似度做一层去重,相似度超过阈值的只保留排名高的那个。
4. 生成链路:上下文组装与Prompt设计
4.1 上下文组装规则
检索质量再高,最终都要靠生成链路输出答案。上下文组装是第一道工序,核心原则只有一条:清晰地区分“证据”和“问题”,并明确告诉模型证据的边界。
组装格式我做了几个版本,最终稳定下来的是这样:
请基于以下资料回答问题。如果资料中没有相关信息,请直接回答“根据现有资料无法回答”,不要编造。 【资料开始】 [1] 来源:设备运维手册-第3章-第2节 内容:AX-200设备在初次启动时需要完成网络自检…… [2] 来源:故障处理记录-2025-03-12 内容:故障码FX-17通常表示电源模块异常…… 【资料结束】 问题:AX-200设备启动时出现FX-17故障码,应该怎么处理?把来源信息放在每段资料前面,是大模型引用溯源的关键。模型看到编号和来源后,回答时会倾向于按编号组织引用,比如“根据资料[1]和[2],建议先检查电源模块”,这对于后续做引用校验非常有价值。资料块的数量一般控制在3到5块,最多不超过6块。超过这个数量,模型会开始“抄错题”,引用不存在的编号,或者把不同来源的信息强行缝合。
4.2 Prompt模板设计的经验之谈
Prompt设计不要追求花哨,核心是把边界划清楚。我总结下来有三点经验。
第一,要明确告诉模型“不知道的时候承认不知道”。这个指令能有效遏制编造倾向。我在没有这个指令时,模型面对找不到答案的情况,有相当概率会硬编一个看似合理的说法;加了这句之后,拒答率明显上升。第二,要给模型明确的输出格式,比如“先给出结论,再列出依据编号”。这样不仅答案结构清晰,方便下游解析,还能促使模型在生成时主动对齐资料里的内容。第三,要提前约定术语,特别是垂直领域的特殊词汇。在Prompt里添加一句“资料中的‘装置’与‘设备’为同一概念”,能避免不少“换词幻觉”。
还有一个容易被忽视的点:Prompt里不要堆太多背景故事,比如“你是一位专业工程师,拥有十年经验”。这类人设设定对知识库问答没有实质帮助,反而占用上下文。除非你需要模型切换特定语气风格(比如客服话术),否则少一点人设,多一点约束。
4.3 生成参数与幻觉抑制
生成阶段的参数设置,直接影响答案质量和风险程度。我习惯把温度调到0到0.3之间。温度越高,模型越可能为了“说得通”而在证据不足时自由发挥,对于知识库问答这种需要忠实于资料的场景,低温度是默认选择。Top-p一般设在0.8左右,进一步约束生成空间。
更进阶的幻觉抑制方法是nucleus采样和约束解码之外的后处理校验。一个实用的小技巧是:生成答案之后,让模型自己对照资料检查一遍,输出“检查结果:以上答案是否完全基于资料内容?如有推断内容请标注”。这相当于做一次二次校验,成本不高但能抓住一部分幻觉。代价是增加一次模型调用,延迟会变高,所以只在高价值场景启用。
再补充一个生成参数之外的建议:控制上下文中的“噪声比例”。上下文中包含不相关内容时,模型更倾向于“参考”它而不是忽略它。所以在组装上下文时,尽量只保留与问题明确相关的段落。如果某个资料块只有一半内容相关,宁可裁剪掉不相关的部分,也不要整块塞进去。
5. 实操:一个最小知识库问答系统
5.1 工程架构与依赖
前四章讲完了思路,这一章给一套可以直接跑通的最小实现。架构不复杂,四个模块:文档加载与切分、向量化与索引、检索与重排序、生成。技术选型我尽量选轻量、文档多、社区活跃的开源工具。
- 文档加载:使用文本解析库读取PDF/Word/Markdown,统一转成纯文本。
- 切分:使用按字符+分隔符的切分器,设置chunk_size=512,overlap=64。
- 向量化:使用开源中文embedding模型,维度数768。
- 向量存储:使用faiss,本地索引,支持单机快速检索。
- 关键词检索:使用BM25算法实现,负责并行召回。
- 重排序:使用一个小型Rerank模型,精排Top-50取Top-5。
- 生成:调用通用大模型API,使用低温度参数。
文件结构很简单,两个核心文件:indexer.py负责建索引,qa.py负责问答流程。
5.2 索引构建代码
# indexer.py # 功能:读取文档、切分、向量化、构建混合索引 # 依赖:pip install faiss-cpu bm25s sentence-transformers import os import faiss import numpy as np from bm25s import BM25 from sentence_transformers import SentenceTransformer # 1. 初始化embedding模型,这里用开源中文向量模型 embedder = SentenceTransformer("bge-small-zh-v1.5") def load_documents(doc_dir): """加载目录下的所有txt文档,返回文档块列表和来源信息""" chunks = [] sources = [] for filename in os.listdir(doc_dir): filepath = os.path.join(doc_dir, filename) with open(filepath, "r", encoding="utf-8") as f: text = f.read() # 按换行和句号切分,保留基础语义完整性 parts = text.split("\n") for part in parts: if len(part) < 100: continue # 每512字符一块,overlap 64字符 for i in range(0, len(part), 512 - 64): chunk = part[i:i + 512] chunks.append(chunk) sources.append(filename) return chunks, sources def build_index(chunks, sources): """向量化文档块,构建faiss向量索引和BM25关键词索引""" embeddings = embedder.encode(chunks, batch_size=32, show_progress_bar=True) embeddings = np.array(embeddings).astype("float32") # L2归一化,配合内积检索可以提高召回稳定性 faiss.normalize_L2(embeddings) index = faiss.IndexFlatIP(embeddings.shape[1]) index.add(embeddings) bm25 = BM25() bm25.index(chunks) # 内部自动分词 return index, bm25 if __name__ == "__main__": chunks, sources = load_documents("./docs") index, bm25 = build_index(chunks, sources) # 保存索引文件和文档块信息,方便QA阶段加载 faiss.write_index(index, "./faiss_index.bin") np.save("./chunks.npy", np.array(chunks)) np.save("./sources.npy", np.array(sources)) # bm25索引序列化 bm25.save("./bm25_index")这段代码有几个地方值得注意。切分逻辑简化成了字符级切分,实际生产环境应该优先按Markdown标题、段落、句子顺序切分,字符级切分只是兜底方案。faiss用内积检索的前提是所有向量都做L2归一化,这样内积就等于余弦相似度。BM25的分词策略默认按空白切分,对中文效果一般,建议替换成中文分词器,比如jieba。
5.3 检索与生成代码
# qa.py # 功能:混合检索、Rerank、组装上下文、调用大模型生成 import faiss import numpy as np from bm25s import BM25 from ranker import CrossEncoderReranker # 简单封装,内部调用Rerank模型 from sentence_transformers import SentenceTransformer embedder = SentenceTransformer("bge-small-zh-v1.5") reranker = CrossEncoderReranker() # 加载索引 index = faiss.read_index("./faiss_index.bin") chunks = list(np.load("./chunks.npy", allow_pickle=True)) sources = list(np.load("./sources.npy", allow_pickle=True)) bm25 = BM25.load("./bm25_index") def hybrid_search(query, top_k=50): """向量检索和BM25并行召回,合并后返回候选""" # 向量召回 q_emb = embedder.encode([query]) faiss.normalize_L2(q_emb) vec_scores, vec_idx = index.search(q_emb, top_k) # BM25召回 bm25_scores, bm25_idx = bm25.retrieve(query, k=top_k) # 合并候选,按“向量分数、BM25分数”加权综合 candidates = {} for score, idx in zip(vec_scores[0], vec_idx[0]): if idx == -1: continue candidates[idx] = candidates.get(idx, 0.0) + float(score) for score, idx in zip(bm25_scores[0], bm25_idx[0]): score_val = float(np.array(score).item()) candidates[idx] = candidates.get(idx, 0.0) + 0.6 * score_val # 按综合分数排序,返回Top-50 ranked = sorted(candidates.items(), key=lambda x: x[1], reverse=True) return [chunks[idx] for idx, _ in ranked[:50]] def rerank(query, candidates, top_n=5): """用Rerank模型对候选精排,返回Top-N和来源""" pairs = [(query, doc) for doc in candidates] scores = reranker.predict(pairs) top_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_n] return [candidates[i] for i in top_indices] def build_prompt(query, docs): """组装上下文和Prompt,交给大模型生成答案""" parts = [] for i, doc in enumerate(docs, 1): parts.append(f"[{i}] 来源:{sources[chunks.index(doc)]}") parts.append(f"内容:{doc}") context = "\n".join(parts) prompt = f"""请仅基于以下资料回答问题,不得使用资料之外的知识。 如果资料不包含答案,请回答“根据现有资料无法回答”。 【资料开始】 {context} 【资料结束】 【问题】 {query} """ return prompt def answer(query): candidates = hybrid_search(query) top_docs = rerank(query, candidates) prompt = build_prompt(query, top_docs) # 调用大模型API,这里用占位配置,实际填自己的接口 resp = call_llm_api(prompt, model="your-chat-model", temperature=0.1) return resp这段代码展示的是完整的问答链路:混合检索→重排序→组装Prompt→生成。call_llm_api是调用大模型接口的封装,实际使用时替换成你自己的SDK。调生成参数时,temperature一定要压住,我用0.1到0.2之间。
5.4 效果评测
搭完系统不能凭感觉说“效果还不错”,要给自己定一套可复用的评测方法。我把评测分成检索环节和生成环节两部分。
检索环节主要看两个指标:Recall@K和MRR(Mean Reciprocal Rank)。Recall@K描述的是正确文档块在检索Top-K中出现的比例;MRR描述的是第一个正确块的排名有多靠前。评测办法是准备一组“问题→答案所在文档块”的测试集,跑一遍检索,统计命中情况。这里我不展开公式推导,直接说实操:维护一个约50条问答对的评测集,每次调整切分参数或检索策略后,跑一遍Recall@5和MRR,对比前后变化。
生成环节我重点看忠实度。最简单的做法是人工标注:模型生成的答案,是否每条陈述都能在上下文中找到对应依据。我在项目里用了一个更细的评分维度——按句打分,每句“有直接依据”得1分,“有推断但合理”得0.5分,“无依据编造”得0分,最后算平均分。这套方法成本高但结果可靠,每次策略调整后抽查20条问答,基本能判断改动方向是否正确。
6. 常见问题与排查技巧实录
6.1 检索不到相关内容怎么办
检索不到内容是RAG系统出场率最高的问题,表现为:答案里模型说“根据现有资料无法回答”,但明明文档里有这个信息。排查顺序从简到繁。
先检查切分粒度。如果文档块太大,一个主题混在好几个主题里,检索相似度被稀释,正确块排不进Top-K。把chunk_size从800调到300试试,看Recall是否回升。再检查Query的表述。用户提问和文档用语差距过大时,向量匹配会失效。先试试人工把问题改写成文档风格,如果改写后能检索到,说明需要加Query改写模块。还要检查是不是混合检索权重问题。比如精确ID类问题,如果向量权重过高、BM25权重太低,可能命不中。把BM25权重拉高再测。
如果以上都试了还不行,就要考虑是不是embedding模型本身对业务术语不敏感。换一个参数量更大的向量模型,或者用领域语料对embedding做继续预训练,都是可选项。最后还有一招——扩大检索范围,把Top-K从50调到100,同时提高Rerank的精排能力,让前面的粗粒度尽量宽进。
6.2 回答出现幻觉怎么排查
幻觉大概是“答案看起来很对,但引用的信息在原文里根本不存在”。先说结论:幻觉的根因八成在检索环节,两成在生成环节。“上下文没给够”是第一大原因——模型没找到答案,但又不愿意承认不知道,就开始编。解决方法是检查检索结果中是否真的包含答案线索。我排查时会把QA日志里模型实际收到的上下文打出来,人工判断答案有没有包含在上下文里。如果答案没在上下文里而模型硬答了,那问题在检索,去优化召回;如果答案在上下文里但模型答错了,那问题在生成,去优化Prompt。
生成侧的优化手段前面提过:压低temperature、在Prompt里明确拒答指令、增加生成后的自检校验。还有一个容易忽略的:上下文顺序。模型倾向于重点参考上下文中前部的信息,如果把正确答案放在第5块,而前4块都是部分相关的内容,模型可能被前面带偏。所以让Rerank把最相关的排在最前面,不只是为了方便模型阅读,也是变相减少幻觉。
6.3 系统性能瓶颈和延迟优化
RAG系统的延迟链条比普通问答长,每个环节都有可能成为瓶颈。我遇到过的典型问题:embedding模型查询太慢、Rerank耗时过高、大模型生成速度不稳。
Embedding慢的解法是批量编码和缓存。把用户问题的query embedding缓存下来,同一问题重复提问时直接命中;文档侧的向量全部预计算,不参与运行时计算。Rerank是最容易拖慢整体延迟的环节,尤其是cross-encoder结构,每个候选都要过一次模型,Top-50个候选就是50次推理。优化手段主要有两种:缩小候选集范围,比如粗召回只取Top-20后再精排;或者把Rerank模型换小。
生成侧延迟受限于大模型API本身的响应速度,优化空间有限,但可以做的有:用流式输出改善首字延迟感知、对常见高频问题做答案缓存、尽量精简上下文长度(减少输入token数量能直接降低首个token的生成时间)。还有一个工程技巧:检索和生成可以部分并行。如果生成不依赖检索结果里的所有内容,可以先把Top-1的上下文发给模型,同时让模型预热,后面再动态补全。这个方案复杂度高,一般系统用不上,但可以留作扩展点。
6.4 避坑清单
最后整理一份我踩过坑之后沉淀的检查清单,每一条都是真金白银换来的。
- 不要直接用原始用户问题检索,产品形态允许的情况下,加一道Query改写,收益非常明显。
- 不要忽视meta data管理。没有来源标注的RAG系统,出了问题连溯源都做不到。
- 不要把embedding模型的维度数当成评判标准。维度高不代表匹配准,还要看训练数据是否覆盖你的领域。
- 不要在没做评测的情况下频繁更换模型。评测集一旦建立,所有优化都围绕它来,否则你根本不知道改动是变好还是变差。
- 不要忽略文档更新策略。知识库不是一次性建的,文档一更新,涉及的块必须重新切分、重新向量化,同时清理旧索引,不然模型会检索到过期信息。
- 不要把RAG的结果直接当最终答案输出。至少让模型在回答时标明依据编号,保留人工审核的可能性。
我做RAG项目最大的体会是:这个概念看起来简单,真正上线后所有的麻烦都藏在细节里。切分参数怎么定、Query要不要改写、召回用几路、Rerank放多大权重、Prompt要怎么约束,每一步都影响最终效果。好在这条路线已经把知识问答类场景从“靠运气”变成了“可调试”,只要你愿意建立评测闭环、按数据说话,系统就会越调越顺。这篇梳理是基于我在多个知识库场景里的实战经验,希望对正在搭RAG系统的人有帮助。