1. 问题背景:为什么检索结果像“答非所问”
我们的业务场景是给公司内部2000+篇技术文档做问答助手,技术栈原本是LangChain + ChromaDB + OpenAI Embedding(text-embedding-ada-002)。但上线后发现两个致命问题:
- 召回不精准:用户问“数据库连接池满怎么办”,返回的是“JDBC驱动配置”相关文档,语义匹配完全跑偏。
- 上下文碎片化:把一篇5000字的文档按固定256字符硬切,导致一个完整的“故障处理流程”被切成3段,每段都只有部分信息,LLM回答时经常“一本正经地胡说八道”。
我们最初怀疑是LLM推理能力不行,后来用LangSmith做了20组测试,发现问题出在检索侧——有13次是检索结果压根不对,只有7次是生成侧问题。于是决定动手优化。
2. 环境与版本:整套系统在跑什么
先交代一下当时的系统配置,方便你对比复现:
Python 3.10.12
langchain 0.2.1
langchain-openai 0.1.3
chromadb 0.4.24
sentence-transformers 2.5.1
vLLM 0.4.2 (部署Qwen2.5-7B-Instruct,kv cache 40GB)
文档预处理用的tiktoken做token统计,分块器是LangChain内置的RecursiveCharacterTextSplitter,当时参数是:
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=256,
chunk_overlap=20,
separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", ";", ";"]
)
这个配置的问题在于chunk_size太小,对于技术文档这种信息密集型文本,256个字符往往表达不完一个完整的技术点。而且分隔符优先级里\n\n排在第一位,但很多技术文档是Markdown格式,标题和正文之间只有单个\n,导致标题经常被单独切出来,和正文内容割裂。
3. 方案设计:三步走,每一步都有明确指标
我们制定了三步走的优化计划,每一步都有一个可量化的验收标准:
| 步骤 | 操作 | 验收指标 |
|---|---|---|
| 第一步 | 重构chunk策略:从固定长度改为语义段落 | 命中率 ≥ 75% |
| 第二步 | 切换embedding模型:ada-002 → BGE-large-zh-v1.5 | 命中率 ≥ 80% |
| 第三步 | 引入rerank:bge-reranker-v2-m3 | 命中率 ≥ 85% |
这里“命中率”的定义是:检索返回的前5个chunk中,至少有一个包含可支撑正确答案的关键信息。我们用50条真实用户问题做评测集,人工标注答案所在文档段落。
4. 核心实现:chunk策略重构 + embedding切换
4.1 第一步:语义chunk——用Markdown标题做边界
我们写了一个简单但有效的函数:先按#标题切分文档,再对每个标题下的内容用。做二次切分,确保每个chunk在800-1000个字符之间(约200-250个token)。
import re
from langchain.text_splitter import RecursiveCharacterTextSplitter
def semantic_chunk(markdown_text: str, max_chunk_size: int = 1000) -> list[str]:
"""
按Markdown标题层级切分文档,标题单独保留,正文按句子边界聚合
"""
# 先按标题切
sections = re.split(r'(?m)^(#{1,4})\s+', markdown_text)
chunks = []
current_title = ""
for i, section in enumerate(sections):
if i % 2 == 1: # 标题部分
current_title = section.strip()
continue
# 正文部分,按句子边界分成小段
sentences = re.split(r'(? max_chunk_size:
chunks.append("\n".join(buffer))
buffer = [current_title, sent]
current_len = len(current_title) + sent_len
else:
buffer.append(sent)
current_len += sent_len
if buffer:
chunks.append("\n".join(buffer))
# 过滤掉只有标题没有内容的chunk
return [c for c in chunks if len(c) > 50]
效果:命中率从61%直接跳到78%。原因很明显——完整的技术段落被保留,比如“数据库连接池满”这个问题的答案,现在能完整出现在一个chunk里。但我们发现一个问题:chunk数量变少了(从原来的每文档平均47个chunk降到19个),导致部分长尾问题的查全率下降。
4.2 第二步:换embedding——BGE还是text2vec?
在chunk优化后,我们对比了三个embedding模型在50条评测集上的表现:
from sentence_transformers import SentenceTransformer
import numpy as np
from chromadb.api.models.Collection import Collection
# 加载模型
bge_model = SentenceTransformer("BAAI/bge-large-zh-v1.5")
# text2vec 模型
t2v_model = SentenceTransformer("GanymedeNil/text2vec-large-chinese")
# 评测函数:基于余弦相似度召回top5
def eval_recall(collection: Collection, queries: list[str], golden_docs: list[str], top_k: int = 5):
hits = 0
for q, gold in zip(queries, golden_docs):
results = collection.query(
query_embeddings=[bge_model.encode(q)],
n_results=top_k
)
if any(gold in doc for doc in results['documents'][0]):
hits += 1
return hits / len(queries)
实测结果:
| 模型 | 维度 | 命中率 | 平均延迟(ms) |
|---|---|---|---|
| text-embedding-ada-002 | 1536 | 61% | 80ms |
| text2vec-large-chinese | 1024 | 72% | 45ms |
| BGE-large-zh-v1.5 | 1024 | 83% | 50ms |
BGE完胜。注意我们用了query_instruction_for_retrieval这个前缀(BGE官方要求),否则效果会掉5个点左右:
# 正确的BGE使用方式
from sentence_transformers import SentenceTransformer
bge_model = SentenceTransformer("BAAI/bge-large-zh-v1.5")
bge_model.encode(["为这个句子生成表示以用于检索相关文章:" + query]) # 查询侧需要加前缀
# 文档侧不需要加前缀,直接encode
4.3 第三步:引入rerank——让精排做最终决定
embedding检索本质是“粗排”,它用余弦相似度找语义接近的chunk,但无法理解“这个chunk里是否真的包含用户问题的答案”。我们引入bge-reranker-v2-m3做精排。
from FlagEmbedding import FlagReranker
reranker = FlagReranker('BAAI/bge-reranker-v2-m3', use_fp16=True)
def rerank(query: str, candidates: list[str], top_k: int = 3) -> list[str]:
"""
对检索出的候选chunk做精排,返回重新排序后的top_k
"""
pairs = [[query, doc] for doc in candidates]
scores = reranker.compute_score(pairs)
# 按分数降序排序
scored = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return [doc for doc, score in scored[:top_k]]
关键点:
- 这个reranker是交叉编码器(cross-encoder),输入query+doc拼接,计算相关度分数,效果比双塔模型好一个档次。
- use_fp16=True可以减半显存占用,我们用的A10 24GB,batch_size设为4。
- 我们只对embedding召回的前20个chunk做rerank,取top3输入给LLM。不要对全部文档做rerank,延迟会爆炸。
5. 踩坑与优化:三个坑,每个都浪费两天
坑1:直接换embedding导致向量库全废
第一次在ChromaDB里直接换新模型,结果查询时维度不匹配报错。必须重建集合,不能原地更新。我们写了个迁移脚本,删掉旧collection重建,重新跑一遍文档入库。
坑2:BGE模型的query前缀忘了加
刚换BGE时命中率只有70%,怀疑是模型不行,后来翻官方文档发现必须加为这个句子生成表示以用于检索相关文章:前缀,加上后直接飙到78%。注意这个前缀只加在query侧,文档侧不需要。
坑3:rerank的batch_size设置过小导致OOM
第一次用batch_size=1跑2000篇文档的rerank,GPU直接爆显存。后来改成batch_size=4,并且用torch.no_grad()包裹推理,显存占用从18GB降到9GB。
6. 效果数据:每一步的量化提升
最终上线后的完整对比(50条评测集,Qwen2.5-7B生成):
| 配置 | 命中率@5 | 回答正确率 | 平均首token延迟 | 总延迟 |
|---|---|---|---|---|
| 原始:256字符chunk + ada-002 | 61% | 54% | 1.2s | 3.8s |
| 语义chunk + ada-002 | 78% | 70% | 1.0s | 3.5s |
| 语义chunk + BGE-large-zh | 83% | 76% | 0.9s | 3.3s |
| 语义chunk + BGE + rerank | 89% | 82% | 1.1s | 4.6s |
注意rerank增加了约1.3秒延迟,但换来的是回答正确率提升6个百分点。对于我们的内部工具场景,准确性优先于速度,这个trade-off可以接受。
另外,我们观察到一个有意思的现象:rerank对“多跳问题”提升特别明显,比如“如果数据库连接池满了,先看哪个配置文件?”这种需要跨文档推理的问题,命中率从71%直接飙到94%。
7. 总结:这套方案的适用边界
最终方案在线上运行三周,整体回答正确率稳定在80%+。但我要泼两盆冷水:
- 这套优化是针对中文技术文档的,如果你做的是英文通用问答,效果可能不同。BGE对中文效果最好,英文建议试试
bge-large-en-v1.5。 - chunk策略严重依赖文档格式——如果你们的文档不是Markdown,而是PDF扫描件,语义切分就失效了,需要先做OCR和版面分析。
最后给个建议:永远先做评测再优化。我们花了半天时间构建50条评测集,但正是这50条样本,让每一步优化都有据可依,避免了盲目调参。
如果你也在做RAG优化,欢迎在评论区交流——特别是rerank的延迟优化,我们目前还在尝试用polyencoder替代cross-encoder,目标是把延迟压回3秒内。