**

一、问题背景:检索不准,后续全白搭

事情是这样的。上个月接了个需求,要给某律所的合同库做智能问答。文档量不大,就2000多份PDF,但每份都是几十页的复杂排版。第一版系统上线后,用户反馈很直接:“问它‘违约金上限怎么约定’,它给我返回一份关于‘合同解除’的条款,这玩意儿能用?”

我拉了一下日志,发现检索模块的Recall@5只有78.3%。这意味着五分之一的问题,正确答案压根没进候选集。更麻烦的是,很多返回的chunk是半句话,上下文被拦腰截断,导致生成阶段胡编乱造。

问题定位在三个层面:

  1. chunk策略太粗暴:直接按512字符硬切,很多法律条款被切断,语义完整性荡然无存。
  2. embedding模型太弱:当时用的BAAI/bge-small-zh-v1.5,对于法律这种专业术语密集的文本,表征能力明显不足。
  3. 缺少精排环节:向量检索返回Top-20后直接送入LLM,相关段落和无关段落混在一起,干扰了生成。

二、环境与版本:先把底交代清楚

优化前的基础环境如下:

  • Python 3.10.13
  • langchain 0.1.12
  • sentence-transformers 2.3.1
  • ChromaDB 0.4.24
  • 部署GPU:单张A10(24GB显存)
  • Embedding模型:BAAI/bge-small-zh-v1.5(512维)
  • LLM:qwen1.5-14b-chat(vLLM部署)

注意,这里用的是ChromaDB纯本地部署,没上ES或Milvus,主要图省事。但后续发现,纯向量检索在这种场景下确实不够精准,必须加rerank。

三、方案设计:三刀并行,缺一不可

我的优化策略分三步走,每一步都有独立的验证指标:

  1. chunk策略重设计:放弃固定长度切分,改为基于段落边界和语义完整性的递归切块。核心思路是:先按标题和换行符分割成大块,再对超过chunk_size的大块利用分隔符优先级递归切分。
  2. embedding模型升级:从bge-small换到bge-large-zh-v1.5(1024维)。虽然向量维度翻倍,存储和计算开销增加,但A10显存够用,检索精度提升明显。
  3. 引入rerank精排:在向量召回Top-50的基础上,用bge-reranker-base对候选chunk与query做交叉编码打分,取Top-5送入LLM。

整个链路变成:文档 → 智能切块 → 向量化 → 向量召回Top-50 → 重排取Top-5 → LLM生成

四、核心实现:三段关键代码

4.1 基于语义边界的递归切块

from langchain.text_splitter import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=400,          # 每个块的目标token数(中文约400字)
    chunk_overlap=80,        # 块间重叠,保证跨块语义连续
    separators=["\n\n", "\n", "。", ";", ",", " ", ""],  # 优先级从高到低
    is_separator_regex=False
)

# 假设text是从PDF中抽取的全文
chunks = text_splitter.split_text(text)

# 自定义后处理:剔除长度小于50字符的碎片块
valid_chunks = [c for c in chunks if len(c) > 50]

关键点在于separators列表的优先级。先尝试按段落(\n\n)切,不行再按句子()切,最后才按字符硬切。这样能最大程度保证每个chunk是语义完整的单元。

4.2 Embedding模型切换

from sentence_transformers import SentenceTransformer
from chromadb.utils import embedding_functions

# 旧模型(512维)
# model_name = "BAAI/bge-small-zh-v1.5"

# 新模型(1024维)
model_name = "BAAI/bge-large-zh-v1.5"
model = SentenceTransformer(model_name, device="cuda")

# 自定义embedding函数,适配ChromaDB
class CustomEmbeddingFunction(embedding_functions.EmbeddingFunction):
    def __call__(self, input):
        # 注意:bge模型需要加查询指令前缀(用于检索场景)
        if isinstance(input, str):
            input = ["为这个句子生成表示以用于检索相关文章:" + input]
        else:
            input = ["为这个句子生成表示以用于检索相关文章:" + t for t in input]
        embeddings = model.encode(input, normalize_embeddings=True)
        return embeddings.tolist()

embed_fn = CustomEmbeddingFunction()

这里有个大坑:bge系列模型在检索场景下,query必须加前缀指令,否则效果退化严重。我一开始没加,直接用裸query去检索,结果Recall@5反而掉了2个点。

4.3 Rerank引入

from FlagEmbedding import FlagReranker

# 加载重排模型
reranker = FlagReranker('BAAI/bge-reranker-base', use_fp16=True)

def rerank_documents(query, documents, top_k=5):
    """
    documents: list[str] 向量召回后的候选chunk列表
    """
    pairs = [[query, doc] for doc in documents]
    scores = reranker.compute_score(pairs, normalize=True)

    # 按分数降序排列,取top_k
    sorted_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
    reranked_docs = [documents[i] for i in sorted_idx[:top_k]]
    return reranked_docs

# 使用示例
# query = "违约金的上限如何约定?"
# candidates = vector_store.similarity_search(query, k=50)  # 先召回50个
# final_docs = rerank_documents(query, [c.page_content for c in candidates])

bge-reranker-base是一个交叉编码器,对query和doc的拼接做二分类打分,虽然比双塔慢,但精度高。在A10上,50个候选重排一次约需300ms,完全可接受。

五、踩坑与优化:不是所有的升级都是正向的

坑1:chunk_size翻倍后,召回反而变差

我一开始把chunk_size从400调到800,想着上下文更完整。结果Recall@5跌了4个百分点。分析后发现,chunk变大导致向量表征被稀释——一个chunk里包含太多不相关主题,embedding被平均掉了。后来用400字作为基准,配合80字重叠,效果最稳。

坑2:原embedding模型没删干净

切换模型时,ChromaDB里的旧向量还留着。由于新旧模型维度不同,直接查会报错。必须重建collection:

import chromadb
client = chromadb.PersistentClient(path="./chroma_db")
try:
    client.delete_collection("legal_docs")
except:
    pass
collection = client.create_collection(
    name="legal_docs",
    embedding_function=embed_fn,
    metadata={"hnsw:space": "cosine"}
)

坑3:rerank引入后,延迟暴增

第一次接入rerank时,我傻乎乎地对Top-50全部重排,单次查询总耗时从800ms涨到1.6s。后来优化成两步:先用向量召回Top-20,再对Top-20做重排取Top-5。延迟只增加200ms,精度几乎无损失。

六、效果数据:用数字说话

整个优化过程历时三天,每步的验证结果如下:

配置 Recall@5 Recall@10 平均检索延迟 生成答案可接受率*
初始方案(512字符+small+无rerank) 78.3% 85.1% 620ms 71%
切块优化后(400字+语义边界) 84.2% 89.7% 640ms 76%
+ embedding切换(large-zh) 91.5% 94.8% 720ms 83%
+ rerank(Top-20重排取5) 94.6% 96.2% 920ms 89%

*可接受率:由两名标注员对100个问答对独立打分,取一致结果。

最终线上效果:

  • 用户主要问题的首答准确率明显提升,投诉明显减少。
  • 幻觉现象减少,因为送入LLM的上下文更聚焦。
  • 单次查询成本几乎没变,因为虽然embedding算力翻倍,但rerank过滤后,LLM的输入token数平均减少了30%。

七、总结:RAG优化没有银弹

这次优化的核心体会是:RAG的瓶颈往往不在生成,而在检索。而检索质量取决于三件事:怎么切块、用什么向量、要不要精排。

  • 切块要遵循语义完整性,但也不要太大,400字左右对中文法律文本是个不错的平衡点。
  • Embedding模型能上大就上大,bge-large比small在专业领域强了不止一个档次。
  • Rerank是性价比最高的精排手段,尤其适合候选集较大、噪声较多的场景。

当然,这套方案不是万能的。如果你的场景是实时聊天,延迟敏感,可能需要考虑用更轻量的rerank模型或减少候选集。但总体来说,这套组合拳在知识库问答场景下有很强的普适性。

代码已开源在项目的rag_optimization分支,有兴趣的可以拉下来跑一跑。有问题欢迎评论区交流。