一、问题背景:为什么基线RAG像“断章取义”

项目是给某保险公司的产品手册做问答助手,文档是80多页的Markdown,包含产品责任、免责条款、理赔流程。基线的做法很粗暴:全文按256字符硬切,重叠32字符,然后用OpenAI text-embedding-ada-002转成向量存FAISS,检索Top5拼进Prompt让GPT-3.5回答。

测试集是32道由业务专家出的题,比如“如果被保人在等待期内确诊甲状腺结节,是否赔付?”和“意外医疗的报销比例是否包含自费药?”。基线效果很差,发现问题集中在三类:

  1. 语义被截断:256字符硬切经常把一个完整的“责任免除”段落从中间切断,导致检索到的chunk只有半句话。
  2. embedding领域适应性差:ada-002对中文保险术语理解偏弱,“等待期”“免赔额”“减额交清”这种词检索时经常匹配到无关段落。
  3. Top5里真正有用的只有1-2个,但生成时被噪音chunk干扰,模型容易把无关条款混进答案。

当时觉得不是模型问题,是检索链路的问题。所以决定从头开始一项项优化。

二、环境与版本:本地化部署是前提

先交代一下环境,方便读者复现对比:

- Python 3.10.12
- langchain 0.1.0(只用它的Document抽象,不玩高级链)
- sentence-transformers 2.5.1(bge模型加载)
- FAISS CPU版 1.8.0.post1
- OpenAI GPT-3.5-turbo-0125(生成用,temperature=0)
- 文本切分:langchain.text_splitter + 自定义MarkdownHeaderSplitter
- Embedding候选:text-embedding-ada-002(维度1536) vs bge-large-zh-v1.5(维度1024)
- Rerank模型:bge-reranker-base(本地,约1.1GB显存)

机器是单张RTX 3090,24GB显存够跑bge-large和reranker同时驻留内存。如果资源紧,bge-base-zh-v1.5是更省的选择,但效果会差2-3个点,后面数据里会提到。

三、方案设计:从“硬切”到“语义切块”

我的第一个优化点很明确——放弃固定长度切分,改为按文档结构切分。保险公司的手册本身就是Markdown的,有明确的层级:

  • # 产品总览
  • ## 保险责任 / 责任免除 / 理赔流程
  • ### 具体条款编号,比如“2.3 等待期”

固定长度切分完全无视了这个结构,我写了一个自定义Splitter,利用Markdown标题层级来做语义边界的锚点。核心逻辑是:

  1. 先按#一级标题把文档切成大块。
  2. 在块内再按##二级标题切。
  3. 如果某个二级标题下的内容超过512字符(特意调大了,因为保险条款经常是一整段),再按句子边界(。!?)做软切分,但保证每个chunk尽量完整包含一个条款。

这样做的结果是chunk数量从原来的1800多个降到700多个,但每个chunk的信息密度大幅提升。比如“责任免除”这个二级标题下的所有条款不会被切到另一个chunk里去了。

四、核心实现:自定义Markdown切分器与Embedding切换

先看第一个关键代码,自定义切分器(基于langchain的MarkdownHeaderTextSplitter改造,加了句子兜底逻辑):

from langchain.text_splitter import TextSplitter
import re

class InsuranceMarkdownSplitter(TextSplitter):
    def __init__(self, max_chunk_size=512, min_chunk_size=128):
        super().__init__()
        self.max_chunk_size = max_chunk_size
        self.min_chunk_size = min_chunk_size

    def split_text(self, text: str) -> list[str]:
        # 第一步:按二级标题切分(保留标题文本在chunk内)
        sections = re.split(r'(?m)^(##\s.*)$', text)
        chunks = []
        for i in range(1, len(sections), 2):
            header = sections[i]
            body = sections[i+1] if i+1  self.max_chunk_size and current_chunk:
                        if len(current_chunk) >= self.min_chunk_size:
                            chunks.append(current_chunk.strip())
                        current_chunk = ""
                    current_chunk += sent
                if current_chunk and len(current_chunk) >= self.min_chunk_size:
                    chunks.append(current_chunk.strip())
        return chunks

注意这里有个细节:我把##标题作为chunk的开头保留,这样embedding时能感知到当前内容所属的条款域,对“等待期”这种词来说,标题里的“责任免除”上下文很重要。

然后是embedding切换。我放弃了OpenAI的ada-002,改用智源的bge-large-zh-v1.5,有两个原因:一是本地部署延迟从300ms降到20ms,二是它对中文长文本的语义匹配在MTEB榜单上明显优于ada-002。切换时有个大坑:oda-002的维度是1536,bge是1024,如果直接覆盖写入同一个FAISS索引文件,会报维度不匹配错误。必须重建索引:

from sentence_transformers import SentenceTransformer
import faiss
import numpy as np

# 加载bge模型(本地)
model = SentenceTransformer('BAAI/bge-large-zh-v1.5', device='cuda')
# 注意:bge在encode时如果不加query指令,检索效果会下降5%左右
query_instruction = "为这个句子生成表示以用于检索相关文章:"

def build_index(all_chunks: list[str]):
    # 为所有chunk生成向量,bge不需要加指令,只有query需要
    chunk_vectors = model.encode(all_chunks, batch_size=32, 
                                 normalize_embeddings=True, device='cuda')
    index = faiss.IndexFlatIP(1024)  # 向量维度变成了1024
    index.add(chunk_vectors)
    faiss.write_index(index, 'insurance_bge.index')
    return index

def search(query: str, index, top_k=10):
    # 关键:查询端要加指令前缀
    q_vector = model.encode([query_instruction + query], 
                            normalize_embeddings=True, device='cuda')
    scores, indices = index.search(q_vector, top_k)
    return scores[0], indices[0]

这个normalize_embeddings=True很重要,因为FAISS的IndexFlatIP是内积相似度,如果不归一化,长文档得分天然更高,会导致偏向长chunk,召回质量下降。

五、引入Rerank:从Top10到Top3的精排策略

切分和embedding改进后,准确率从61.3%提到了74.2%,但还不够。我观察到一个现象:Top10召回里确实包含了正确答案,但答案排在5-10位,直接取Top5会漏掉。

最初的方案是把Top5改成Top10让GPT-3.5自己选,结果上下文变长后模型反而更容易被噪音干扰,准确率掉到69%。所以决定引入Rerank阶段:先用bge检索Top10(粗排),再用bge-reranker-base对query和这10个chunk做精排,取Top3喂给LLM。

核心实现:

from BCEmbedding import RerankerModel

reranker = RerankerModel(model_name_or_path="BAAI/bge-reranker-base", device='cuda')

def rerank_and_generate(query: str, top_k=3):
    # 1. 粗排取Top10
    _, indices = search(query, index, top_k=10)
    candidates = [all_chunks[i] for i in indices]

    # 2. 精排:对(query, chunk)对打分,返回相关性分数
    pairs = [[query, doc] for doc in candidates]
    scores = reranker.compute_score(pairs)

    # 3. 按分数降序,取Top3
    sorted_pairs = sorted(zip(candidates, scores), 
                          key=lambda x: x[1], reverse=True)
    top3 = [doc for doc, score in sorted_pairs[:top_k]]

    # 4. 拼Prompt给LLM
    context = "\n\n---\n\n".join(top3)
    prompt = f"基于以下保险条款片段回答问题,不要编造:\n\n{context}\n\n问题:{query}"
    # ... 调用GPT-3.5

六、踩坑与优化:阈值设定、延迟与token消耗

引入rerank后,效果提升明显但遇到几个具体问题,逐个记录:

1. Rerank分数分布很集中。bge-reranker-base的输出分数在0.001到0.95之间,但不同query的分数分布差异极大。有的query最高分0.5,有的0.9。如果设固定阈值比如0.3,有的query会把低分垃圾chunk放进来。最终我放弃固定阈值,改为只看相对排序取Top3,不设绝对分数门槛。但增加了一个兜底:如果Top1的分数低于0.1,直接回答“根据现有资料无法回答”,而不是硬编。这个操作把幻觉率从11次降到3次。

2. 检索延迟从50ms涨到350ms。粗排+精排多了一步,但换来的是生成质量提升。为了补偿延迟,我加了缓存:对完全相同的query做24小时LRU缓存,命中率大概30%,实际平均延迟降回210ms。

3. Token消耗反而降了。因为从Top5改为Top3,且Top3的精准度更高,Prompt里的噪音变少。实测平均每次请求的prompt token从2800降到1500,调用成本降了约35%。

4. Embedding模型大小与显存。bge-large占1.3GB显存,reranker-base占1.1GB,加上FAISS索引,单卡3090还能剩20GB。但如果部署到16GB显存的卡,建议用bge-base-zh(512维)加量化版reranker,效果只差2.3个点,显存占用能控制在2GB。

七、效果数据对比与总结

最终在32道题上的完整对比:

方案 准确率 幻觉次数(编造条款) 单次检索耗时
基线(固定chunk+ada-002+Top5) 61.3% 11 50ms
+语义chunk 68.8% 8 55ms
+bge-large-zh 74.2% 6 60ms
+rerank(Top3) 84.4% 4 350ms
+低分拒答机制 87.5% 3 210ms(含缓存)

从61.3%到87.5%,每一步提升都不算惊艳,但叠加起来效果显著。总结下来我的核心建议是:

  • Chunk策略永远是第一优先级,embedding再强也扛不住输入是语义碎片。
  • 中文场景下bge-local优于OpenAI ada-002,且省了API延迟和费用。
  • Rerank不是可选项,是必选项,尤其当你的chunk数量超过500时,粗排+精排的两阶段是质变的关键。
  • 宁可拒答,不要硬编。低分拒答机制虽然会让回答率下降一点,但专业场景里正确的“不知道”远胜过一个编造的“知道”。

最后提醒一点:任何评测都要基于自己的人工标注集,不要迷信公开benchmark。保险条款的术语分布和日常问答差异很大,建议最少准备30-50条真实业务场景题来验证。如果你也在做类似的知识库问答,可以按这个路径走一遍,每一步改动都不大,但叠加效果不会让你失望。