一、背景:一个“能用但不好用”的RAG系统

去年Q3,我接手了一个内部知识库问答系统。技术栈选型是:LangChain 0.0.267 + ChromaDB + OpenAI text-embedding-ada-002 + GPT-3.5-turbo。数据源是2000多份技术文档(PDF、Markdown),总字数约800万。

上线两周后,从日志里统计出用户反馈“答非所问”的case占总数的38%。排查下来,根因集中在三点:

  1. chunk太小:很多文档被切成128 tokens的碎片,一个完整的技术概念被拆到多个chunk里,检索时上下文丢失严重。
  2. Embedding模型在中文技术术语上表现差:比如“微服务熔断降级”这样的短语,ada-002的向量与“服务降级”的余弦相似度只有0.72,而和完全不相关的“数据库索引”却有0.68。
  3. 没有重排(Rerank):向量检索只返回Top-5,但Top-5里经常混入2-3个不相关的chunk,直接喂给LLM导致幻觉。

于是决定做一次系统性优化。

二、环境与版本说明

  • Python 3.10.12
  • LangChain 0.1.12(升级后支持更好的Rerank集成)
  • ChromaDB 0.4.22
  • Embedding模型:BAAI/bge-large-zh-v1.5(HuggingFace)
  • Rerank模型:BAAI/bge-reranker-v2-m3(HuggingFace)
  • LLM:gpt-3.5-turbo-1106
  • 硬件:单卡A100 80G(主要用于Embedding和Rerank推理,实际CPU也能跑)

⚠️ 注意:Rerank模型需要显存约6GB(fp16),如果显存不够可以考虑用bge-reranker-base,精度略低但显存需求减半。

三、方案设计:三步走优化策略

整个优化拆成三个独立实验,每次只改一个变量,用相同的测试集(500个真实用户问题)评估召回准确率(Recall@5)和Top-5命中率。

实验编号 变量 其他参数
实验A chunk_size: 256→512→1024, chunk_overlap: 20,40,80 Embedding固定ada-002,无rerank
实验B Embedding: ada-002 → bge-large-zh-v1.5 chunk_size固定为最佳值,无rerank
实验C 引入rerank模型 前面最佳组合 + rerank

评估指标:
- Recall@5:Top-5检索结果中包含正确答案的比例(正确答案由人工标注)。
- Top-5命中率:Top-5中至少有一个chunk与问题语义相关的比例(宽松指标)。

四、核心实现:chunk策略调整与Embedding切换

4.1 动态chunk策略代码

之前用的固定128 tokens,我改成基于段落结构的分块,并支持可配置的overlap。

# chunk_optimizer.py
from langchain.text_splitter import RecursiveCharacterTextSplitter

def build_splitter(chunk_size: int = 512, chunk_overlap: int = 80):
    """
    使用RecursiveCharacterTextSplitter,按段落、句子、单词层级切分
    适合中文技术文档:优先保留段落完整性
    """
    separators = ["\n\n", "\n", "。", ".", " ", ""]
    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        separators=separators,
        length_function=len,  # 使用字符数而非token数,更保守
        is_separator_regex=False
    )
    return text_splitter

# 使用示例
splitter = build_splitter(chunk_size=512, chunk_overlap=80)
docs = splitter.split_documents(raw_documents)
print(f"原始文档数: {len(raw_documents)} -> chunk数: {len(docs)}")

效果数据
- chunk_size=256时,Recall@5=67%,因为关键信息被切碎,检索不到。
- chunk_size=512时,Recall@5=74%,overlap=80比40提升3%。
- chunk_size=1024时,Recall@5=71%,因为chunk太大,噪声增多。
最终选chunk_size=512, overlap=80

4.2 Embedding模型切换代码

切换到bge-large-zh-v1.5,需要安装sentence-transformers 2.2.2。注意:bge模型在HuggingFace上发布时,官方推荐使用querypassage前缀来区分检索场景。

# embedding_switch.py
from sentence_transformers import SentenceTransformer
from langchain.embeddings import HuggingFaceBgeEmbeddings

model_name = "BAAI/bge-large-zh-v1.5"
model_kwargs = {'device': 'cuda:0'}
encode_kwargs = {'normalize_embeddings': True}  # 余弦相似度需要归一化

bge_embeddings = HuggingFaceBgeEmbeddings(
    model_name=model_name,
    model_kwargs=model_kwargs,
    encode_kwargs=encode_kwargs,
    query_instruction="为这个句子生成表示以用于检索相关文章:",  # bge官方前缀
    embed_instruction="",
)

# 注意:LangChain的HuggingFaceBgeEmbeddings会自动处理前缀
# 但如果你直接调用model.encode(),需要手动加前缀
# 测试相似度:
query = "微服务熔断降级机制"
passage = "当服务调用失败率达到阈值时,熔断器打开,后续请求直接返回降级结果"
query_vec = bge_embeddings.embed_query(query)
passage_vec = bge_embeddings.embed_documents([passage])[0]
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
sim = cosine_similarity([query_vec], [passage_vec])[0][0]
print(f"bge-large 相似度: {sim:.4f}")  # 输出0.9234
# 而之前ada-002只有0.72

效果数据
- 切换后Recall@5从74%提升到83%,提升9个百分点。
- 中文技术术语的向量相似度平均提高0.15-0.2,尤其是“熔断”“降级”“分布式事务”这类词。

五、关键优化:引入Rerank重排

即使Embedding切换后,Top-5里依然有25%的case包含不相关chunk。Rerank的作用就是:对Top-30的候选重新打分,精排Top-5。

5.1 Rerank实现

bge-reranker-v2-m3,注意该模型直接输出相关性分数(0~1),不需要二次归一化。

# reranker.py
from transformers import AutoModelForSequenceClassification, AutoTokenizer
import torch

class BGEReranker:
    def __init__(self, model_name: str = "BAAI/bge-reranker-v2-m3"):
        self.device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
        self.tokenizer = AutoTokenizer.from_pretrained(model_name)
        self.model = AutoModelForSequenceClassification.from_pretrained(
            model_name,
            trust_remote_code=True,
            torch_dtype=torch.float16  # 减少显存占用
        ).to(self.device)
        self.model.eval()

    def rerank(self, query: str, passages: list[str], top_k: int = 5) -> list[tuple[int, float]]:
        pairs = [[query, passage] for passage in passages]
        with torch.no_grad():
            inputs = self.tokenizer(
                pairs,
                padding=True,
                truncation=True,
                max_length=512,
                return_tensors="pt"
            ).to(self.device)
            scores = self.model(**inputs).logits.squeeze(-1).cpu().numpy()
        # 按分数降序排列
        sorted_indices = scores.argsort()[::-1][:top_k]
        return [(idx, scores[idx]) for idx in sorted_indices]

# 使用示例
reranker = BGEReranker()
query = "如何配置Hystrix线程池大小?"
candidates = chroma_collection.similarity_search(query, k=30)  # 先召回30个
passages = [doc.page_content for doc in candidates]
reranked = reranker.rerank(query, passages, top_k=5)
for idx, score in reranked:
    print(f"分数: {score:.4f}, 内容: {passages[idx][:60]}...")

关键参数
- 召回数量K=30(实验发现K=50时rerank耗时翻倍但精度只提升0.5%)。
- 推理batch_size=1(显存受限),每个query耗时约0.8秒。

5.2 踩坑:Rerank模型输入长度

bge-reranker-v2-m3最大输入长度是512 tokens。我一开始没做截断,遇到超长chunk(1024 tokens)时直接报错。解决方案:在max_length=512时自动截断,并记录被截断的chunk数,后来发现只有不到3%的chunk被截断,对精度影响可忽略。

六、效果数据与对比

最终在500个测试问题上跑出以下结果:

优化阶段 Recall@5 Top-5命中率 平均检索耗时 用户满意度(抽样)
原始(128 token + ada-002 + 无rerank) 62% 73% 0.3s 62%
优化chunk(512/80) 74% 82% 0.3s 76%
+切换bge-large-zh-v1.5 83% 89% 0.5s(模型更大) 85%
+引入rerank 91% 96% 1.3s(含rerank 0.8s) 94%

结论
- chunk策略调整是成本最低、收益最明显的步骤(+12%)。
- Embedding切换带来+9%提升,但模型推理时间增加0.2秒,可接受。
- Rerank是最后一块拼图,+8%提升,但延迟增加0.8秒。对于实时问答场景,可考虑用异步任务或缓存热点query。

七、总结与后续方向

这次优化让我深刻体会到三个原则:

  1. chunk策略是第一道防线:不要迷信大模型,信息切碎了神仙也救不了。结合文档结构(标题、段落)做自适应分块比固定大小好很多。
  2. Embedding模型选择要匹配语料:通用模型在垂直领域表现差,bge系列在中文和英文技术文档上明显优于OpenAI的通用模型,且可以私有化部署。
  3. Rerank是性价比最高的精排手段:只增加80%的检索耗时(0.8s/query),但把Top-5命中率从89%提高到96%,几乎是“最后一公里”的保证。

后续计划:
- 尝试用Cohere rerank(付费但精度更高)和bge-reranker-v2做对比。
- 引入混合检索(向量+BM25),针对长尾术语做互补。
- 将chunk策略改为基于语义相似度的合并,避免硬切分。

最后,贴一张优化前后的用户反馈词云对比图(略,实际博客会放图)。如果大家有更好的chunk策略或rerank方案,欢迎评论区交流。

(全文完)