ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RAG检索增强生成落地实践:向量库选型、文本分块策略与召回率调优

RAG检索增强生成落地实践:向量库选型、文本分块策略与召回率调优

一、引言:Demo 三天上线,生产环境却迟迟无法交付

几乎所有团队做 RAG 都经历过同一个过程:接上大模型、写个 PDF 解析、扔进向量库,半天就能跑出一个能答问题的 Demo;

但真正进入业务、面对几万份内部文档和挑剔的用户时,系统立刻变得不可用——答非所问、答案残缺、引用张冠李戴、明明文档里有答案却说“未找到”。

很多人第一反应是换更大的模型。这是一个典型的误判。在 RAG 里,生成模型决定的是“表达下限”,而检索质量决定的是“事实上限”。如果召回的上下文里根本没有答案,再强的模型也只能编。工程实践中,一次 RAG 效果不达标,80% 的归因落在三件事上:文档解析质量差、分块破坏了语义、召回链路单一且缺乏评估。

本文不谈概念,只讲落地:向量库怎么选、分块怎么做、召回率怎么调,以及那些只有在生产环境才会暴露的问题。

先把“不可用”这件事拆细一点,因为不同症状对应的病因完全不同,盲目调模型只会浪费预算:

用户看到的症状真实病因典型修复手段
答非所问,答的是另一个产品的参数分块把表格切碎 / 缺少标题路径结构感知分块 + 元数据注入
答案残缺,只答了一半chunk 太小,跨块信息未合并父子分块 / 增大 overlap
引用张冠李戴,来源对不上元数据(doc_id、页码)未随 chunk 落库全链路透传 metadata
文档里明明有,却答“未找到”单路向量召回漏掉了精确词混合检索(BM25 + 向量)
答案前后矛盾新旧版本文档同时被召回软删除 + 版本覆盖
回答内容正确但语气发散噪声上下文过多,Context Precision 低Rerank + 上下文裁剪

我见过一个很典型的内部知识库项目:3200 份 PDF 产品文档,第一版用“固定 500 字 + 100 字重叠”的粗暴分块,评估集上 Recall@10 只有 61%,端到端人工抽检准确率 41%。后面三轮优化没有换模型、没有换向量库,只做了四件事:PDF 解析改为保留标题层级、分块改为结构感知并注入标题路径、加入 BM25 混合召回、加一层 rerank。Recall@10 提到 89%,端到端准确率 78%。这就是本文要讲的全部内容。

另外一个必须先建立的工程习惯:在写第一行检索代码之前,先攒一个 50~200 条的评估集。每条样本包含query、gold_chunk_ids(或至少gold_doc_id + 段落定位)、标准答案。没有它,你后面所有的“优化”都是凭感觉,改 A 坏 B,永远收敛不了。

二、核心原理:RAG 本质是一个搜索问题

RAG 的完整链路可以拆成离线与在线两条:

离线索引:文档解析(PDF/HTML/Word → 纯文本 + 结构) → 清洗 → 分块(Chunking) → 嵌入(Embedding) → 写入向量库并建立索引。

在线检索:Query 改写/扩展 → 多路召回(向量 + 关键词) → 融合 → 重排(Rerank) → 上下文拼装 → 生成。

衡量这条链路的两个核心指标:

  • Recall@K(召回率):正确答案所在的片段,是否出现在前 K 个候选里。这是硬性上限——没召回,后面全是零。
  • Context Precision(上下文精度):进入 Prompt 的片段里,有多少是真正相关的。噪声过多会显著降低模型的忠实度,也会推高 token 成本。

一个必须建立起来的认知:向量检索不是“语义搜索”的银弹,它是近邻搜索。嵌入模型把文本压成定长向量,必然丢失细节;对于型号、编号、人名、错误码这类低频精确词,向量相似度经常完全失效。所以生产级 RAG 几乎不会只用单路向量召回。

把这件事说得再具体一点。嵌入模型(比如 1024 维的 bge-m3)做的事情是:把一段几百字的文本压缩成 1024 个浮点数,并且保证“语义相近的文本,向量夹角小”。这个压缩过程是有损且平滑的——它会保留“这段在讲连接失败排查”这种粗粒度语义,但会把ERR_CONN_REFUSED_1042和ERR_CONN_REFUSED_1043这种只差一位的字符串映射到几乎相同的位置。对模型来说它们是“同一类东西”,对业务来说它们是两个完全不同的故障。

这就解释了一个经典现象:用户问“1043 报错怎么解决”,向量检索返回了一堆 1042 的文档,因为语义确实最接近;而真正包含 1043 的那一段,可能因为措辞不同(“连接被拒绝(错误码 1043)”)排在第 30 名开外。BM25 这类稀疏检索恰恰相反——它对词频和逆文档频率敏感,精确匹配1043的片段会直接被顶到第一。两者的失效模式互补,这就是混合检索存在的根本理由。

融合两路结果最常用的是 RRF(Reciprocal Rank Fusion),它只看排名不看分数,天然规避了两路分数量纲不可比的问题:

defrrf_fuse(rank_lists,k:int=60,top_n:int=20):""" rank_lists: 多路召回的有序 id 列表,例如 [向量召回ids, BM25召回ids] k: 平滑常数,经验值 60;k 越小,头部排名的影响越被放大 """scores={}foridsinrank_lists:forrank,doc_idinenumerate(ids,start=1):scores[doc_id]=scores.get(doc_id,0.0)+1.0/(k+rank)returnsorted(scores.items(),key=lambdax:-x[1])[:top_n]

两个指标的量化方式也要提前定死,否则团队内部会各说各话。Recall@K 按“gold 片段被召回的比例”算;Context Precision 可以先用人工标注的小样本估,规模上来后用 LLM-as-judge 打分(让模型判断“这段上下文对回答该问题是否有用”,0/1 二分类即可,比打 1~5 分稳定得多)。

三、向量库选型:别被 Benchmark 带偏

选型的核心不是“谁的 QPS 高”,而是你的数据规模、过滤需求、运维能力和更新频率。以下是常见选项的定位对比:

产品部署形态典型规模过滤/混合能力适用场景
FAISS嵌入式库单机内存级无元数据过滤,需自建离线实验、批量打分
Chroma库 / 轻服务十万级基础元数据过滤原型验证、内部工具
pgvectorPostgreSQL 扩展百万 ~ 千万SQL 过滤极强,0.7+ 支持稀疏向量已有 PG 团队,默认首选
Qdrant独立服务千万 ~ 亿payload 过滤 + 稀疏向量,强需要精细过滤的生产系统
Milvus分布式亿级以上强,支持多索引类型大规模、多租户
Weaviate独立服务千万级内置 BM25,开箱混合检索想少写代码做混合召回

我的明确观点:千万级以下、团队已有 PostgreSQL 的,直接用 pgvector,不要引入新组件。RAG 的瓶颈在分块和召回策略,不在向量库性能;多一个中间件就多一套备份、监控、扩容和故障排查。只有当数据量突破千万、或需要多租户强隔离与极致低延迟时,再考虑 Qdrant / Milvus。

补充一句规模换算,方便你对号入座:一条 1024 维的 float32 向量约 4KB,100 万条 chunk 的原始向量约 4GB,加上 HNSW 图结构的额外开销(通常 1.5~2 倍),单机 16GB 内存完全扛得住。也就是说,百万级数据量根本不是选型的决策点,过滤能力和运维成本才是。

选型时容易忽略的三个细节:

  1. 过滤与检索的顺序。向量库普遍采用 post-filter(先取 top_k 再过滤),一旦带上tenant_id = xxx这类高选择性条件,top_k 会被过滤到只剩几条,召回直接崩。务必确认目标库支持pre-filter / filtered search,或把ef_search、nprobe调大到 top_k 的 3~5 倍。
  2. HNSW 参数。M(每层连接数)16~32、ef_construction200 左右是常见起点;ef_search必须 ≥ top_k 的 2 倍,否则近邻图搜不充分,召回率会莫名其妙掉 10 个点。
  3. 更新与删除。文档是会改版的。索引必须支持按doc_id软删除与版本覆盖,否则新旧内容会同时被召回,模型给出自相矛盾的答案。

把 pgvector 的最小可用建表和查询写出来,很多坑在 SQL 层面就能规避:

CREATEEXTENSIONIFNOTEXISTSvector;CREATETABLEdoc_chunks(id bigserialPRIMARYKEY,doc_idtextNOTNULL,-- 原始文档唯一标识tenant_idtextNOTNULL,-- 多租户隔离字段versionintNOTNULLDEFAULT1,heading_pathtext,-- 标题路径,用于展示引用contenttextNOTNULL,embedding vector(1024)NOTNULL,is_deletedbooleanNOTNULLDEFAULTfalse,updated_at timestamptzNOTNULLDEFAULTnow());-- 向量索引:余弦距离CREATEINDEXONdoc_chunksUSINGhnsw(embedding vector_cosine_ops)WITH(m=16,ef_construction=200);-- 过滤字段一定要建索引,否则 pre-filter 退化成全表扫描CREATEINDEXONdoc_chunks(tenant_id,doc_id,version)WHEREis_deleted=false;
SETLOCALhnsw.ef_search=100;-- 必须 >= top_k * 2SELECTid,doc_id,heading_path,content,1-(embedding<=>$1)ASscoreFROMdoc_chunksWHEREtenant_id=$2ANDis_deleted=falseORDERBYembedding<=>$1LIMIT20;

关于 post-filter 那个坑,值得讲一个真实故障。某 SaaS 团队把 30 个租户的数据放在同一张表,查询时写的是“先ORDER BY 距离 LIMIT 5,再在应用层过滤tenant_id”。结果大租户的数据多、距离近,把小租户的候选全挤掉了,小租户的召回率长期在 20% 上下,排查了两周才发现是过滤顺序问题。修复方式有两种:一是像上面 SQL 那样把过滤条件下推到WHERE(pgvector 会走 pre-filter 路径);二是升级到 pgvector 0.8+,开启迭代扫描SET hnsw.iterative_scan = relaxed_order;,让它在图里持续搜索直到凑够满足条件的行数。

四、文本分块:RAG 效果的第一性变量

分块是整条链路里最“脏”也最关键的环节。它决定了检索的最小语义单元,一旦切错,后面所有优化都是在错误的地基上装修。

4.1 策略谱系

  • 固定长度 + 重叠:最省事的基线,适合结构混乱的纯文本,但极易在句子、表格中间切断。
  • 结构感知分块:按 Markdown 标题、段落、列表、代码块切分,保证每块语义自洽。这是绝大多数企业文档的最佳实践。
  • 语义分块:计算相邻句子的嵌入相似度,在相似度骤降处断开。效果略好,但索引成本高、速度慢,通常只在关键知识库上使用。
  • 父子分块(Small-to-Big):用小块(200 字)建索引保证检索精度,命中后返回其所属的大块(1500 字)给模型保证上下文完整。这是解决“精度与完整性矛盾”的最有效手段之一。

再补充两个策略谱系里常被漏掉的:

  • 句子窗口检索(Sentence Window):索引单句,命中后向前后各扩 N 句再送给模型。它是父子分块的一种轻量变体,特别适合 FAQ、法规条款这类“一句话就是一条知识”的文档。
  • 表格/代码单独成块:表格不要和正文混在一个 chunk 里,代码块同理。它们各自需要不同的切分逻辑(表格按行组切,代码按函数/类切),混切的结果是两边都不可用。

不同策略的取舍可以对照这张表:

策略索引成本检索精度上下文完整性适用文档
固定长度 + 重叠极低中中无结构纯文本、会议纪要
结构感知低高高产品手册、规范、Markdown
语义分块高高中长段落散文、研究报告
父子分块中高极高技术文档、法务合同
句子窗口中高中FAQ、条款、参数表

4.2 代码示例:结构感知分块 + 元数据注入

下面的实现按标题层级切分,跳过代码块内的#,并把标题路径写进 chunk 正文——这一步极其重要,因为一块脱离了标题的正文往往语义不完整(比如“以上参数默认关闭”),补上产品手册 > 配置说明 > 高级选项后,它的可检索性会明显提升。

importrefromdataclassesimportdataclass,field HEADING_RE=re.compile(r"^(#{1,6})\s+(.*)$")@dataclassclassChunk:text:strmetadata:dict=field(default_factory=dict)def_split_long(text:str,max_chars:int,overlap:int)->list[str]:"""对超长正文做兜底二次切分,优先在段落/句号处断开,避免硬切。"""iflen(text)<=max_chars:return[text]parts,start=[],0whilestart<len(text):end=min(start+max_chars,len(text))ifend<len(text):# 在窗口后 20% 范围内找一个句号或换行作为断点window=text[end-max_chars//5:end]cut=max(window.rfind("。"),window.rfind("\n"))ifcut!=-1:end=end-max_chars//5+cut+1parts.append(text[start:end])start=max(end-overlap,start+1)# 保证前进,防止死循环returnpartsdefsplit_by_heading(md:str,max_chars:int=800,overlap:int=100)->list[Chunk]:lines=md.splitlines()chunks:list[Chunk]=[]stack:list[tuple[int,str]]=[]# 维护标题栈:(level, title)buf:list[str]=[]in_code=False# 代码块内的 # 不是标题defflush():ifnotbuf:returnbody="\n".join(buf).strip()buf.clear()ifnotbody:returnpath=" > ".join(tfor_,tinstack)# 关键:把标题路径拼进正文,提升可检索性prefix=f"【{path}】\n"ifpathelse""forpiecein_split_long(body,max_chars,overlap):chunks.append(Chunk(text=prefix+piece,metadata={"heading_path":path,"level":stack[-1][0]ifstackelse0},))forlineinlines:ifline.strip().startswith("```"):in_code=notin_code buf.append(line)continuem=Noneifin_codeelseHEADING_RE.match(line)ifm:flush()# 遇到新标题,先把上一节收尾level,title=len(m.group(1)),m.group(2).strip()whilestackandstack[-1][0]>=level:stack.pop()# 同级或更高级标题,弹栈stack.append((level,title))else:buf.append(line)flush()returnchunks

这段代码有三处值得单独说:

  1. flush()放在遇到标题时调用,而不是在循环结束后统一处理。这样每个 chunk 天然以标题为边界,不会出现“上一节的结尾 + 下一节的开头”这种拼接污染。
  2. 标题栈的弹栈条件用>=。假设当前栈是[H1, H2],来了一个 H2,说明新的 H2 和旧的 H2 是兄弟关系,旧的必须弹出;如果来了 H3,则保留,因为它属于当前 H2 的子节点。
  3. prefix拼在每一段前面,而不是只拼在 chunk 的第一个。超长节被二次切分后,每一段都要带上标题路径,否则后半段又会变成“无主正文”。

还有一个进阶玩法值得一试:给每个 chunk 补一句“这段在讲什么”的自动摘要(可以用便宜的小模型批量生成),拼在正文前面再去做嵌入。业界把这叫 contextual retrieval,实测能把 Recall@20 提升 5~15 个点,代价是索引阶段多一轮 LLM 调用。对知识库更新不频繁的场景,这笔钱花得值。

4.3 分块参数怎么定:别拍脑袋,跑网格

chunk_size和overlap没有普适最优值,但有可靠的起点:

  • 中文技术文档:300~600 字/块,overlap 10%~15%。低于 200 字容易丢上下文,高于 800 字会稀释向量语义。
  • FAQ / 条款:一条一问一块,不做合并。
  • 代码文档:按函数/类切,单块不超过 100 行。
  • overlap 不要超过 chunk_size 的 20%,否则相邻块高度重复,检索时会出现“同一个内容占满 top5”的假象,反而挤掉了其他有用信息。

定参数的正确姿势是拿评估集跑网格搜索,而不是看哪篇博客说得对。一个最小化的跑批脚本长这样:

fromstatisticsimportmeandefrecall_at_k(retrieved_ids,gold_ids,k):topk=set(retrieved_ids[:k])returnlen(topk&set(gold_ids))/max(len(gold_ids),1)defevaluate(search_fn,dataset,k=10):scores=[recall_at_k([h["chunk_id"]forhinsearch_fn(it["query"],k)],it["gold_chunk_ids"],k)foritindataset]return{"recall@%d"%k:round(mean(scores),4),"n":len(dataset)}

把(chunk_size, overlap)组合成 4×3 的网格,每组重建索引、跑一遍评估,通常两小时内就能得到属于你自己数据的最优解。经验上,这个网格搜索带来的收益,比换一个更强的嵌入模型还大。

五、召回率调优:从 60% 到 90% 的工程路径

这是最容易被跳过、也最决定成败的一节。调优要按“性价比”排序,从上往下做,做到指标达标就停手。

5.1 第一层:混合召回(收益最大,成本最低)

向量召回 + BM25 关键词召回,各取 top 20,用前面写的rrf_fuse融合。BM25 不需要额外组件:pgvector 场景可以直接用 PostgreSQL 的tsvector全文索引,或者装pg_search/zhparser做中文分词。

-- 中文需要先装分词扩展;英文用内置 'english' 配置即可CREATEINDEXidx_chunks_ftsONdoc_chunksUSINGgin(to_tsvector('simple',content));

这一步在含型号、编号、专有名词的知识库上,Recall@10 通常能提升 15~25 个点,是所有优化里投入产出比最高的。

5.2 第二层:Query 改写(解决“用户不会说人话”)

用户的提问往往很短、带指代、缺上下文。三种改写手段按需选用:

  • 指代消解:多轮对话里把“它的默认值是多少”补全成“XX 组件的超时默认值是多少”。不做这一步,第二轮的召回率会断崖式下跌。
  • 多查询扩展(Multi-Query):让模型把一个问题改写成 3 个不同措辞的版本,分别召回后融合。适合口语化提问多的 C 端场景。
  • HyDE:先让模型“编”一段假答案,再用这段假答案去检索。因为假答案的用词更接近文档语料,实测对短 query 有效,但对事实性极强的问题有引入偏差的风险,谨慎使用。

5.3 第三层:Rerank(提升 Context Precision)

召回阶段追求“不漏”,rerank 阶段追求“精准”。用 cross-encoder(如 bge-reranker 系列)对 query 和每个候选 chunk 做联合打分,把 top 50 精排到 top 8 再送进 Prompt。cross-encoder 逐对计算,慢但准,所以只放在召回之后、条数有限的位置。

这一步的收益往往体现在“答案质量”而不是 Recall 上:噪声被清掉后,模型的忠实度和引用准确率会明显改善,同时 token 成本下降 40% 以上。

5.4 调优效果对照(某 3200 份文档知识库实测)

阶段Recall@10Context Precision端到端准确率
基线:固定分块 + 单路向量61%0.3841%
+ 结构感知分块与标题路径注入74%0.4556%
+ BM25 混合召回 + RRF86%0.5269%
+ Rerank(50 → 8)89%0.8178%
+ Query 指代消解89%0.8381%

注意最后一行:Recall 没涨,但端到端准确率涨了 3 个点。这说明多轮场景下,问题本身没被正确理解时,检索再好也白搭。

5.5 调优时的三个反直觉结论

  1. top_k 不是越大越好。把 top_k 从 10 提到 50,Recall 只涨 2 个点,但 Context Precision 掉 20 个点,端到端反而变差。正确做法是“大召回 + 强重排”,而不是“大召回直接喂”。
  2. 换嵌入模型之前先看分块。很多团队一上来就换更大的 embedding 模型,结果提升微乎其微——因为瓶颈在分块把语义切碎了,模型再好也救不回来。
  3. 评估集要定期扩充。线上真实 query 分布和初期拍脑袋写的评估集差异很大,建议每月从线上日志里采样 badcase 补充进评估集,否则你会在一套过时的标尺上持续“优化”。

六、生产环境踩坑清单(FAQ)

Q1:PDF 解析出来全是乱序,表格变成一列数字,怎么办?
A:优先换解析器而不是硬修。带版式的 PDF(双栏、表格、页眉页脚)用通用文本抽取必然出错。选型上:简单 PDF 用pdfplumber;复杂版式用unstructured的hi_res模式或商用文档解析 API;扫描件走 OCR。解析阶段丢的结构,后面任何环节都补不回来。

Q2:chunk 里到底要不要保留标题?会不会污染向量?
A:要保留,而且要把标题路径拼进正文。标题是天然的语义浓缩,去掉它,正文里的“以上”“如下”就失去了指代对象。担心污染的话,可以把标题放在正文前面而不是混在句子里,嵌入模型对位置是有感知的。

Q3:文档更新了,旧 chunk 怎么处理?
A:按doc_id做版本覆盖:写入新版本时把旧版本置is_deleted = true,查询永远带is_deleted = false。不要物理删除,留出回滚和排查的余地。定期跑一次离线任务清理 30 天前的软删除记录。

**Q4:为什么加了tenant_id过滤之后,召回率突然暴跌?

更多硬核网安与AI工具包,请扫码获取完整源码!
**
A:post-filter 的典型症状,见第三节。把过滤条件下推到WHERE,或开启hnsw.iterative_scan,或把ef_search调到 top_k 的 5

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进