1. 问题背景:为什么“看起来能跑”的系统实际一塌糊涂?

上个月接手了一个内部知识库问答项目。最初的实现非常简单:langchain + FAISS + text2vec-large-chinese,文档按固定256字符切块。演示时效果尚可,一旦丢进真实的售后工单、产品手册和故障排查记录,问题立刻暴露:

  • 检索结果错位:用户问“电池在低温下续航衰减多少”,返回的是“电池保养注意事项”整段,核心数据表没被切出来。
  • 答案拼接混乱:上下文窗口塞满了不相关片段,LLM生成时东拉西扯。
  • 命中率无法直视:人工标注了2000条问答对做评估,Top-5召回率只有61.3%,MRR(平均倒数排名)0.44。

这个成绩意味着三分之一的问题根本找不到正确文档块。于是我决定系统性优化,而不是继续调prompt。

2. 环境与版本:先把地基钉死

为避免优化过程中出现“改了一行代码效果变了但不知道哪行引起的”情况,先锁定环境:

Python 3.10.12
langchain 0.1.16
langchain-community 0.0.36
faiss-cpu 1.7.4
transformers 4.38.2
sentence-transformers 2.5.1
torch 2.2.1+cu121

Embedding模型下载地址用huggingface_hub快照,全部存到本地/models/目录。测试集是我自己用半自动方式构建的:从文档里挑出2000个有明确答案的段落,反向生成问题,确保每道题至少有一个“金标准”chunk。评估指标只看两个:Hit@5(正确答案是否出现在前5个检索结果中)和MRR(第一个正确答案排名的倒数均值)。

3. 第一刀:chunk策略从“固定长度”改成“语义边界”

问题诊断:我打印了检索错误的case,发现大量问题出在chunk把完整的表格、代码块或结论句拦腰截断。比如一个产品的“技术参数”表格,被切成三段,前两段只有表头和数据行,第三段是表尾——这种碎片无论怎么embedding都难以匹配到“峰值功率”这类查询。

方案设计:放弃RecursiveCharacterTextSplitter的固定长度逻辑,改用MarkdownHeaderTextSplitter先按标题层级切出大块,再对每个大块按语义完整性做二次切分。核心思想是:标题是天然的语义锚点,不要让正文溢出到下一个标题

核心实现:我写了一个混合切分器,关键参数如下:

from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter

# 第一遍:按标题切出顶级语义块
headers_to_split_on = [
    ("#", "H1"),
    ("##", "H2"),
    ("###", "H3"),
]
md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)

# 第二遍:对每个标题块内部,如果过长再递归切分
chunk_size = 512  # 字符级,比之前256大一倍,因为语义块本身有上下文
chunk_overlap = 128

def hybrid_split(text: str):
    sections = md_splitter.split_text(text)
    final_chunks = []
    for section in sections:
        # 关键:把标题拼回到内容里,让embedding能感知上下文
        header_text = " > ".join(section.metadata.values()) if section.metadata else ""
        content = f"{header_text}\n{section.page_content}"

        if len(content) <= chunk_size:
            final_chunks.append(content)
        else:
            # 用递归切分器兜底,但保持overlap让边界信息不丢失
            sub_splitter = RecursiveCharacterTextSplitter(
                chunk_size=chunk_size,
                chunk_overlap=chunk_overlap,
                separators=["\n\n", "\n", "。", ";", " ", ""]  # 注意中文标点优先
            )
            sub_chunks = sub_splitter.split_text(content)
            final_chunks.extend(sub_chunks)
    return final_chunks

踩坑提醒
MarkdownHeaderTextSplitter返回的Document对象里metadata带着标题层级,如果你把标题从内容里剥离出去单独存metadata,检索时标题信息不会参与向量化。我一开始就是直接split_text然后取page_content,结果标题被丢了,效果反而变差。一定要把标题拼回内容开头。

第一轮效果对比
| 策略 | Hit@5 | MRR |
|------|-------|-----|
| 固定256字符 | 61.3% | 0.44 |
| 混合切分(512+128) | 68.7% | 0.51 |

提升幅度不错,但离可用还有差距。原因是embedding模型本身区分度不够。

4. 第二刀:embedding模型换型——从千维中文向量到通用中文语义模型

为什么换text2vec-large-chinese是1024维,在长尾专业术语上表现一般。我测试了“过流保护值设定”这类工单用语,它和“电流限制阈值”的余弦相似度只有0.72,区分度太差。而BAAI/bge-large-zh-v1.5有两个优势:一是用了retromae预训练,对专业缩写和同义术语更鲁棒;二是官方明确支持为query和passage分别添加指令前缀,这能显著提升不对称检索的效果。

核心实现:加载方式很简单,但注意两个坑。第一个坑是sentence-transformers加载bge模型时,必须手动设置query_instruction,否则它的默认行为是当作对称语义模型用。第二个坑是归一化——bge模型输出向量建议做L2归一化再存入FAISS,否则内积分数会偏大。

from sentence_transformers import SentenceTransformer

class BGEEmbedding:
    def __init__(self, model_path: str = "/models/bge-large-zh-v1.5"):
        # 关键参数:normalize_embeddings=True,query指令必须明确
        self.model = SentenceTransformer(model_path, device="cuda:0")
        self.model.max_seq_length = 512  # bge支持最长512,对chunk长度友好
        self.query_instruction = "为这个句子生成表示以用于检索相关文章:"
        self.doc_instruction = ""  # 文档不需要前缀,或者可以留空

    def embed_query(self, text: str):
        return self.model.encode(
            self.query_instruction + text,
            normalize_embeddings=True,
            convert_to_numpy=True
        )

    def embed_documents(self, texts: list[str]):
        # 文档不加指令,直接编码
        return self.model.encode(
            texts,
            batch_size=32,
            normalize_embeddings=True,
            convert_to_numpy=True,
            show_progress_bar=False
        )

踩坑与优化
换模型后,我犯了一个低级错误——没有重新建FAISS索引。旧索引里的向量还是text2vec的1024维,直接塞新向量进去维度不匹配报错。重建索引后,又发现bge对输入长度敏感:我原来的混合切分里chunk_size=512字符,但中文一个字往往对应1-2个token,512字符可能超过512 token上限。于是把chunk_size调到400字符,overlap保持100,保证截断不会太狠。

第二轮效果对比
| 策略 | Hit@5 | MRR |
|------|-------|-----|
| 混合切分 + text2vec | 68.7% | 0.51 |
| 混合切分 + bge-large-zh-v1.5 | 79.4% | 0.62 |

到了79%,明显能感觉到检索结果不再是“驴唇不对马嘴”,但还有两成问题出在排序上——正确chunk往往排在第三第四位,而不是第一。这是向量检索的天然短板:双塔模型无法精确建模query与chunk之间的细粒度交互。

5. 第三刀:引入rerank——用交叉编码器给前20个候选重新排序

方案设计:RAG管线的经典三段式——粗召回(向量Top20)+ 精排(交叉编码器重排)+ 截断Top5。粗召回阶段用bge向量快速筛出20个候选,然后让bge-reranker-base对每个(query, chunk)对打分。reranker是交叉编码器,query和chunk可以做全交互attention,精度远高于双塔。

核心实现:我把rerank封装成一个独立组件,用transformersAutoModelForSequenceClassification加载:

from transformers import AutoTokenizer, AutoModelForSequenceClassification
import torch

class Reranker:
    def __init__(self, model_path: str = "/models/bge-reranker-base"):
        self.tokenizer = AutoTokenizer.from_pretrained(model_path)
        self.model = AutoModelForSequenceClassification.from_pretrained(model_path)
        self.model.eval()
        self.device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
        self.model.to(self.device)

    def rerank(self, query: str, docs: list[str], top_k: int = 5):
        pairs = [[query, doc] for doc in docs]
        inputs = self.tokenizer(
            pairs,
            padding=True,
            truncation=True,
            max_length=512,
            return_tensors="pt"
        ).to(self.device)

        with torch.no_grad():
            scores = self.model(**inputs).logits.squeeze(-1)  # shape: [batch_size]
            scores = torch.sigmoid(scores).cpu().numpy()

        # 按分数降序,取top_k
        doc_scores = list(zip(docs, scores))
        doc_scores.sort(key=lambda x: x[1], reverse=True)
        return doc_scores[:top_k]

踩坑与优化
这一步最大的坑是batch size太大导致显存溢出。bge-reranker-base虽然只有278M参数,但输入是query+doc拼接,序列长度翻倍。我一开始用batch_size=16,A10G显卡直接OOM。改成batch_size=4后稳定,但推理速度只有每请求约80ms——对于离线评测没问题,在线服务需要加缓存或换更小的bge-reranker-small

另一个细节:rerank的输入必须是原始文本,不能是向量。这要求FAISS索引在返回候选时,需要把page_content一并存进去。我是用FAISS.add_texts时把内容作为metadata存了,但默认的Document对象存储会序列化整个对象,内存占用大。建议只存page_content字符串。

第三轮效果对比
| 策略 | Hit@5 | MRR |
|------|-------|-----|
| bge + 不重排 | 79.4% | 0.62 |
| bge + rerank(top20取5) | 89.2% | 0.71 |

Hit@5终于突破85%大关,MRR提升到0.71意味着大部分时候正确答案排在前两位。用户反馈“答案靠谱多了”。

6. 工程落地:评估脚本与最终管线

优化过程必须要有可重复的评估脚本,否则就是玄学调参。我的评测脚本核心逻辑如下:

def evaluate_pipeline(pipeline, test_queries, gold_doc_ids, k=5):
    hit = 0
    mrr_sum = 0.0
    total = len(test_queries)

    for query, gold_id in zip(test_queries, gold_doc_ids):
        retrieved = pipeline.retrieve(query, top_k=k)
        retrieved_ids = [doc_id for doc_id, _ in retrieved]

        if gold_id in retrieved_ids:
            hit += 1
            rank = retrieved_ids.index(gold_id) + 1
            mrr_sum += 1.0 / rank

    hit_rate = hit / total
    mrr = mrr_sum / total
    return {"hit@k": round(hit_rate, 4), "mrr": round(mrr, 4)}

最终管线流程:
1. 文档预处理(去HTML、清理空行)
2. 混合切分(标题优先,正文递归)
3. bge-large-zh-v1.5编码,存入FAISS索引
4. 查询时:query向量粗召回Top20
5. bge-reranker-base精排Top20,取前5
6. 将5个chunk拼接送入LLM生成答案

7. 总结与反思

三刀下去,Hit@5从61.3%提升到89.2%,MRR翻倍到0.71。总结几条经验:

  • chunk切分是地基:固定长度切分只适合纯文本段落,遇到结构化文档(Markdown/HTML/PDF表格)必须让语义边界优先。
  • Embedding模型值得花时间换:bge系列对中文专业场景的改进是实打实的,换模型成本低,收益高。
  • Rerank不是万能的但有奇效:它解决了“正确但排名靠后”的问题,但前提是粗召回阶段不能把正确答案丢掉——所以Top20召回比Top5召回更稳妥。
  • 评估体系必须先行:没有2000条标注测试集,我根本不知道每一步改动的真实效果,只能凭感觉。

最后说个坑:所有中文文本在切分前一定要做统一编码归一化(比如全角转半角),否则text_splitter在处理“,”和“,”时会认为它们是不同分隔符,导致切分点错乱。这个我花了半天才排查出来。希望这篇记录对你有用,欢迎评论区交流你的RAG调优经历。