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在短文本相似度上表现尚可,但有两个致命问题:

  1. 领域词汇稀疏:法律术语如“不可抗力条款”“对赌协议”在预训练语料中出现少,导致向量空间挤压。
  2. 长文本退化:超过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优化的三个层次

  1. chunk是地基:不要用固定长度切分,至少用递归切分保证语义完整。法律、代码、论文建议用不同separator。
  2. embedding决定上限:领域数据如果预训练覆盖不够,赶紧换bge-m3或text-embedding-3。但务必重建索引。
  3. rerank是性价比之王:只增加150ms,准确率提升8个百分点。强烈建议所有RAG系统都加。

最后说点私货:别迷信大模型能力。很多RAG效果差,问题出在召回阶段,而不是生成阶段。先花时间优化检索链路,比疯狂调prompt有效得多。

如果读者遇到类似问题,欢迎在评论区交流。下期准备写“多路召回与混合检索”,敬请期待。