一、问题背景:为什么我的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

上线两周后,用户反馈集中在三类问题:

  1. 答案不完整:明明文档里有完整步骤,回答只给了一半。
  2. 答非所问:问"如何配置超时",召回的是"超时错误码列表"。
  3. 多文档冲突:两个文档说法不一致时,模型随机选一个。

我手动抽样了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做多向量检索。有进展再写一篇。