最近又有朋友来问我RAG应该怎么搭,说按照网上的教程跑通了demo,但一换自己的文档效果就崩。聊下来我发现,大部分人的问题都出在同一个地方——太把RAG当成一个开箱即用的工具,忽略了索引和检索这两端的细节打磨。这篇就结合我自己落地几个RAG项目的经验,把从索引到智能检索这条链路完整拆一遍,说说哪些地方值得花心思,哪些坑能直接避开。
这个系列写完,正好有读者私信说前面几篇偏基础,想要更贴近实战的内容。这篇以构建一套完整的知识问答RAG系统为例,覆盖文档解析、分块、向量化、召回、重排、上下文组装这几个核心环节。不管你是刚入门想搭第一个RAG,还是已经做了几轮迭代想要提升效果,这篇应该都有能直接拿走用的东西。
1. 整体设计思路:把RAG拆成两个独立优化的子系统
很多人把RAG当成一条直线流程——文档塞进去,问题丢进去,答案出来。但实际上RAG由两个相对独立的子系统组成:索引系统和检索系统。索引系统负责把原始文档转化成可被计算机理解的结构化表示,检索系统负责在用户提问时从这些表示中找出最相关的片段。两个子系统独立优化,最后通过生成环节衔接。
1.1 为什么索引质量决定了RAG的体验上限
先说我踩过的坑。最初做知识库问答时,我直接把PDF整本丢进系统,让模型按从头到尾的顺序读。结果有两个问题:一是token消耗巨大,二是答案质量差——因为模型根本不知道哪里是重点,只能靠注意力机制硬扫全文。
后来改成先索引后检索,才知道索引阶段的一个小决策能直接改写检索结果。比如分块时如果把一个长表格截成两块,检索时无论问什么,召回的都只有半个表格,答案自然是残缺的。再比如embedding模型选得不合适,切分后的块向量在语义空间中挤成一团,检索时前几名召回的相似度都差不多,重排也救不回来。
这里建议每个想做RAG的人先把一句话刻脑子里:检索结果的上限,在索引阶段就已经定死了。召回环节能做的只是从索引里挑出相对好的,不可能凭空变出高质量内容。所以索引阶段花的时间再多都不冤枉。
1.2 完整链路拆解:查询进来之后发生了什么
一次完整的RAG请求,我习惯把它拆成这么几段:
- 用户提交问题
- 查询改写(可选):把口语化问题改成适合检索的表达
- 向量化:用同一个embedding模型把查询转成向量
- 召回:从向量库找出top-K个最相似的候选片段
- 重排:用更精细的模型对候选片段重新打分排序
- 上下文组装:把最相关的片段拼接成模型输入
- 生成:大模型基于上下文和原始问题生成回答
索引侧则是另一条线:
- 文档解析:从PDF、Word、HTML等格式中抽取纯文本
- 清洗:去掉页眉页脚、乱码、无关内容
- 结构化分区:按章节、段落、语义拆解
- 分块:控制在模型可接受的token范围内
- 向量化:把每个块转成向量
- 写入向量库:构建索引并保存元数据
这两条线像两条独立的流水线,在检索那一刻才交汇。所以项目启动的时候我建议先把这两条线的接口定义清楚,别一上来就混着改。
1.3 可用性和效果之间的平衡点
做RAG有个绕不开的取舍:精确匹配和语义模糊匹配的关系。纯向量检索擅长语义相似,但对精确的关键词、型号、编号这类内容束手无策——比如用户问"型号ABC-204的额定功率",向量检索可能因为"ABC-204"分词后被拆散而召回失败。反过来,纯关键词检索又处理不了"我想找一个能处理大量并发请求的方案"这种语义发散的问题。
所以现在主流的做法是混合检索,把向量召回和关键词召回的结果合并,再做一次重排。虽然工程上复杂一些,但对真实场景的提升非常明显。具体怎么做,后面检索章节详细说。
2. 索引阶段:把文档变成合格的检索单元
2.1 文档解析与清洗:最脏最累的活儿
先说解析。不同格式的解析难度完全不在一个等级:
- Markdown/纯文本:几乎没有解析成本,但需要处理代码块和表格
- HTML:要剔除标签,保留标题层级
- PDF:这里坑最多,尤其是扫描件需要OCR,多栏论文需要版面分析,表格需要用专门的工具抽取
- Word:兼容性问题多,样式丢失是常态
我的经验是:第一版先能跑通就够,但PDF必须格外注意。很多人用命令行工具直接转文本,遇到多栏论文就满屏乱序——左边一栏的下一行直接接到右边栏,检索时语义根本对不上。现在有几十个开源的版面分析工具能很好地解决这个问题,建议解析PDF时务必先跑一步版面分析,把页面拆成标题、正文、表格等区块再抽取。
清洗这部分,必须做的几件事:
- 去掉页眉页脚和重复的导航文本,比如论文里的"第X页共Y页"
- 合并被拦腰截断的行,特别是PDF转出来的文本经常每行末尾有换行
- 统一中文标点和编码格式,避免全文夹杂繁体或全角半角混乱
- 过滤广告、免责声明、联系方式这类噪声
我做过一个知识库,清洗前后检索准确率差了近15个百分点。原因不在算法,而是原文里大量页脚噪声被分块后混进了向量空间,检索时频繁召回,真实的正文片段反而被挤出了top-K。
2.2 分块策略:块大小、重叠窗口和边界感知
分块这一步,是索引阶段最影响检索效果的单点决策。搞懂分块为什么重要,得先理解embedding模型的工作方式。模型把一段文本映射为一个向量,这个向量的语义粒度是全局性的——它表达的是整段文本的主题,而不是局部的细节。所以如果分块太大,块内塞了多个子主题,向量表示就会变成"这堆内容的平均值",检索时对每个子主题都有点相关,但都不精准。如果分块太小,比如一句话一块,虽然语义聚焦了,但缺少上下文,模型没法判断这句话在什么场景中出现,召回后也容易张冠李戴。
具体参数上,我常用的起点是:
- 普通文档:单块300~500个token
- 代码仓库:按函数或类切分,单块200~400个token
- 长表格或复杂结构:单独处理,尽量整块保留
快与块之间加重叠窗口很有必要,一般重叠量是块大小的10%~20%。为什么?因为两块之间如果恰好有一句话被切掉一半,语义就断了。加上窗口,切掉的内容在上一块的结尾和下一块的开头都出现一遍,就能减少信息丢失。
边界感知比固定大小切分好得多。优先按章节标题、段落边界、代码函数定义来切。如果找不到明显边界,再退回到按固定长度切。我见过不少项目一上来就直接按字符数硬切,结果是同一个章节被拦腰截断,检索结果天然残缺。
2.3 向量化:Embedding模型选型的四个判断维度
选embedding模型这件事,在中文场景尤其值得重视。很多开箱即用的embedding模型在英文上表现不错,一到中文就稀烂。选型时我主要看四个方面:
第一,是否在中文语料上做过充分训练。判断方法很简单,拿几个你自己的文档片段做召回测试,看看同义改写后能不能正确召回。
第二,向量维度。高维度(如1024维)通常表达能力强,但存储和计算成本高;低维度(如384维)速度快,但可能表达不够细。不用盲目追求高维度,够用就好。
第三,最大输入长度。有些模型支持很长的上下文(比如8k),但长了不一定好——块内内容太杂反而稀释语义。我个人体验是,支持512~1024token的模型配合合适的分块策略,效果通常是最好的。
第四,是否有领域适配。比如法律、医疗这类专业领域,用通用模型还是垂直模型,效果差异很大。预算允许的话建议都跑一遍对比再定。
另一个值得提醒的:检索用的embedding模型必须是同一个。我曾经有个项目,前期测试时用模型A离线建了索引,后来觉得模型B更好,直接换掉了,但向量库里还是模型A建的老数据。检索时问题用模型B编码,老文档用模型A编码——两边向量空间都不一样,相似度计算完全是乱的。这个坑务必避掉。
2.4 向量存储:除了向量库,元数据同样重要
选向量数据库时,我会同时关注这几个方面:支持索引类型、相似度度量方式、元数据过滤能力、扩展性和运维成本。
索引类型主流是HNSW(分层可导航小世界图)和IVF(倒排文件)。HNSW检索延迟低、召回率高,但构建索引占内存多;IVF内存占用小但需要调候选集参数。我的经验是:中小规模(百万级以内)直接用HNSW,把neighbors参数调大一点就够了,省心且稳定。
相似度度量一般有内积、余弦、欧氏距离三种。对语义检索来说,余弦相似度是最常用的——它只关心向量方向,不受向量长度影响。但要留意,很多库的默认配置有归一化处理,配置时务必搞清楚实现细节,否则会出现"看起来配置的一样,结果差一大截"的情况。
元数据存储是很多人忽略的点。每条向量都应该绑定来源文档ID、章节标题、页码、创建时间等元数据。我之前做一个多知识库聚合系统,就是靠元数据过滤实现了按部门检索——用户提问时先限定部门范围,再在子集内做最近邻搜索。没有元数据,这种需求只能硬扫全库,效果和速度都难看。
3. 智能检索:让正确的信息浮出水面
3.1 向量召回:Top-K和相似度阈值的艺术
向量检索的核心操作是把用户问题编码成向量,然后在向量库里找最近邻。这里有两个参数最需要调:召回数量Top-K和相似度阈值。
Top-K的选择很微妙。K太小,可能漏掉关键信息;K太大,无关内容混进来的概率也变大,还会让后续的重排和上下文组装压力增大。我常用的策略是:在重排环节之前先多召回,K设为10~20,再用重排模型压缩到3~5个片段。第一轮召回讲覆盖,第二轮重排讲精准,两个阶段各司其职。
相似度阈值的作用是兜底。设置一个下限,低于这个分数的向量即使排在前K也直接丢。很多RAG系统一看召回结果不对就开始怀疑模型,其实往往就是没设阈值——一堆低质量的垃圾片段全都浮上来了。阈值的设定没有通用标准,跟你用的模型和数据分布强相关。实战时先跑一批问题,统计命中正确的片段得分大概落在什么区间,再设定阈值。
还有一点:召回前对查询做改写,能显著提升命中率。用户的问题往往是口语化、指代不清的表述,比如"它支持并发吗"——这个"它"如果不还原成实体名,检索时向量相似度很分散。我在实际项目中常用的改写策略有:
- 用大模型把口语问题改成书面化的检索语句
- 把指代词还原成具体的实体名词
- 必要时扩展同义词,提高召回命中
这步改写成本很低,但对检索质量提升非常明显。
3.2 混合检索:向量+关键词的双引擎互补
前面说过,向量检索对精确匹配的召回效果不够稳定,尤其是型号、编号、人名这类实体。混合检索的思路就是让两个引擎各自负责自己擅长的部分,最后合并结果。
具体实现上,经典的做法是向量检索加BM25关键词检索。BM25是一种基于词频和逆文档频率的打分模型,擅长精准匹配,速度快。把两个引擎的召回结果合并,用一组合适的权重做融合,比如向量得分占0.7、关键词得分占0.3,按加权总分取前K个。
混合检索里最麻烦的是两种分数的可比性。向量相似度通常是0~1之间的float,BM25分数是0到几十的整数,直接加权没法比。常见做法是分别做归一化(比如min-max归一化或z-score),再融合。或者用更简单的策略:各自取top-N后,用互斥补集的方式合并——关键词命中但向量漏掉的补进来,向量召回但关键词没命中的也补进来,去重后按向量分数排序。
我的实操体验:引入混合检索后,知识库问答的准确率能提升5~10个百分点,特别是在掺杂大量专有名词的领域。代价是多维护一个关键词索引,但对中等规模知识库来说成本完全可接受。
3.3 重排序:用交叉编码器做二次精排
召回阶段找回来的top-K个片段,彼此之间的相关度差异其实很模糊。因为双编码器(先分别编码查询和文档,再算相似度)效率高但精度有限。重排序干脆换一种玩法:直接把查询和文档拼在一起,用一个交叉编码器判断它们的相关程度,精度高得多。代价是速度慢——每对都要过一遍模型,所以只能对少量候选做。
重排这个环节,是把"差不多相关"变成"准确相关"的关键一步。我在项目中重排前后对比过:重排前头名经常是次优结果,重排后命中率能提升8%~12%。
工程实现上,重排模型选择和调用要注意几个点:
- 输入长度限制:交叉编码器通常要求较短输入(如512 token),超出时需要截断或分段打分
- 批量推理:把多组查询-文档对拼成一个批次做推理,能大幅提升吞吐
- 分数归一化:重排分数通常会做sigmoid或softmax归一化,方便和后续逻辑对接
3.4 上下文组装:别让多余信息污染答案
经过重排之后,剩下的片段就是最终的上下文。组装这一步经常被轻视,但耗token还是小事,关键是给大模型喂太多不相关信息,反而会诱导幻觉。大模型收到上下文里混着几条无关信息时,很可能把其中不相关的部分也当作事实引用了。
所以组装的核心原则是:宁缺毋滥。我一般只保留重排后得分最高的3~5个片段,每个片段控制在500个token以内。必要时对片段再做一次裁剪——保留得分最高的部分,去掉冗余的句子再拼接。
组装时还要给大模型明确的指令格式。比如要求"仅根据以上参考资料回答,如果资料中没有相关内容,请直接说明不知道"。这种防御式指令对幻觉有明显的抑制作用。
4. 实操过程:一个可复现的完整落地示例
写到这里,直接上可运行的示例。下面这套流程用Python实现了一个基础的RAG索引和检索链路,适配了小规模知识库的搭建。
4.1 环境准备与关键依赖
先装好基础环境。我这里只列出关键依赖,具体版本号按你的运行环境自行锁定。
pip install sentence-transformers # 用于加载embedding模型 pip install pymilvus # 向量数据库客户端 pip install rank-bm25 # BM25关键词检索 pip install openai-style-sdk # 大模型推理接口(按需替换)环境准备阶段有一点要特别强调:所有组件版本需要锁定,因为embedding模型和向量库的兼容性问题经常导致线上事故。我有个经验,把依赖锁进requirements文件之外,还要记录模型文件本身的信息,否则过了几个月你根本不知道库里是哪个模型建的索引。
4.2 索引构建:从原始文档到可检索向量
第一步,加载并清洗文档。
import re def clean_text(text): # 去除页眉页脚(具体规则按文档特征调整) text = re.sub(r"第\s*\d+\s*页", "", text) text = re.sub(r"\s+", " ", text) return text.strip()第二步,按章节边界分块,设置重叠窗口。
def split_into_chunks(text, chunk_size=300, overlap=50): paragraphs = text.split("\n\n") chunks = [] buffer = [] buffer_len = 0 for para in paragraphs: para_len = len(para) if buffer_len + para_len > chunk_size and buffer: chunks.append("\n".join(buffer)) # 保留末尾最少N个字符作为重叠 tail = "" for last in buffer[::-1]: tail = last + "\n" + tail if len(tail) >= overlap: break buffer = [tail] if tail else [] buffer_len = len(tail) buffer.append(para) buffer_len += para_len if buffer: chunks.append("\n".join(buffer)) return chunks第三步,批量向量化并写入向量库。
from sentence_transformers import SentenceTransformer model = SentenceTransformer("path/to/your-chinese-embedding-model") def build_index(documents): embeddings = [] metadata = [] for doc_id, doc in enumerate(documents): for chunk_id, chunk in enumerate(split_into_chunks(doc["text"])): vec = model.encode(chunk, normalize_embeddings=True) embeddings.append(vec) metadata.append({ "doc_id": doc_id, "chunk_id": chunk_id, "title": doc["title"], "source": doc["source"], }) return embeddings, metadata写入向量库的过程,要重点确认两件事:是否开启了归一化、相似度度量是否设置为余弦。这两项设置错误,检索结果会非常奇怪,而且很难排查。
4.3 检索链路实现:召回、重排、组装、生成
查询侧的整体实现如下。
def retrieve(query, top_k=20, threshold=0.35): query_vec = model.encode(query, normalize_embeddings=True) # 向量召回 vec_results = vector_db.search(query_vec, top_k=top_k) # BM25关键词召回 bm25_results = bm25_index.search(query, top_k=top_k) # 合并并归一化(简化实现) merged = merge_and_normalize(vec_results, bm25_results) # 阈值过滤 merged = [item for item in merged if item["score"] >= threshold] return merged def rerank(query, candidates): # 用交叉编码器逐条打分 pairs = [(query, c["text"]) for c in candidates] scores = cross_encoder.predict(pairs) ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True) return [r[0] for r in ranked[:5]] def build_prompt(query, top_chunks): context = "\n\n".join( f"【来源{ i+1 }】{ chunk['text'] }" for i, chunk in enumerate(top_chunks) ) return ( "请基于以下参考资料回答问题。若资料中不含答案,请直接说明未知。\n\n" f"参考资料:\n{ context }\n\n" f"问题:{ query }\n\n" "回答:" )整体调用:
candidates = retrieve("ABC-204的额定功率是多少?") top_chunks = rerank("ABC-204的额定功率是多少?", candidates) prompt = build_prompt("ABC-204的额定功率是多少?", top_chunks) answer = llm_completion(prompt)这条链路第一次跑通不难,难的是把每个环节的参数调到互相匹配。我自己的调参顺序是:先确定分块大小 → 再选embedding模型 → 然后调召回和阈值 → 最后加重排。顺序反过来调参,很多问题会互相干扰,很难定位根源。
5. 常见问题与排查技巧实录
5.1 检索系统典型问题速查表
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 召回结果语义相关但不精准 | 分块过大,语义被稀释 | 减小块大小,检查重叠窗口 |
| 召回结果完全无关 | Embedding模型不适合中文/领域 | 换模型,用自查集对比测试 |
| 精确关键词召回失败 | 只用了向量检索 | 接入BM25混合检索 |
| 答案中使用了错误信息 | 上下文被无关片段污染 | 降低Top-K,加强阈值过滤 |
| 同一个问题反复波动 | 向量库索引参数未调优 | 调HNSW的M和efConstruction参数 |
| 查询速度慢 | 全量扫描代替索引查询 | 确认是否走HNSW索引而非暴力搜索 |
5.2 幻觉问题的两个有效缓解手段
RAG系统搭建起来后,幻觉问题是大头。我自己用的两个最有效的手段:
第一,强制引用来源。让大模型回答时标注是从哪条参考资料得出的结论,比如"根据资料2中描述:……"。一旦模型必须交代出处,它编造事实的概率会明显下降。如果你用的推理框架支持结构化输出,更可以将答案和引用片段一起作为字段输出。
第二,设置低置信度兜底。在检索阶段设定一个严格阈值,当最高分的片段得分低于该阈值时,不生成回答,而是提示"知识库中未检索到相关信息"。这样虽然用户会碰壁,但至少不会收到幻觉答案。在企业内部场景中,明确说不知道比编造一个看似合理的答案要好得多。
5.3 性能瓶颈和优化方向
RAG系统的性能瓶颈一般出现在两个环节:embedding推理和向量检索。embedding推理一般是批量离线进行的,构建索引时慢一点问题不大;真正考验性能的是在线查询时的查询向量编码和重排。重排是纯加法开销,候选集范围大时会明显拖慢延迟,建议控制在最多20个候选以内。
另一方面,向量检索虽然有HNSW索引加速,但数据量大了以后内存占用会飙升。优化方向有两个:一是把索引分成多段,只加载热段;二是对embedding做乘积量化压缩,用一点精度换大幅内存下降。对于亿级以下的数据规模,HNSW加充足的机器内存是最省心的方案。
5.4 评估方法:用真实业务问题建回归集
最后聊聊评估。没有评估,RAG系统的迭代就是摸黑走路。建议从一开始就准备一组真实的业务问题作为回归测试集,每条问题标注期望命中的片段或期望答案。每次改动索引策略、分块参数、检索逻辑时,跑一遍测试集,记录命中率、忠实度、准确率几个指标。
这里有个很关键的小技巧:测试集一定要来自真实的用户问题,不能用你自己编的问题。因为自己编的问题自带"标准答案"倾向,测不出系统在真实场景里的表现。我通常从用户日志里抽100条高频问题,再人工标注参考答案,长期维护这个测试集。效果优化到一定程度后,再请业务方盲测验收。
评估指标上,我比较关注两个:
- 命中率:期望片段是否出现在召回/重排后的top结果里,主要反映检索质量
- 忠实度:生成答案是否严格基于引用片段,主要反映调用大模型时的提示词和组装质量
这两个指标分别对应检索和生成两端,调优时能快速定位问题出在哪一端。
写在最后
RAG这个方向,如果说有什么心得体会,那就是它没有一个环节能"省掉"。索引偷懒了,检索端就找不回来;检索偷懒了,生成端就编瞎话。每一个组件都按应有的标准做到位,整个系统的效果才能稳定住。
同时也想提醒一点:不要为了追求新思路而不断增加复杂度。混合检索、重排、查询改写这些手段都是好东西,但也是逐步引入的。每加一个环节,都要先确认它确实带来了收益,而不是让系统变重。从最简单的单链路跑通,再一步步加料,是我建议的迭代方式。
后续我打算聊聊如何针对特定领域微调embedding模型,以及在长文档场景下做分层索引的思路。这期内容如果对你有帮助,欢迎留言分享你自己在搭建RAG时遇到的最棘手的问题。