先交代一下背景。我大概从去年初开始集中做RAG落地,从最早拿LangChain默认配置跑POC,到后面把整套流程拆开重做、量化评估、持续调优,踩了不少坑,也攒下了一套相对系统的打法。这篇就围绕三个最核心的环节展开:分块策略、混合召回、质量评估。
如果你正准备搭企业知识库问答,或者已经跑通了一个Demo但发现效果不稳定——召回不准、回答幻觉、调参靠蒙——那这篇应该对你有用。我会把每个环节的取舍逻辑、实操参数、常见坑都写清楚,尽量让你能直接拿去用。
1. 搭建RAG前,先想清楚这三件"看不见的事"
很多人一上来就急着选向量数据库、调Embedding模型,其实在动任何代码之前,有三个问题值得先想清楚。它们不会写进架构图里,但几乎决定了你整个RAG系统的效果天花板。
第一件事:你的源数据到底是什么形态?
这听起来像废话,但实际操作中影响巨大。我在一个项目里遇到过客户给的"知识库",实际上是几千份扫描版PDF,全是图片,OCR都没跑过。这种情况下你无论用什么分块策略、多先进的召回模型都没用,因为信息根本没有被提取出来。源数据的形态直接决定了你的预处理管线:
- 纯文本/Markdown/Word:最好处理的形态,直接进入分块流程即可
- PDF:需要先判断是文本型还是扫描型,扫描型要先过OCR,而且中文PDF的OCR识别率和排版还原度跟英文比还是有差距
- HTML/XML:需要先做正文提取,去掉导航、页脚、脚本标签,否则会污染语义
- Excel/CSV:结构化表格数据处理逻辑完全不同,经常要按行列转成描述性文本再入库
- 音视频/会议纪要:需要先转写再处理,转写错误会直接带进知识库
第二件事:你的问题到底是什么样的?
目标问题形态决定了检索策略的复杂度。如果只是"根据文档回答问题",那标准向量检索就够。但真实场景往往是混合的:
- 关键词明确的问题,比如"服务器报错500怎么处理",这种问题里"500"是强信号,纯向量检索往往不如关键词/稀疏检索(BM25)表现好
- 语义相似但字面不匹配的问题,比如"系统宕机了怎么办"和文档中的"服务不可用恢复流程",这种就需要向量语义召回
- 多跳推理问题,比如"A部门的预算审批过审时间跟B部门相比哪个更长",这种问题甚至需要拆解成多轮检索,单纯召回几个chunk还不够
第三件事:你的约束条件是什么?
这里的约束包括:数据隐私(能不能用云端大模型API)、延迟要求(是允许10秒还是必须2秒内返回)、成本上限(Embedding和LLM的token成本)、以及团队的技术栈(Java团队和Python团队选型会很不一样)。这些约束不提前想清楚,后面架构设计很容易推翻重来。
我把这三件事跟团队复盘过很多次,结论是:RAG系统80%的问题,其实在数据准备阶段就已经注定了。检索和生成环节是在有限的条件下尽量补救。所以建议你在动手前,先花一到两天把这三件事写成文档,想得越清楚,后面的工就越顺。
2. 分块策略:为什么说chunk size是个"薛定谔的参数"
分块是整个RAG链条里最容易被低估的一环。很多人直接让LangChain的TextSplitter默认参数跑一遍就完事,结果检索效果差也不知道是哪个环节出了问题。
2.1 chunk size对召回效果的影响机制
先说结论:chunk size没有绝对最优,它是跟你的文档类型、检索策略、LLM上下文窗口强耦合的。但搞清楚它怎么影响效果,你就能自己判断该调大还是调小。
我用一个简单的例子说明。假设文档里有这么一段话:
"服务器在运行过程中如果检测到CPU温度超过85摄氏度,会自动触发降频保护机制。该机制会逐步降低CPU工作频率,并记录告警日志,通知运维人员进行处理。"
- 如果chunk size设得很大(比如1000字以上),这一段会跟很多其他内容合在一起。当你问"CPU过热会有什么保护机制"时,向量相似度会被整段的其他信息稀释,召回的chunk可能包含了答案但不够聚焦,喂给LLM后回答容易发散。
- 如果chunk size设得很小(比如50字),这一段会被切成几部分,语义完整性被破坏。当问题涉及"降频保护机制"时,可能只召回"自动触发降频保护机制"这一个碎片,缺少"降频保护机制的具体行为"上下文,LLM生成时会猜测甚至编造。
所以chunk size相关的是一个"语义完整性 vs 检索精准度"的平衡问题。块越大,语义越完整,但检索精度越低;块越小,检索越精准,但上下文越容易碎片化。
2.2 各种分块方法的对比与选型
在实际工程里,我用过四类分块方法,各有适用场景:
| 分块方法 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 固定长度切分 | 按字符数/词数硬切 | 实现简单,可控性强 | 语义割裂严重 | 仅作为baseline |
| 递归字符切分 | 按段落标题→换行→句号→词逐级回退 | 保持基本语义单元 | 对结构复杂的文档支持有限 | 通用型文本,LangChain默认方案 |
| 结构感知切分 | 按Markdown标题/HTML标签/代码函数边界切 | 语义边界清晰,上下文完整 | 强依赖文档结构,没有结构就失效 | 技术文档、Markdown、代码库 |
| 语义分块 | 基于Embedding相似度变化点切分 | 语义边界自然,适应性强 | 计算成本高,边界判断有误差 | 主题跳跃较大的文档、公告类内容 |
我个人的推荐是:优先用结构感知切分,没有结构再用递归字符切分(RecursiveCharacterTextSplitter),不要用固定长度硬切。理由很简单——语义边界这种东西,文档结构就是最廉价的信号。Markdown里的##、###天然就是主题边界,HTML里的<h1>、<h2>也是。抛弃这些直接用长度硬切,等于把免费信息扔掉了。
2.3 具体的分块参数与实战配置
如果你用LangChain,一个相对可用的起步配置是这样:
from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=512, # 按字符数计算的块大小 chunk_overlap=64, # 相邻块之间的重叠区间 separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""], length_function=len, )几个参数的取舍逻辑:
- chunk_size=512:中文场景下约等于300到500字的段落。这个大小对于多数问答场景是比较安全的起点。如果文档偏技术规范、逻辑链条长,可以往768到1024调;如果是FAQ、条款类短文本,256左右可能更好。
- chunk_overlap=64:保证切分边界上的语义不断裂。比如一个知识点刚好跨在两个块的边界,有overlap就能在两侧都保留一部分上下文。
- separators的顺序很重要:它代表切分时的优先级,先试段落、再试换行、再试句号。这个顺序保证了"能用段落边界就不用句号,能用句号就不用逗号",尽量减少对语义的破坏。
我在实际项目里一般会把chunk_size和overlap配成8:1到10:1的关系,overlap太大会引入大量冗余token,增加存储和检索成本,太小又起不到连接作用。
2.4 一个经常被忽略的分块思路:父子分块
如果你做过一段时间的RAG,可能会遇到这样的情况:chunk里包含了答案,但缺少足够的背景信息,导致LLM回答得很片面。这时候可以考虑父子分块(Parent-Child Chunking)方案。
思路很简单:检索用小块(child),喂给LLM用大块(parent)。
- 建库时:文档按大块(比如1024字)切分,再在每个大块内部细分为小块(比如256字),建立小块到大块的映射关系
- 检索时:用小块去做向量检索(召回精准),拿到命中的小块后,回溯对应的完整大块,把大块内容作为上下文喂给LLM
这样既保证了检索的精准度,又保证上下文完整。缺点是实现复杂度稍高,且喂给LLM的token量会增加,成本稍微高一点。
LangChain的ParentDocumentRetriever支持这个模式,用起来很方便:
from langchain.retrievers import ParentDocumentRetriever from langchain.storage import InMemoryStore from langchain.vectorstores import Chroma # 小chunk的切分器 child_splitter = RecursiveCharacterTextSplitter(chunk_size=256, chunk_overlap=32) # 大chunk的切分器 parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1024, chunk_overlap=64) retriever = ParentDocumentRetriever( vectorstore=vectorstore, docstore=InMemoryStore(), child_splitter=child_splitter, parent_splitter=parent_splitter, ) # 入库时,先按parent_splitter切分并存储到docstore, # 再将每个父块细分为子块,用子块更新向量索引2.5 元数据设计:让检索结果更容易被过滤
很多人把chunk切完了就往向量库里塞,忽略了一个重要的事情——元数据。元数据是RAG检索的"后门",可以在向量检索之前或之后做硬性过滤,能解决掉很多纯靠向量相似度解决不了的问题。
常见的有用的元数据字段:
- 来源文档ID / 文件名:定位和溯源
- 章节路径:比如"第3章/3.2节",支持按章节范围检索
- 文档类型:FAQ / 规范 / 工单 / 变更记录
- 更新时间:做时间衰减或按版本过滤
- 业务线/部门:知识库场景下做权限隔离
- 作者/维护人:方便追溯和反馈
举个实际例子:企业知识库里可能有《XX系统操作手册2023版》和《XX系统操作手册2024版》,新旧版本内容互相冲突。如果你的检索不区分版本,用户问"XX系统怎么配置",召回的结果可能同时包含两版内容,LLM就会给出自相矛盾的答案。这时候在元数据里加一个version字段,检索时按条件过滤掉旧版本,问题直接消失。
3. 混合召回:让稀疏检索、向量检索和Rerank各司其职
3.1 为什么纯向量检索不够用
这几年Embedding模型发展很快,语义检索的效果越来越好。但如果你做的是真实业务系统,会发现纯向量检索有几个先天短板:
- 专有名词、型号、工单编号不敏感:"RTX4090的性能参数"和"RTX3080的性能参数",如果两段文本在同一个上下文里出现,Embedding可能会把它们混在一起
- 精确匹配失效:用户的"P95延迟"和文档里的"Percentile 95延迟"在语义上是同一件事,但如果模型训练数据里没覆盖到这种等价关系,向量相似度可能很低
- 低频术语遗忘:Embedding模型对生僻词、缩写词、代码变量名的表示通常很差,因为这些词在预训练语料里出现频率太低了
这就是为什么需要混合召回:用稀疏检索(BM25)解决精确匹配和关键词命中,用稠密向量检索(Embedding)解决语义泛化,两者互补。
3.2 混合检索的工程实现
常见的混合检索方案就是把BM25命中和向量命中的结果合并起来,再做去重和重排。Elasticsearch同时支持BM25和向量检索(kNN),Weaviate、Milvus、Qdrant这些向量数据库也都陆续支持了混合检索能力。如果你用Python栈,可以这样组合:
from langchain.retrievers import BM25Retriever, EnsembleRetriever from langchain.vectorstores import Milvus # 向量检索器 vector_retriever = Milvus(embedding_function=embeddings, collection_name="docs").as_retriever( search_kwargs={"k": 10} ) # BM25检索器 bm25_retriever = BM25Retriever.from_texts(docs, k=10) # 混合检索器,加权融合 hybrid_retriever = EnsembleRetriever( retrievers=[bm25_retriever, vector_retriever], weights=[0.3, 0.7], )这里有两个关键点值得展开说。
第一个是要不要做加权,以及权重怎么设。我现在更倾向于不用固定权重,而是用RRF(Reciprocal Rank Fusion)来做结果融合。RRF的思路很简单:每个召回结果按排名得到一个倒数分数,最后按分数汇总。它不依赖你预先调权重,对两个检索器的分数尺度差异也不敏感,鲁棒性好很多。公式是:
score(d) = Σ 1 / (k + rank_i(d))其中k是一个常数,通常取60。rank_i(d)表示文档d在第i个检索器里的排名。如果某个结果同时在两个检索器里都排得靠前,它的RRF分数会很高。
第二个是混合检索不是简单的"1+1"。两个检索器召回的内容可能高度重叠,如果不做去重,最后喂给LLM的上下文会有一堆重复信息,浪费token还干扰判断。所以融合之后需要按score排序并去重,这一步千万别省。
3.3 Rerank:让结果排序更贴合你的真实需求
混合召回解决的是"找得到",Rerank解决的是"排得对"。向量检索和BM25返回的排序是基于"相关性估计"的,但这个相关性跟你最终问的问题之间是有差距的。我举个例子:
你问"公司年假制度是什么",向量召回的结果可能有几条:一条是《员工手册》里关于年假的计算规则,另一条是某个员工发的离职帖子提到了年假折算,还有一条是HR在公告里通知年假申报时间。后两条在语义上跟"年假制度"都有关系,但真正的答案只来自第一条。
Rerank模型的作用就是:输入查询和候选文档,输出每篇文档精细化的相关性分数。它通常比Embedding模型的交互式匹配要精细得多,因为它把query和document做深度交互编码,而不是分别编码再算相似度。
开源方案里,BGE-Reranker和Cohere Rerank是两种常用选择。BGE系列对中文支持不错,跑在本地也方便。使用时要考虑推理时间——如果引入Rerank后整个链路的延迟多了好几秒,用户的体验很难接受。我一般先召回10到20个候选项,然后用Rerank挑出top 3到5个,这样一个流程加几十到几百毫秒,用户可以接受。
from FlagEmbedding import FlagReranker reranker = FlagReranker('BAAI/bge-reranker-base', use_fp16=True) pairs = [[query, doc_text] for doc_text in candidate_docs] scores = reranker.compute_score(pairs, normalize=True) # 按rerank分数重新排序,取top_k sorted_results = sorted(zip(candidate_docs, scores), key=lambda x: x[1], reverse=True)[:top_k]3.4 混合召回链路中的延迟控制与缓存
工程落地的时候,召回链路里加了BM25、向量检索、Rerank三个环节,每个环节都有耗时,加起来可能就慢了。需要做几件事:
- 并行召回:BM25和向量检索互相独立,应该并发执行而不是串行等待。Python里用asyncio.gather或者多线程就能做到。
- 缓存热点查询:高频问题(比如"密码重置流程""如何提交报销")的召回结果可以直接缓存,设置合理的TTL(比如按天),能大幅降低重复查询的压力。
- 分层召回:第一轮用快速检索把候选集控制在100条以内,第二轮用更重的语义模型精排。这个思路在数据量大的时候尤其有效,能避免全局计算带来的性能瓶颈。
从我自己项目的压测数据看,加了缓存之后,P95延迟从差不多4.5秒降到了1.8秒,其中很大一部分是因为热点问题不再走完整检索链路。
4. 质量评估:用数据而不是感觉来判断RAG好不好
分块、混合召回这些做完之后,一个绕不开的问题是:怎么知道系统到底好不好?很多人靠手工测试几个问题感觉"还行"就上线了,结果用户一用全是问题。RAG效果必须用一套可量化的评估体系来衡量。
4.1 评估维度的拆解
RAG的质量评估可以拆成两个大环节:检索质量和生成质量。每个环节再往下拆:
检索质量:
- 召回率(Recall@k):正确答案在不在召回的前k条里
- 精确率(Precision@k):召回的前k条里有多少是真正相关的
- MRR(Mean Reciprocal Rank):第一个正确答案排在第几位
生成质量:
- Faithfulness(忠实度):回答内容是否严格基于检索到的上下文,有没有编造或用模型自己的知识补充
- Answer Relevancy(答案相关性):回答是否真的回应了用户的问题,有没有答非所问
- Context Relevance(上下文相关性):检索到的上下文是否跟问题高度相关
- 幻觉率:回答中是否有原文没有支撑的信息
这些指标里,Faithfulness是RAG系统最核心的指标,因为它直接衡量了RAG最想解决的问题——幻觉。
4.2 评估集的构建
没有评估集,一切指标都是空谈。实际上,建立一个小而精的评估集,比搭一个复杂的评估平台更优先。
我建议从这几类问题构建评估集:
- 核心高频问题:从用户真实日志里抽,如果还没有日志,就让业务方提供50到100个最常被问到的问题,并标注标准答案
- 边界和否定问题:"公司年假按什么标准计算"和"公司年假按什么标准不计算"这种,考察检索对否定语义的区分能力
- 跨文档问题:答案需要综合两篇或多篇文档的内容,考察系统的多跳检索能力
- 近似语义问题:同样一个意思,用不同的措辞提问,看系统能不能稳定召回
- 对抗性问题:故意问知识库里没有的内容(比如公司根本没有的业务),看系统会不会一本正经地编造
评估集的标注不需要一蹴而就,每轮迭代加一部分就行。关键是先有50个高质量问题,让评估这件事跑起来,后续再慢慢扩充覆盖度。
4.3 自动化评估的实现方案
人工评估100条case要老半天,自动化评估是必须的。目前常用的做法有两个方向。
方向一:用LLM做裁判(LLM-as-a-judge)。即让一个大模型来评估系统回答的忠实度、相关度等指标。RAGAS框架就是这样的思路,它用LangChain或LlamaIndex把评估流程封装好了,能算出一组指标分数。
from ragas import evaluate from ragas.metrics import ( faithfulness, answer_relevancy, context_precision, context_recall, ) result = evaluate( dataset=eval_dataset, # 包含question, answer, contexts, ground_truth的验证集 metrics=[faithfulness, answer_relevancy, context_precision, context_recall], )用LLM做裁判的好处是普适性强、不需要人工标注,坏处是成本高、且有裁判模型自身偏见的风险。所以建议关键指标(比如Faithfulness)定期抽取部分case做人工复核,确保自动评估没有"漂移"。
方向二:基于标注集的规则指标。对检索质量这块,你用前面构建的包含标准答案的评估集,直接算Recall@k、Precision@k、MRR等。这些指标不依赖LLM,计算快、稳定,适合在CI里跑回归。
我的实践是两套并行:用标注集算检索指标(每轮迭代跑,保证召回能力不退步),用LLM裁判算生成指标(小批量跑,监控整体质量趋势)。
4.4 用指标反推系统问题
评估的价值不只是打分,而是能帮你定位"问题出在哪一环"。我这里整理一个简单的排查路径:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 检索召回率低、正确答案不在候选集里 | 分块过大或过小、Embedding模型与领域不匹配、元数据过滤过严 | 调整chunk size,换领域Embedding模型,检查过滤条件 |
| 检索精确率低、召回了一堆不相关的内容 | chunk过小导致碎片化、Rerank没做或权重不对 | 加大chunk或做父子分块,引入Rerank |
| 召回了相关内容但回答不忠实、编造事实 | 上下文信息不足、提示词约束不够、LLM被无关上下文干扰 | 切换父块上下文,强化提示词约束,清理无关chunk |
| 回答相关但流于表面、没有深入解答 | 只给了片断上下文,缺少段落级背景 | 用父子分块方案,检索chunk但生成用parent chunk |
| 不同问法效果波动大 | 评估集覆盖不够、混合召回权重不合适 | 扩充评估集,调研BM25与向量的权重分配 |
在项目里,我见过不少"明明是生成环节问题,却跑去调检索参数"的情况。有了这层映射关系,可以少走不少弯路。
5. 一些现场踩过的坑和个人收尾经验
最后分享几个我实际经历过的有代表性的坑,很多是文档上不会明写的细节。
坑一:Embedding模型的max sequence length。有一个项目,我们把chunk_size调到了1500个字符,然后发现向量化时某些文本报错,一查才发现Embedding模型的最大输入长度是512个token。中文字符约等于0.6到1个token不等,1500字符很可能超限。超限后有些库直接截断,有些库直接报错,而截断会导致向量表达偏差。这个必须在选Embedding模型前就确认清楚,然后反推chunk_size的限制范围。
坑二:PDF解析的质量参差不齐。不同PDF解析工具对双栏论文、表格、页眉页脚的处理差异很大。我用过PyPDF2、pdfplumber、unstructured、PyMuPDF,效果最好的方案往往是结合使用:先用unstructured做基础的layout分析,表格部分用专门的表格抽取模块,再人工检查样例输出。如果解析质量不过关,后面所有环节的效果都会被带偏。
坑三:Rerank的耗时是真真实实的延迟来源。第一次接BGE-Reranker时,没做并发、没做缓存,一个查询多了2秒多。后来做了召回结果缓存和异步重排,延迟才降到可接受。任何新环节上线前,建议先测一下它对整个链路端点延迟的影响,不然功能是有了,用户在等的时候可不会管你内部逻辑有多精密。
坑四:评估集要跟着系统一起演进。我曾经花了一整天标注了100个问题,测完一轮调整后就丢在那不管了。等到系统迭代了几个版本,再用老的评估集一测,发现很多指标"变差"了。其实不是系统变差了,而是文档更新了、问题场景变了,老的评估集已经不能反映真实情况。评估集需要随知识库内容变化定期增量更新。
回到开头说的,RAG工程实践本质上没有多少高深的算法创新,更多是把分块、召回、生成、评估这些环节里的细节做扎实,让每个环节都稳定可控。我在这篇文章里给出的参数和方法,算是从我自己的实践中沉淀下来的基线值,不一定直接适用于你的场景,但如果你想找个起点,这些是可以放心用的。后续结合你自己的数据集多测几轮,很快就能摸到那套属于你的"最优配置"。