一、问题背景:一个“看起来能用”的RAG系统

我们做的是一个面向内部技术文档的问答助手,知识库大约 1.2 万篇文档,主要是 Markdown 和 PDF,总 token 量约 480 万。第一版系统用最朴素的方案搭起来:

  • 固定长度切分:chunk_size=512,chunk_overlap=50
  • Embedding:OpenAI text-embedding-ada-002
  • 向量库:Milvus 2.3.4,IVF_FLAT 索引
  • 检索:Top-K=5 直接拼进 prompt
  • LLM:GPT-3.5-turbo

上线两周,我们人工抽检了 300 条真实 query,结果很难看:

指标 数值
答非所问比例 23%
Top-5 召回率(人工判定) 61%
平均响应延迟 2.8s
用户追问率 31%

问题很明确:检索环节没把对的片段找出来。LLM 本身没问题,是喂给它的上下文不对。于是我们定了三轮优化:chunk 策略 → embedding 模型 → rerank。

二、环境与版本

先把环境钉死,避免“在我机器上能跑”:

Python 3.10.13
langchain 0.1.0
langchain-community 0.0.10
pymilvus 2.3.4
milvus 2.3.4 (standalone, docker)
sentence-transformers 2.2.2
FlagEmbedding 1.2.5
torch 2.1.2 + cu121

硬件:单卡 A10 24G,Milvus 和推理服务在同一台机器上(测试环境,生产是分开的)。

三、方案设计:三轮迭代

第一轮:Chunk 策略

原来的固定 512 字符切分有两个致命问题:

  1. 切断语义:一个完整的函数说明被从中间切开,前半段在 chunk A,后半段在 chunk B,检索时只召回一半。
  2. 噪声多:Markdown 里的表格、代码块被切得稀碎,embedding 出来的向量语义很糊。

我们改成 语义分段 + 滑动窗口:

  • 先按 Markdown 标题层级(#、##、###)切成 section
  • 如果 section 超过 800 token,再按句子边界切,chunk_size=800,chunk_overlap=150
  • 代码块整体保留,不切
  • 每个 chunk 前面拼上标题路径,比如 [部署指南 > Docker > 网络配置]

第二轮:Embedding 模型切换

text-embedding-ada-002 是通用模型,中文技术文档上表现一般。我们换成 bge-large-zh-v1.5(本地部署,1024 维),并加上了 BGE 官方推荐的 query instruction:为这个句子生成表示以用于检索相关文章:。

第三轮:引入 Rerank

向量检索是双塔模型,query 和 doc 分别编码,交互不够。我们引入 bge-reranker-large 做 cross-encoder 精排:先向量召回 Top-20,再 rerank 取 Top-5。

四、核心实现

4.1 语义切分

```python
import re
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.schema import Document

def semantic_split(md_text: str, source: str):
# 按标题层级切 section
sections = re.split(r'\n(?=#{1,3} )', md_text)
splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=150,
separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""],
length_function=lambda x: len(x), # 按字符近似 token
)
docs = []
for sec in sections:
if not sec.strip():
continue
# 提取标题路径
title_match = re.match(r'(#{1,3}) (.+)', sec)
title_path = title_match.group(2).strip() if title_match else ""
# 代码块整体保留
if "``" in sec and len(sec) Docker > 网络配置] 前缀后,短 query(如“docker 网络”)的召回明显变好,因为标题里就有关键词。

六、效果数据

三轮迭代后,在同一批 300 条 query 上人工评估:

方案 Top-5 召回率 Top-3 命中率 MRR 平均延迟
基线(512 固定 + ada-002) 61.0% 68.3% 0.712 2.80s
+ 语义切分 74.0% 79.7% 0.768 2.85s
+ bge-large-zh-v1.5 82.3% 86.0% 0.854 2.91s
+ bge-reranker-large 89.7% 91.3% 0.901 2.96s

端到端延迟只增加了 160ms(其中 rerank 47ms,embedding 推理约 100ms),但 Top-3 命中率提升了 23 个百分点。用户追问率从 31% 降到 12%。

七、总结

RAG 系统的优化,检索质量是天花板。LLM 再强,喂错上下文也白搭。我们这轮优化的优先级是:

  1. Chunk 策略:语义完整 > 长度均匀,标题路径拼接是低成本高收益。
  2. Embedding 模型:中文场景下 BGE 系列明显优于 ada-002,且本地部署成本可控。
  3. Rerank:cross-encoder 是召回率的最后一道保险,延迟代价可以接受。

下一步我们准备试 query 改写(HyDE)和混合检索(BM25 + 向量),目标是把 Top-3 命中率推到 95%。如果有类似场景的同学,欢迎交流。