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摘要来提升检索精度。希望这篇记录对你有帮助,欢迎评论区交流。