一、问题背景:为什么语义检索在私有文档上“失灵”了?

我们的场景是为一家制造业客户的SOP(标准作业程序)文档构建问答助手。文档包含大量长表格、混排的代码块(设备参数)以及嵌套标题。最初基于LangChain的经典流程:RecursiveCharacterTextSplitter(chunk_size=256, chunk_overlap=40) + BAAI/bge-large-zh-v1.5 embedding + CosineSimilarity 直接取top-5。

上线后人工评测发现:Top-5召回正确率仅62%,Top-1命中率惨不忍睹(31%)。排查典型badcase:用户问“液压站油温超过60度时,PLC应执行什么安全联锁?”,召回结果却是一段关于“液压站日常点检”的文本,因为两者在字面上共享“液压站”三个字,但语义焦点完全不同。问题根源有两个:一是分块粗暴切断了表格与描述文字的上下文;二是单向量检索对“反向问题”的语义匹配能力不足。

二、环境与版本:避免“玄学调优”的基线锁定

所有实验均在固定环境下进行,避免库版本升级带来的干扰:

  • Python 3.10.14(docker镜像 python:3.10-slim
  • langchain==0.2.15(注意:0.3.x的splitter接口有breaking change)
  • langchain-community==0.2.12(用于HuggingFaceEmbeddings
  • sentence-transformers==3.0.1(bge-v1.5加载依赖)
  • FlagEmbedding==1.2.10(用于加载GTE-Qwen2及reranker)
  • chromadb==0.5.3(向量库,持久化到本地./db目录)
  • 评估集:从SOP文档中人工构造120对(问题,标准答案片段),覆盖安全联锁、参数调整、故障复位三类。

三、第一刀:从“固定切块”到“结构感知分块”

方案设计:放弃RecursiveCharacterTextSplitter,改用根据Markdown标题层级(#~###)进行递归切分,并自定义Separator列表为["\n## ", "\n### ", "\n#### ", "\n表格行"]。核心逻辑:如果子块内包含完整表格(以|开头),则强制把表格及其上文描述合并为一个块,不跨表格行切分。

核心实现(基于langchain.text_splitter重写):

from langchain.text_splitter import TextSplitter
from typing import List, Dict
import re

class StructureAwareSplitter(TextSplitter):
    def __init__(self, chunk_size: int = 700, chunk_overlap: int = 100):
        super().__init__(chunk_size=chunk_size, chunk_overlap=chunk_overlap)
        self.table_pattern = re.compile(r'^\|.*\|$', re.MULTILINE)

    def split_text(self, text: str) -> List[str]:
        # 第一步:按三级标题切分
        sections = re.split(r'(?m)^(?=####?\s)', text)
        chunks = []
        for section in sections:
            if not section.strip():
                continue
            # 第二步:处理段内表格——若表格行数>3,整表捕捉;否则按行合并到邻近文本
            if self.table_pattern.search(section):
                lines = section.split('\n')
                buffer = []
                for line in lines:
                    buffer.append(line)
                    if self.table_pattern.match(line) and len(buffer) >= 5:
                        # 遇到连续表格行,则flush成一个chunk
                        merged = '\n'.join(buffer)
                        if len(merged)  self.chunk_size:
                    parts = re.split(r'(? self.chunk_size:
                            chunks.append(temp)
                            temp = part
                        else:
                            temp += part
                    if temp:
                        chunks.append(temp)
                else:
                    chunks.append(section)
        return chunks

# 实例化并覆盖原splitter
splitter = StructureAwareSplitter(chunk_size=700, chunk_overlap=100)

踩坑:直接按####切分时,部分SOP的标题是3.2.1这种数字编号,没有Markdown井号。后来改为在预处理阶段,用正则re.sub(r'(?m)^(\\d+(\\.\\d+)+\\s)', r'#### \\1', line)先把数字标题转换为四级标题,才让分块逻辑生效。

效果数据:分块数量从原有的840个减少至530个(少了的块是大表格不再被切碎)。Recall@5从62%提升至71%,但Top-1命中率仍只有35%。此时我们意识到:分块解决的是“上下文完整性”,但“语义匹配”仍是短板。

四、第二刀:Embedding从“通用小模型”升级为“指令微调大模型”

方案设计:切换为GTE-Qwen2-7B-instruct,这是一个基于Qwen2-7B的指令微调模型,需要输入带指令前缀的query(如"为检索到相关段落,请查询:")才能发挥效果。它输出3584维向量,比bge-large的1024维多3.5倍,理论上能容纳更细腻的语义。

核心实现(使用FlagEmbedding的官方封装):

from FlagEmbedding import FlagModel
import numpy as np
from langchain.embeddings.base import Embeddings

class GTEQwen2Embeddings(Embeddings):
    def __init__(self, model_path: str = "/models/GTE_Qwen2-7B-instruct"):
        self.model = FlagModel(
            model_path,
            query_instruction_for_retrieval="为检索到相关段落,请查询:",
            use_fp16=True,  # 7B模型必须用fp16,否则OOM
            devices="cuda:0"
        )

    def embed_documents(self, texts: List[str]) -> List[List[float]]:
        # 文档端不需要指令前缀
        return self.model.encode(texts, batch_size=8).tolist()

    def embed_query(self, text: str) -> List[float]:
        # query端自动附加指令
        return self.model.encode_queries([text], batch_size=1).tolist()[0]

# 注意:langchain的HuggingFaceEmbeddings无法直接加载FlagModel,必须自定义类

踩坑与调优
- GPU显存:7B模型fp16需要约16GB显存,用vLLM部署时单卡A100勉强。但我们是在批处理阶段离线生成所有文档向量,所以可以一次性加载。如果做在线增量,建议用vLLM起OpenAI兼容服务。
- 归一化:FlagEmbedding默认输出L2归一化向量,但chromadb的默认距离是l2,需要改为cosine,否则效果有衰减。
- batch_size:从默认的32调低到8,否则7B模型在编码长文本时容易触发CUDA OOM。

效果数据:Recall@5从71%升至79%,但Top-1仅从35%升至44%。关键观察:embedding变强后,召回的5个结果相关性都更好,但排序依然不对——最相关的答案经常排在第二位。这说明需要专门的重排器来精排

五、第三刀:引入Reranker二次精排,把正确结果“拎”出来

方案设计:使用BAAI/bge-reranker-v2-m3,这是一个跨编码器(cross-encoder)模型,对每个(query, doc)对直接打分,比双塔embedding更精细。流程改为:先用GTE-Qwen2召回top-20(扩大召回池),再用reranker对20个候选打分,取top-3返回给LLM。

核心实现

from FlagEmbedding import FlagReranker

reranker = FlagReranker('/models/bge-reranker-v2-m3', use_fp16=True)

def rerank(query: str, docs: List[str], top_k: int = 3):
    # 构造pair列表
    pairs = [(query, doc) for doc in docs]
    scores = reranker.compute_score(pairs, normalize=True)  # normalize=True返回0~1概率
    # 按分数降序取top_k
    sorted_idx = np.argsort(scores)[::-1][:top_k]
    return [docs[i] for i in sorted_idx], [scores[i] for i in sorted_idx]

# 在检索链中集成:
# 1. 用GTE召回top_20 = vector_store.similarity_search(query, k=20)
# 2. final_docs = rerank(query, [d.page_content for d in top_20], top_k=3)

踩坑记录
- 模型加载路径:v2-m3需要sentencepiece分词器,路径不能包含中文,否则报UnicodeDecodeError。我们被迫把模型从/data/重排器/软链到/models/reranker
- normalize参数:旧版本FlagRerankercompute_score返回原始logits,范围在-10到10之间,不直观。升级到1.2.10后加normalize=True可得到概率值,便于设定阈值(我们实验发现0.35以上的候选才值得进LLM)。
- 延迟问题:reranker单条pair约耗时15ms(A100),20条候选即300ms。如果对实时性敏感,可以把召回池缩小到10,但会损失1-2%的最终准确率。

效果数据:在top-20召回池基础上重排,Top-1准确率从44%直接飙升至67%,Top-3准确率高达89%。对比仅用top-5重排(即先GTE召回5条再rerank),Top-1只有58%,所以我们坚持“扩大召回池再精排”的策略。

六、全链路效果对比与总结

配置阶段 Recall@5 Top-1 Acc 平均检索延迟(ms)
基线: 256固定分块 + bge-large + top-5直接取 62% 31% 15
+ 结构感知分块 71% 35% 15
+ GTE-Qwen2-7B embedding 79% 44% 45
+ 召回池扩至top-20 + bge-reranker-v2-m3 83% 67% 360

最终结论
1. 分块是地基,如果分块切碎了表格和逻辑段落,再强的embedding也无力回天。但分块优化是性价比最高的——几乎零成本,提升9%。
2. Embedding从1亿参数升级到70亿参数,Recall涨了8%,但Top-1只涨了9%,说明双塔模型在排序任务上存在瓶颈,你无法通过无限增大embedding维度来解决排序问题。
3. Reranker是真正的“终极武器”,虽然增加了约300ms延迟,但Top-1翻倍。对于离线文档问答场景(非实时对话),这个延迟完全可接受。

建议:如果资源有限,请优先砸钱在reranker上,而不是盲目追求超大embedding。另外,所有数据均基于内部120条测试集,指标波动在±2%以内,建议在自有数据上复测后再迁移方案。