1. 问题背景:为什么召回总是“差一点”

今年Q2接手了一个内部技术文档问答系统,目标是让运维人员通过自然语言查询网络配置、故障处理流程。技术栈是LangChain + Chroma + OpenAI GPT-4。上线后遇到一个典型问题:用户问“如何配置OSPF多区域”,系统返回的文档片段要么是OSPF概述,要么是多区域配置的后半段,关键的前置步骤总是缺失。人工标注了200条测试用例后,发现Top-5召回率只有68%,Top-1准确率更是低至54%。

排查发现三个核心问题:
- chunk切割生硬:固定512字符切割,把逻辑完整的段落切成了两半。
- embedding模型不匹配:text-embedding-ada-002对中文技术术语(如“BGP路由反射器”、“STP根桥”)的语义捕捉较弱。
- 缺乏二次排序:相似度检索直接取Top-5,没有对候选文档做精细排序。

于是决定从三个方向优化:chunk策略、embedding模型、增加rerank模块。

2. 环境与版本

  • Python 3.10.12
  • LangChain 0.1.12
  • Chroma 0.4.22
  • OpenAI Embeddings: text-embedding-ada-002(版本2023-12)
  • 国产模型:bge-large-zh-v1.5 (FlagEmbedding 1.2.8)
  • Rerank模型:BAAI/bge-reranker-v2-m3 (FlagEmbedding 1.2.8)
  • 向量维度:ada-002 1536维,bge-large-zh 1024维
  • 测试数据集:企业内部200条问答对,覆盖网络、存储、安全三个领域

3. 方案设计:三阶段优化路径

优化分为三个阶段,每个阶段独立评估效果:

阶段一:chunk策略优化
将原来的固定512字符切割改为:
- 语义分割:先按Markdown标题(#、##、###)分割,再按段落(\n\n)分割
- 重叠窗口:相邻chunk重叠100字符,防止上下文断裂
- 最小chunk长度:低于200字符的chunk合并到前一段

阶段二:embedding模型切换
对比三个模型:
1. text-embedding-ada-002(OpenAI,0.13美元/百万token)
2. bge-large-zh-v1.5(国产,免费,1024维)
3. m3e-large(国产,免费,1024维)

最终选择bge-large-zh-v1.5,原因有二:对中文技术术语的余弦相似度得分比ada高0.12-0.18,且完全本地部署,无API调用成本。

阶段三:引入rerank
在向量检索后,对Top-20候选文档用交叉编码器(bge-reranker-v2-m3)重新排序,取Top-5。该模型会计算query与每个候选文档的相关性得分,而不仅是向量距离。

4. 核心实现:关键代码与参数

4.1 优化后的chunk策略实现

# chunk_strategy.py
import re
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.schema import Document

def semantic_chunk(documents: list[Document], chunk_size=800, chunk_overlap=100):
    """
    语义分割:先按Markdown标题分割,再按段落分割,最后用RecursiveCharacterTextSplitter分块
    """
    # 第一阶段:按Markdown标题分割
    heading_splitter = RecursiveCharacterTextSplitter(
        separators=["\n## ", "\n### ", "\n#### ", "\n\n", "\n", " "],
        chunk_size=2000,  # 先设置一个较大的值,只做标题级分割
        chunk_overlap=0,
        length_function=len,
    )

    heading_docs = heading_splitter.split_documents(documents)

    # 第二阶段:对每个标题块再做细粒度分块
    final_splitter = RecursiveCharacterTextSplitter(
        separators=["\n\n", "\n", "。", ".", " ", ""],
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        length_function=len,
        keep_separator=True,
    )

    final_docs = final_splitter.split_documents(heading_docs)

    # 后处理:合并小chunk
    merged_docs = []
    buffer = ""
    for doc in final_docs:
        if len(buffer + doc.page_content) < 200:
            buffer += doc.page_content
        else:
            if buffer:
                merged_docs.append(Document(page_content=buffer, metadata=doc.metadata))
            buffer = doc.page_content
    if buffer:
        merged_docs.append(Document(page_content=buffer, metadata=final_docs[-1].metadata))

    return merged_docs

# 使用示例
with open("network_docs.md", "r", encoding="utf-8") as f:
    raw_text = f.read()
raw_docs = [Document(page_content=raw_text, metadata={"source": "network_docs.md"})]
chunks = semantic_chunk(raw_docs, chunk_size=800, chunk_overlap=100)
print(f"原始文档大小: {len(raw_text)}字符,生成chunk数: {len(chunks)}")

4.2 embedding模型切换与rerank集成

# rag_optimizer.py
import os
from langchain_community.embeddings import OpenAIEmbeddings
from langchain_community.embeddings import HuggingFaceBgeEmbeddings
from langchain_community.vectorstores import Chroma
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain_community.cross_encoders import HuggingFaceCrossEncoder

# 配置参数
EMBEDDING_MODEL = "BAAI/bge-large-zh-v1.5"  # 切换为国产模型
RERANK_MODEL = "BAAI/bge-reranker-v2-m3"
VECTOR_DB_PATH = "./chroma_db_optimized"
CHUNK_SIZE = 800
CHUNK_OVERLAP = 100

# 初始化embedding模型
embedding_model = HuggingFaceBgeEmbeddings(
    model_name=EMBEDDING_MODEL,
    model_kwargs={"device": "cuda"},  # 如果有GPU,使用CUDA加速
    encode_kwargs={
        "normalize_embeddings": True,  # 归一化后余弦相似度等价于内积
        "batch_size": 32,
    },
    query_instruction="为这个句子生成表示以用于检索相关文章:",  # bge模型的prompt模板
)

# 构建向量库
vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embedding_model,
    persist_directory=VECTOR_DB_PATH,
    collection_metadata={"hnsw:space": "cosine"},  # 使用余弦距离
)

# 基础检索器(仅向量检索)
base_retriever = vectorstore.as_retriever(
    search_type="similarity",
    search_kwargs={"k": 20}  # 先取Top-20给rerank筛选
)

# 初始化rerank模型
cross_encoder = HuggingFaceCrossEncoder(model_name=RERANK_MODEL)
compressor = CrossEncoderReranker(
    model=cross_encoder,
    top_n=5,  # 最终返回Top-5
    batch_size=16,
)

# 组合检索器:向量检索 + rerank重排序
optimized_retriever = ContextualCompressionRetriever(
    base_compressor=compressor,
    base_retriever=base_retriever,
)

# 测试查询
query = "OSPF多区域配置步骤"
docs = optimized_retriever.get_relevant_documents(query)
for i, doc in enumerate(docs):
    print(f"Rank {i+1}: {doc.page_content[:100]}... (score: {doc.metadata.get('relevance_score', 'N/A')})")

5. 踩坑与优化:三个关键细节

踩坑1:bge模型需要prompt模板
最初直接使用bge-large-zh-v1.5,发现检索结果比ada还差。排查后发现该模型要求query侧加上前缀“为这个句子生成表示以用于检索相关文章:”,否则语义空间不对齐。加上后,相似度得分分布明显合理。

踩坑2:Chroma的hnsw参数
默认Chroma使用L2距离,但bge模型归一化后使用余弦距离更合适。在Chroma初始化时设置collection_metadata={"hnsw:space": "cosine"},将Top-5命中率提升了约4%。

踩坑3:rerank的batch_size
bge-reranker-v2-m3交叉编码器在CPU上推理速度约40ms/对,Top-20候选需要20次推理。设置batch_size=16后,单次查询总耗时从1.2秒降至0.4秒(GPU加速后降至0.08秒)。

6. 效果数据:A/B测试对比

在200条测试集上,采用“召回率@5”(Top-5包含正确答案的比例)和“Top-1准确率”作为指标:

方案 召回率@5 Top-1准确率 平均检索耗时(ms)
原始方案(ada+固定chunk) 68% 54% 180
优化chunk(语义分割+重叠) 82% 66% 195
+切换bge-large-zh-v1.5 91% 78% 210
+引入rerank 94% 89% 420

关键发现:
- chunk优化贡献了14个百分点的召回率提升,主要解决了上下文断裂问题。
- embedding切换贡献了9个百分点的召回率提升,在“BGP路由反射器”、“STP根桥”等术语上尤其明显。
- rerank将Top-1准确率从78%拉到89%,因为二次排序打掉了那些语义相似但实际不相关的文档(比如“OSPF概述” vs “OSPF多区域配置”)。
- 耗时增加主要来自rerank(从210ms到420ms),但仍在可接受范围。

7. 总结与建议

这次优化让我深刻体会到三点:
1. chunk是地基:花时间根据文档结构设计分块策略,比调embedding参数收益更大。
2. 模型选择要看场景:中文技术文档场景,国产bge系列表现优于OpenAI ada,且成本更低。
3. rerank不是银弹:它对Top-1准确率提升明显,但会增加延迟。如果业务对实时性要求极高(<200ms),可以只取Top-5不做rerank,或者用更轻量的模型如bge-reranker-v2-m3的小版本。

后续计划尝试:基于chunk的元数据过滤(比如按文档类型、时间范围预筛选),以及用LLM自动生成chunk摘要来提升检索精度。希望这篇记录对你有帮助,欢迎评论区交流。