一、问题背景:为什么我的RAG系统答非所问
我们内部有一个技术文档问答机器人,知识库约1.2万篇Markdown文档,总token量约800万。上线初期用的最朴素的方案:
- 切分:
RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=0) - Embedding:OpenAI
text-embedding-ada-002 - 向量库:Chroma 0.4.24,HNSW索引
- 检索:余弦相似度 Top-5
- LLM:GPT-3.5-turbo-0613,temperature=0
上线两周后,用户反馈集中在三类问题:
- 答案不完整:明明文档里有完整步骤,回答只给了一半。
- 答非所问:问"如何配置超时",召回的是"超时错误码列表"。
- 多文档冲突:两个文档说法不一致时,模型随机选一个。
我手动抽样了200条bad case,统计后发现问题分布:
- 48% 是chunk切分导致上下文断裂
- 27% 是embedding对中文技术术语区分度不够
- 19% 是Top-5里混入了语义相近但无关的片段
- 6% 是LLM本身幻觉
于是决定分三轮优化:chunk策略 → embedding模型 → rerank。
二、环境与版本
Python 3.10.13
langchain 0.1.20
langchain-community 0.0.38
chromadb 0.4.24
sentence-transformers 2.7.0
FlagEmbedding 1.2.10
torch 2.2.1 + cu121
openai 1.30.1
硬件:单卡 A10 24GB,CPU 16核,内存64GB。评估集:200条人工标注的QA对,每条有标准答案和golden chunk id。
评估指标:
- Hit@5:Top-5中是否包含golden chunk
- MRR@5:平均倒数排名
- Faithfulness:用GPT-4打分,答案是否完全来自召回内容
- Latency P95:端到端响应时间
三、方案设计与核心实现
3.1 第一轮:chunk策略调整
原方案512字符无重叠,问题在于中文技术文档里一个完整步骤经常跨chunk。我对比了三种策略:
| 策略 | 参数 | Hit@5 | MRR@5 |
|---|---|---|---|
| A 字符切分 | 512/0 | 61.3% | 0.512 |
| B 字符切分+重叠 | 512/64 | 66.8% | 0.557 |
| C 语义切分 | 256 tokens/10% | 73.4% | 0.631 |
最终选C,但做了改良:先按Markdown标题层级切,再在段落内按token切。核心代码如下:
from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
import tiktoken
def semantic_chunk(md_text: str, max_tokens: int = 256, overlap_ratio: float = 0.1):
# 1. 按标题层级切
headers = [("#", "h1"), ("##", "h2"), ("###", "h3")]
md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers)
sections = md_splitter.split_text(md_text)
# 2. 段落内按token切,保留10%重叠
enc = tiktoken.get_encoding("cl100k_base")
token_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
encoding_name="cl100k_base",
chunk_size=max_tokens,
chunk_overlap=int(max_tokens * overlap_ratio),
separators=["\n\n", "\n", "。", ";", " ", ""]
)
chunks = []
for sec in sections:
header_path = " > ".join([f"{k}:{v}" for k, v in sec.metadata.items()])
for sub in token_splitter.split_text(sec.page_content):
# 把标题路径拼到chunk前面,增强上下文
chunks.append({
"text": f"[{header_path}]\n{sub}",
"metadata": sec.metadata
})
return chunks
关键点:把标题路径拼接进chunk文本。这一步单独贡献了约4个点的Hit@5提升,因为embedding时能感知到"这是配置章节还是错误码章节"。
3.2 第二轮:embedding模型切换
text-embedding-ada-002 在中文技术语料上表现一般,尤其是"超时配置"vs"超时错误"这种细粒度区分。我对比了四个模型:
| 模型 | 维度 | Hit@5 | MRR@5 | 单条编码延迟 |
|---|---|---|---|---|
| text-embedding-ada-002 | 1536 | 73.4% | 0.631 | ~80ms (API) |
| m3e-base | 768 | 76.1% | 0.658 | 12ms |
| bge-base-zh-v1.5 | 768 | 79.8% | 0.692 | 11ms |
| bge-large-zh-v1.5 | 1024 | 83.2% | 0.724 | 28ms |
选 bge-large-zh-v1.5。注意它需要加query instruction,官方建议query前缀 "为这个句子生成表示以用于检索相关文章:",passage不加。这个细节不做的话会掉2-3个点。
from sentence_transformers import SentenceTransformer
import numpy as np
class BGEEmbedder:
def __init__(self, model_path="BAAI/bge-large-zh-v1.5", device="cuda"):
self.model = SentenceTransformer(model_path, device=device)
self.query_instruction = "为这个句子生成表示以用于检索相关文章:"
def encode_queries(self, queries, batch_size=32):
queries = [self.query_instruction + q for q in queries]
return self.model.encode(
queries, batch_size=batch_size,
normalize_embeddings=True, # 必须归一化,配合内积
show_progress_bar=False
)
def encode_passages(self, passages, batch_size=64):
return self.model.encode(
passages, batch_size=batch_size,
normalize_embeddings=True,
show_progress_bar=False
)
# 重建索引
embedder = BGEEmbedder()
texts = [c["text"] for c in chunks]
embs = embedder.encode_passages(texts)
collection.add(
ids=[str(i) for i in range(len(texts))],
embeddings=embs.tolist(),
documents=texts,
metadatas=[c["metadata"] for c in chunks]
)
重建1.2万文档的索引,A10上跑了约6分钟。
3.3 第三轮:引入rerank
到83.2%后遇到瓶颈。分析bad case发现:Top-5里通常有1-2个是"语义相近但主题不同"的片段。比如问"如何修改默认端口",召回里混进了"端口被占用的排查方法"。
Rerank本质是cross-encoder,把query和doc拼一起过模型,精度高但慢。我选 bge-reranker-large,策略是:向量召回Top-20,rerank后取Top-5。
from FlagEmbedding import FlagReranker
class RerankRetriever:
def __init__(self, collection, embedder, reranker_path="BAAI/bge-reranker-large"):
self.collection = collection
self.embedder = embedder
self.reranker = FlagReranker(reranker_path, use_fp16=True)
def retrieve(self, query, recall_k=20, top_k=5):
# 1. 向量召回
q_emb = self.embedder.encode_queries([query])[0]
res = self.collection.query(
query_embeddings=[q_emb.tolist()],
n_results=recall_k
)
docs = res["documents"][0]
ids = res["ids"][0]
# 2. rerank
pairs = [[query, d] for d in docs]
scores = self.reranker.compute_score(pairs, normalize=True)
# 3. 重排取Top-K
ranked = sorted(zip(ids, docs, scores), key=lambda x: x[2], reverse=True)
return ranked[:top_k]
use_fp16=True 在A10上把rerank 20条对的延迟从420ms压到180ms。这个参数必开。
四、踩坑与优化
坑1:bge模型的normalize。一开始忘了normalize_embeddings=True,Chroma默认用L2距离,结果Hit@5只有71%,比ada-002还差。改成归一化+内积后正常。Chroma的collection要设 metadata={"hnsw:space": "ip"}。
坑2:overlap不是越大越好。试过20%重叠,索引体积涨了35%,Hit@5只涨0.6个点,但检索延迟涨了15%。最终定10%。
坑3:rerank的recall_k。一开始recall_k=10,rerank后提升有限。调到20后Hit@5从86.1%跳到89.7%。但再往上调到30,收益递减且延迟翻倍。20是甜点。
坑4:标题路径拼接长度。有的文档h1>h2>h3路径很长,占了chunk的1/3 token。后来限制路径最多3级、总长不超过50 token。
坑5:FlagReranker的batch。默认batch_size=1,20条对串行跑很慢。手动传 batch_size=8 后延迟从580ms降到180ms。
五、效果数据
| 阶段 | 配置 | Hit@5 | MRR@5 | Faithfulness | Latency P95 |
|---|---|---|---|---|---|
| Baseline | 512字符/ada-002/Top-5 | 61.3% | 0.512 | 72.0% | 1.8s |
| +语义chunk | 256token/10%/ada-002 | 73.4% | 0.631 | 79.5% | 1.9s |
| +bge-large | +bge-large-zh-v1.5 | 83.2% | 0.724 | 86.1% | 2.1s |
| +rerank | +bge-reranker-large, recall20 | 89.7% | 0.781 | 91.2% | 2.4s |
用户侧bad case率从18.5%降到5.2%。延迟增加600ms,但换来28.4个点的Hit@5提升,完全值得。如果对延迟敏感,可以用 bge-reranker-base,Hit@5约87.1%,延迟只增加200ms。
六、总结
三轮优化下来,最大的体会是:RAG的效果瓶颈通常不在LLM,而在检索。我们花在prompt工程上的时间,远不如把Top-5命中率从61%提到90%来得实在。
几个可复用的结论:
1. 中文技术文档,chunk按语义切+标题路径拼接,比固定长度切分强10个点以上。
2. 中文场景下bge-large-zh-v1.5显著优于text-embedding-ada-002,且成本更低(本地推理)。
3. rerank是性价比最高的一步,20条recall+large模型,能再提6个点。
4. 每一步都要有评估集,否则你根本不知道改动是正收益还是负收益。
后续还想试的方向:query改写(HyDE)、混合检索(BM25+向量)、以及用bge-m3做多向量检索。有进展再写一篇。