一、问题背景:一个"看起来能用"的RAG系统
三个月前我接手了公司内部知识库问答系统。原始版本是典型的"周末项目":LangChain + OpenAI + Milvus,文档一股脑按512字符硬切,embedding用text-embedding-ada-002,检索Top-3直接丢给GPT-3.5-turbo生成答案。
上线第一周就翻车了。用户问"报销流程需要哪些材料",系统返回的是"年假申请流程"——因为两个文档在向量空间里距离太近。更离谱的是问"服务器重启步骤",模型一本正经地编了一段不存在的命令。
我拉了一个1200条真实问题的评测集,人工标注了标准答案所在的文档片段。跑出来的基线数据很难看:
| 指标 | 数值 |
|---|---|
| Top-5召回率 | 62.3% |
| Top-1命中率 | 41.8% |
| 端到端准确率 | 58.7% |
| 平均检索延迟 | 42ms |
| 平均总延迟 | 1.8s |
问题定位很清晰:召回阶段就漏了,后面生成再强也白搭。于是开始了为期三周的优化。
二、环境与版本
先把环境固定下来,避免"优化完发现是版本问题"的尴尬:
Python 3.11.6
langchain 0.1.12
langchain-community 0.0.28
pymilvus 2.3.4
sentence-transformers 2.6.1
FlagEmbedding 1.2.10
torch 2.2.1 (CUDA 12.1)
openai 1.14.2
硬件:单卡A10 24G,Milvus单机部署(16C32G)。文档库规模:约4.2万篇技术文档与制度文件,切分后约38万个chunk。
三、方案设计:三阶段递进优化
我没有一次性全改,而是分三阶段,每阶段单独评测,这样才能知道到底是哪一步起了作用。
阶段一:Chunk策略调整
- 从固定512字符 → 递归字符切分(按\n\n、\n、。、!、?优先级)
- 加入语义边界检测:用句向量相似度低于阈值处强制断开
- chunk size 512 → 384,overlap 50 → 80
阶段二:Embedding模型切换
- text-embedding-ada-002(1536维)→ bge-large-zh-v1.5(1024维)
- 中文场景下BGE明显更优,且本地部署省成本
阶段三:引入Rerank
- 检索Top-20 → bge-reranker-large重排 → 取Top-5
- 这是提升最明显的一步
四、核心实现
4.1 语义+递归混合切分
单纯递归切分对技术文档不友好,比如代码块会被切碎。我加了一层语义边界检测:
import re
import numpy as np
from sentence_transformers import SentenceTransformer
from langchain.text_splitter import RecursiveCharacterTextSplitter
class SemanticChunker:
def __init__(self, model_name="BAAI/bge-large-zh-v1.5",
threshold=0.72, max_chunk=384, overlap=80):
self.model = SentenceTransformer(model_name)
self.threshold = threshold
self.max_chunk = max_chunk
self.overlap = overlap
self.splitter = RecursiveCharacterTextSplitter(
chunk_size=max_chunk,
chunk_overlap=overlap,
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
keep_separator=True,
)
def _split_sentences(self, text):
# 保留代码块完整性
parts = re.split(r'(\n\n+)', text)
sents = []
for p in parts:
if p.strip():
sents.extend(re.split(r'(? self.max_chunk:
chunks.append(cur)
# overlap:把上一块尾部拼到新块
tail = cur[-self.overlap:] if len(cur) > self.overlap else cur
cur = tail + nxt
else:
cur += nxt
if cur:
chunks.append(cur)
# 二次递归切分兜底
final = []
for c in chunks:
final.extend(self.splitter.split_text(c) if len(c) > self.max_chunk else [c])
return final
阈值0.72是调出来的:太低切太碎,太高切不动。我试过0.65/0.70/0.72/0.75/0.80,0.72的召回最好。
4.2 检索 + Rerank流水线
Milvus检索粗排Top-20,再用bge-reranker-large精排:
from FlagEmbedding import FlagReranker
from pymilvus import Collection
from sentence_transformers import SentenceTransformer
class RagRetriever:
def __init__(self, collection: Collection, top_k=20, rerank_top=5):
self.collection = collection
self.embedder = SentenceTransformer("BAAI/bge-large-zh-v1.5")
self.reranker = FlagReranker("BAAI/bge-reranker-large",
use_fp16=True)
self.top_k = top_k
self.rerank_top = rerank_top
# BGE推荐的query指令前缀
self.query_prefix = "为这个句子生成表示以用于检索相关文章:"
def _embed_query(self, query: str):
return self.embedder.encode(
self.query_prefix + query,
normalize_embeddings=True
).tolist()
def retrieve(self, query: str):
# 1) 向量粗排
self.collection.load()
res = self.collection.search(
data=[self._embed_query(query)],
anns_field="embedding",
param={"metric_type": "IP", "params": {"ef": 128}},
limit=self.top_k,
output_fields=["text", "doc_id", "chunk_id"],
)
candidates = [
{"text": h.entity.get("text"),
"doc_id": h.entity.get("doc_id"),
"score": float(h.distance)}
for h in res[0]
]
if not candidates:
return []
# 2) Rerank精排
pairs = [[query, c["text"]] for c in candidates]
scores = self.reranker.compute_score(pairs, normalize=True)
for c, s in zip(candidates, scores):
c["rerank_score"] = float(s)
candidates.sort(key=lambda x: x["rerank_score"], reverse=True)
return candidates[:self.rerank_top]
注意query_prefix这个细节:BGE系列在检索时给query加指令前缀,能涨2-3个点,很多人不知道。
Milvus的索引参数:IVF_FLAT,nlist=4096,检索时nprobe=32。试过HNSW,M=32, efConstruction=256,召回略高但内存吃紧,最终选了IVF_FLAT。
五、踩坑与优化
坑1:切分后chunk长度分布严重不均
一开始语义切分出来的chunk有的20字,有的800字。太短的噪声大,太长的embedding被截断(BGE最大512 token)。解决办法是设置min_chunk=80,低于这个值就与相邻块合并。
坑2:Reranker拖慢响应
bge-reranker-large在A10上跑20个pair耗时约140ms(fp16)。如果并发上来会成瓶颈。我的做法是:把rerank封装成独立服务,用FastAPI + batch推理,单次请求最多等50ms攒批。
坑3:BGE模型首次加载慢
冷启动加载bge-large-zh-v1.5要8-10秒。生产环境用预热脚本,服务启动时先encode一条假数据。
坑4:Milvus的IP度量与归一化
用IP(内积)时embedding必须归一化,否则分数无意义。BGE的normalize_embeddings=True别漏。
坑5:overlap导致重复召回
overlap=80时,相邻chunk会共享尾部内容,Top-5里可能出现两个高度相似的chunk。加了一个简单的去重:如果两个chunk的Jaccard相似度>0.85,只保留rerank分高的。
六、效果数据
每阶段单独评测,1200条测试集,指标如下:
| 阶段 | Top-5召回 | Top-1命中 | 端到端准确 | 检索延迟 | 总延迟 |
|---|---|---|---|---|---|
| 基线(512固定切分+ada-002) | 62.3% | 41.8% | 58.7% | 42ms | 1.8s |
| +语义混合切分 | 71.5% | 52.4% | 66.2% | 48ms | 1.9s |
| +bge-large-zh-v1.5 | 80.1% | 63.7% | 74.5% | 55ms | 1.9s |
| +bge-reranker-large | 89.4% | 76.2% | 84.1% | 195ms | 2.1s |
几个观察:
1. 切分策略贡献了约9个点,比我想象的重要。固定切分把完整段落切碎是最大问题。
2. Embedding切换贡献8.6个点,中文场景下BGE对ada-002是碾压式的。
3. Rerank贡献9.3个点,但代价是延迟从55ms涨到195ms。不过端到端只涨了200ms,因为生成阶段占大头。
4. Top-1命中率提升幅度(41.8%→76.2%)比Top-5更明显,说明rerank对"把正确结果顶到第一"特别有效。
成本方面:ada-002按token收费,38万chunk的embedding花了约$18;切到BGE后本地推理,一次性投入电费可忽略。Reranker增加了GPU占用,但省下了不少GPT-4的调用(准确率上去了,不用靠大模型兜底)。
七、总结
这次优化的核心体会是:RAG系统的瓶颈通常在检索,不在生成。很多人一上来就换GPT-4,其实先把召回做好,GPT-3.5也能给出不错答案。
如果只能做一件事,我会选引入rerank——投入产出比最高,代码改动最小,效果立竿见影。但前提是粗排的Top-20里得包含正确答案,所以chunk策略和embedding是地基,不能跳过。
后续还想试的方向:
- 用bge-m3替换当前embedding,支持多语言+稀疏稠密混合检索
- 引入query改写(HyDE或query2doc),解决短查询召回差的问题
- 用ColBERT式的late interaction做粗排,可能比双塔更准
代码都跑通了,但生产环境永远有新的边界情况。RAG没有银弹,只有不断迭代。