1. 背景:为什么我的RAG系统像“人工智障”
上个月我们给公司内部法律合规助手做了一轮大版本升级,结果发现用户反馈“答非所问”的比例从12%飙升到28%。排查日志后发现,问题出在召回环节——查询“竞业协议中的违约金上限”,返回的chunk内容却是“竞业禁止条款定义”。典型的语义偏移。
翻看当时的RAG链路:
query → embedding(768d) → ES向量检索(topK=20) → 送入LLM
问题非常明显:切块方式太粗糙 + 单向量检索精度不足。本文记录我们如何通过三步骤优化,将准确率拉回并反超。
2. 环境与版本基线
先交代实验环境,方便读者做横向对比:
| 组件 | 版本/参数 | 备注 |
|---|---|---|
| 操作系统 | Ubuntu 22.04 LTS | 8核32G |
| Python | 3.10.12 | |
| langchain | 0.2.14 | 后续改为自研检索组件 |
| Elasticsearch | 8.12.2 | 向量索引HNSW,m=32 |
| Embedding | text2vec-large-chinese (1024d) → bge-m3 (1024d) | 后者支持长文本 |
| Rerank | bge-large-reranker | 默认截断topK=20 |
| LLM | deepseek-chat (qwen_turbo) | 上下文8K |
原始配置:chunk_size=512, overlap=64,Embedding为text2vec。测试集为300条法律问答对,人工标注了标准答案对应的文档段落ID。
3. 第一步:chunk策略调整——从“机械切分”到“语义感知”
3.1 问题定位
我们统计了前100个错误样本,发现47%的错误来源于跨段落切分。比如下面这段:
合同终止后,乙方应在____日内归还甲方提供的全部资料。
若乙方逾期未归还,甲方有权按每日____元标准收取违约金。
原切分方式(512字符硬截断)很可能把“归还期限”和“违约金标准”切到两个chunk里。用户如果问“逾期归还资料怎么罚”,第一个chunk里没有罚则,第二个chunk里丢了“归还”上下文。
3.2 方案设计:递归字符切分 + 标题前置
改用RecursiveCharacterTextSplitter,并做了两个关键调整:
- separators优先级:先按
\n\n(段落),再按\n(行),最后按中文句号。保证语义块完整。 - chunk_size从512降到384,overlap增加到64。为什么降?因为embedding模型对长文本的注意力会分散,小chunk对语义聚焦更友好。
3.3 核心实现
from langchain.text_splitter import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=384,
chunk_overlap=64,
separators=["\n\n", "\n", "。", "!", "?", ";", ","],
keep_separator=True,
)
# 针对法律文本的标题感知增强
def split_with_title_hint(doc):
chunks = text_splitter.split_text(doc)
# 如果文档包含"第X条"或"X、",将其作为chunk前缀
enhanced = []
for chunk in chunks:
title = extract_section_title(chunk) # 简单正则提取
if title:
chunk = f"{title}\n{chunk}"
enhanced.append(chunk)
return enhanced
效果对比(仅改chunk,embedding未变):
| 指标 | 原512固定切分 | 384递归切分+标题增强 |
|---|---|---|
| Top-5 Recall@5 | 67.3% | 74.8% |
| 单条检索耗时 | 420ms | 405ms(切分逻辑影响微乎其微) |
这里有个发现:overlap不是越大越好。试过overlap=128,反而导致重复内容过多,ES查询时命中大量近似重复chunk,实际精确率下降2%。最终定在64。
4. 第二步:embedding模型切换——从text2vec到bge-m3
4.1 为什么换?
text2vec-large-chinese在短文本相似度上表现尚可,但有两个致命问题:
- 领域词汇稀疏:法律术语如“不可抗力条款”“对赌协议”在预训练语料中出现少,导致向量空间挤压。
- 长文本退化:超过256字符后,text2vec的向量质量急剧下降。而我们切分后的chunk平均长度是320字符。
4.2 切换过程与坑
bge-m3是BAAI推出的多语言模型,支持8192长度,且对中文法律文本有优化。切换时遇到一个大坑:
ES索引维度不匹配。text2vec是1024维,bge-m3也是1024维,但向量分布差异巨大。直接替换模型而不重建索引,ES返回的cosine相似度全部在0.2以下——因为旧索引的向量空间和新模型的向量空间不对齐。
必须重建索引。我们写了离线重建脚本:
from elasticsearch7 import Elasticsearch
from sentence_transformers import SentenceTransformer
import numpy as np
es = Elasticsearch(["http://localhost:9200"])
model = SentenceTransformer("/models/bge-m3") # 4.6GB FP16
# 重建索引映射
index_mapping = {
"mappings": {
"properties": {
"content": {"type": "text", "analyzer": "ik_max_word"},
"content_vector": {
"type": "dense_vector",
"dims": 1024,
"index": True,
"similarity": "cosine"
},
"metadata": {"type": "object"}
}
}
}
es.indices.delete(index="legal_chunks", ignore=[400, 404])
es.indices.create(index="legal_chunks", body=index_mapping)
# 批量写入
all_docs = read_all_chunks() # 约2.3万chunk
for i in range(0, len(all_docs), 500):
actions = []
for doc in all_docs[i:i+500]:
vec = model.encode(doc["content"], normalize_embeddings=True)
actions.append({
"_index": "legal_chunks",
"_id": doc["id"],
"_source": {
"content": doc["content"],
"content_vector": vec.tolist(),
"metadata": doc["metadata"]
}
})
es.bulk(operations=actions)
4.3 效果数据
换模型后(chunk策略不变),Top-5 Recall@5从74.8%涨到81.2%。但注意:检索耗时从405ms涨到510ms。原因:bge-m3模型推理时间比text2vec慢约30%(12ms vs 8ms每chunk)。
这里我们做了个妥协:预计算所有chunk的向量并缓存,查询时只对query做一次embedding,避免实时计算文档向量。耗时回落到330ms。
5. 第三步:引入rerank——终极杀器
5.1 为什么不只靠向量检索?
ES的向量检索本质是近邻搜索,召回的是“候选集”,而不是“精确答案”。即使用bge-m3,Top-20召回里依然有大量语义相似但非答案的chunk。举个例子:
- 查询:“员工离职后是否需要归还公司电脑”
- 召回chunk1:“员工离职前需完成工作交接,包括归还办公设备”(相关)
- 召回chunk2:“公司为员工配备的电脑属于公司资产”(不相关但语义相似)
向量检索区分不了这种细粒度差异。而rerank模型本质是cross-encoder,将query和chunk拼接后做二分类打分,精度远超双塔结构的向量检索。
5.2 实现与调参
我们选用bge-large-reranker,输入长度限制512,正好覆盖我们的chunk。关键参数:
- topK_from_retriever = 20:从ES召回20个候选,过多会拖慢rerank耗时。
- topN_after_rerank = 5:重排序后只保留前5个送入LLM。
代码实现:
from transformers import AutoModelForSequenceClassification, AutoTokenizer
import torch
class Reranker:
def __init__(self, model_path="/models/bge-reranker-large"):
self.tokenizer = AutoTokenizer.from_pretrained(model_path)
self.model = AutoModelForSequenceClassification.from_pretrained(model_path)
self.model.eval()
if torch.cuda.is_available():
self.model.cuda()
def rerank(self, query, documents, top_n=5):
pairs = [[query, doc] for doc in documents]
with torch.no_grad():
inputs = self.tokenizer(
pairs, padding=True, truncation=True,
max_length=512, return_tensors="pt"
)
if torch.cuda.is_available():
inputs = {k: v.cuda() for k, v in inputs.items()}
scores = self.model(**inputs).logits.squeeze(-1)
scores = scores.tolist()
# 按得分降序
ranked = sorted(zip(scores, documents), key=lambda x: x[0], reverse=True)
return [doc for _, doc in ranked[:top_n]]
5.3 踩坑记录
坑1:rerank对超长document会截断。如果chunk超过512字符,需要截断或跳过,否则模型会报错或性能下降。我们实际将法律条文中的超长chunk按句号二次切分。
坑2:重排序后的得分分布不均匀。bge-large-reranker的得分范围是0-1,但实际集中在0.7-0.9之间。我们一开始用score > 0.85作为过滤条件,结果很多相关chunk被过滤掉。后来改为相对排序——只取Top-5,不设置绝对阈值。
坑3:GPU显存占用。bge-large-reranker需要约2.5GB显存,如果和LLM共用显存会OOM。我们把它部署在CPU(batch=16),单次rerank 20个chunk耗时150ms,可以接受。
5.4 最终效果
| 阶段 | Top-5 Recall@5 | 检索+rerank耗时 | 用户满意度(抽样100条) |
|---|---|---|---|
| 原始(512切分+text2vec) | 67.3% | 420ms | 61% |
| +chunk优化 | 74.8% | 405ms | 73% |
| +bge-m3 | 81.2% | 330ms | 79% |
| +rerank | 89.1% | 310ms | 88% |
6. 总结:RAG优化的三个层次
- chunk是地基:不要用固定长度切分,至少用递归切分保证语义完整。法律、代码、论文建议用不同separator。
- embedding决定上限:领域数据如果预训练覆盖不够,赶紧换bge-m3或text-embedding-3。但务必重建索引。
- rerank是性价比之王:只增加150ms,准确率提升8个百分点。强烈建议所有RAG系统都加。
最后说点私货:别迷信大模型能力。很多RAG效果差,问题出在召回阶段,而不是生成阶段。先花时间优化检索链路,比疯狂调prompt有效得多。
如果读者遇到类似问题,欢迎在评论区交流。下期准备写“多路召回与混合检索”,敬请期待。