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封装成一个独立组件,用transformers的AutoModelForSequenceClassification加载:
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调优经历。