一、问题背景

我们内部有一个面向运维文档的RAG问答系统,知识库大概12万篇文档,涵盖部署手册、故障排查、API说明。上线三个月,用户反馈集中在两点:一是回答经常“张冠李戴”,问A故障却召回B故障的段落;二是答案不完整,明明文档里有步骤,模型只答了一半。

初期架构很朴素:LangChain 0.0.2xx + FAISS + text-embedding-ada-002,chunk用RecursiveCharacterTextSplitter固定512字符、overlap 50,top_k=4直接塞进prompt。这套东西demo阶段够用,真上量就露馅了。

我先做了一轮badcase归因,抽了200条负反馈:

  • 召回阶段就没找到正确段落:78条(39%)
  • 召回了但排在top_k之外:31条(15.5%)
  • 召回正确但被噪声段落干扰:52条(26%)
  • 生成阶段问题(模型忽略上下文):39条(19.5%)

也就是说,超过一半的问题出在检索侧。这决定了优化方向:先动chunk和embedding,再考虑rerank。

二、环境与版本

先把基线环境固定下来,方便复现:

Python 3.10.13
torch 2.1.2 + cu121
transformers 4.36.2
sentence-transformers 2.2.2
langchain 0.1.0
langchain-community 0.0.10
faiss-cpu 1.7.4(离线评估用),线上用faiss-gpu 1.7.4
FlagEmbedding 1.2.5(reranker)

评估集:从真实日志里抽500条query,人工标注golden chunk(文档ID+段落起止),指标用Recall@k、MRR、以及端到端“首答准确率”(人工判定答案是否覆盖关键点)。基线数据:

指标 基线值
Recall@4 0.68
MRR 0.54
首答准确率 61%
P95检索延迟 120ms

三、方案设计

整体分三步走,每步单独评估,避免多变量耦合说不清归因:

  1. Chunk策略调整:固定长度 → 结构感知+递归混合切分
  2. Embedding切换:ada-002 → bge-large-zh-v1.5(中文场景)
  3. 引入Rerank:bi-encoder召回top20 → cross-encoder精排取top4

顺序上先chunk再embedding,因为chunk决定语义单元边界,边界不对,再好的模型也救不回来。rerank放最后,它是“锦上添花”型优化,前提是召回池里得有正确结果。

四、核心实现

4.1 Chunk策略调整

原方案的问题:512字符硬切,把“故障现象-排查步骤-解决方案”这种结构化段落拦腰截断,且overlap 50不足以覆盖跨段指代。

新方案按文档结构走。我们的文档是Markdown,先按标题层级切,再对超长section做递归切分,同时保留标题路径作为metadata注入chunk头部:

import re
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_core.documents import Document

def structure_aware_split(md_text: str, doc_id: str, max_len: int = 800):
    """
    按 Markdown 标题层级切分,超长 section 再递归切。
    chunk 头部拼接标题路径,提升 embedding 的语义完整性。
    """
    # 按 ## / ### 标题切块,保留标题行
    blocks = re.split(r'\n(?=#{2,3}\s)', md_text)
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=max_len,
        chunk_overlap=100,
        separators=["\n\n", "\n", "。", ";", " ", ""],
        length_function=len,
    )
    docs = []
    current_h1 = ""
    for block in blocks:
        block = block.strip()
        if not block:
            continue
        # 记录一级标题作为路径前缀
        m1 = re.match(r'#\s+(.+)', block)
        if m1:
            current_h1 = m1.group(1).strip()
            continue
        m2 = re.match(r'(#{2,3})\s+(.+)', block)
        heading = m2.group(2).strip() if m2 else ""
        path = f"{current_h1} > {heading}" if heading else current_h1

        if len(block)  排查步骤”这样的路径进入embedding,解决孤立段落语义漂移的问题。

### 4.2 Embedding切换

中文场景下ada-002其实不差,但有两个硬伤:一是按token计费,12万文档全量重建索引成本不低;二是中文短query的区分度一般。我们对比了三个候选(都在评估集上跑):

| 模型 | 维度 | Recall@4 | 单条推理(CPU) | 备注 |
|---|---|---|---|---|
| text-embedding-ada-002 | 1536 | 0.71 | API调用 | 基线,chunk优化后 |
| bge-large-zh-v1.5 | 1024 | 0.78 | ~18ms | 中文最优档 |
| bge-m3 | 1024 | 0.79 | ~45ms | 多语言强,但慢 |

最终选bge-large-zh-v1.5,Recall提升明显且能本地部署,批量编码12万chunk在单张A10上约25分钟。注意bge系列检索时要加query instruction,这是官方推荐的用法,不加会掉2-3个点:

```python
from sentence_transformers import SentenceTransformer
import numpy as np

model = SentenceTransformer("BAAI/bge-large-zh-v1.5", device="cuda")
# 关键:query 侧加 instruction,doc 侧不加
QUERY_INSTR = "为这个句子生成表示以用于检索相关文章:"

def encode_queries(texts):
    return model.encode(
        [QUERY_INSTR + t for t in texts],
        normalize_embeddings=True,
        batch_size=64,
    )

def encode_docs(texts):
    return model.encode(
        texts,
        normalize_embeddings=True,
        batch_size=128,
    )

归一化后直接用内积当余弦相似度,FAISS用IndexFlatIP。12万条1024维向量,内存占用约470MB,单机完全扛得住。

4.3 Rerank引入

bi-encoder召回top20,再上bge-reranker-base做cross-encoder精排,取top4进prompt。reranker输入是(query, chunk)对,逐对打分,20对在A10上约35ms(batch=20)。

from FlagEmbedding import FlagReranker

reranker = FlagReranker("BAAI/bge-reranker-base", use_fp16=True)

def rerank(query: str, candidates: list[dict], top_n: int = 4):
    pairs = [[query, c["content"]] for c in candidates]
    scores = reranker.compute_score(pairs, normalize=True)
    for c, s in zip(candidates, scores):
        c["rerank_score"] = float(s)
    return sorted(candidates, key=lambda x: x["rerank_score"],
                  reverse=True)[:top_n]

阈值上我们没有做硬截断,因为运维问答里“部分相关”的段落有时也有用,硬截断反而丢信息。只看排序,取top4。

五、踩坑与优化

坑1:chunk变长后top_k=4塞爆prompt。 800字符×4加上标题路径,单次上下文约3500 token,加上系统prompt逼近4k。解决:chunk上限压到800的同时,把进prompt的top_k从4降到3,rerank后取3,实测准确率没掉,延迟降了。

坑2:bge的instruction加错位置。 一开始doc侧也加了instruction,Recall反而掉到0.74。查了官方说明才确认instruction只加query侧。这个细节很多教程写错,值得单独提。

坑3:reranker的normalize参数。 compute_score默认不归一化,直接拿原始logits排序没问题,但如果要和阈值比较必须开normalize。我们线上只排序不比阈值,所以开了normalize方便监控分布。

坑4:FAISS索引重建期间的服务降级。 全量重建索引要25分钟,期间用旧索引+新chunk会错位。做法是双索引灰度:新索引写好后原子切换,切换前用10%流量对比两版召回结果,确认无异常再全量。

坑5:长文档的metadata膨胀。 标题路径注入后,部分深层文档path长达80字符,占了chunk十分之一。限制path最多两级,超出截断,embedding质量没有可感知下降。

六、效果数据

三轮迭代的对比(同一评估集500条,人工判定):

阶段 Recall@4/5 MRR 首答准确率 P95检索延迟
基线 0.68 0.54 61% 120ms
+chunk优化 0.71 0.58 65% 118ms
+embedding切换 0.78 0.66 73% 135ms
+rerank(top20→3) 0.89 0.81 84% 315ms

几点观察:

  • chunk优化的增益比预期小(+4pt),但它是后面两步的地基,单独看低估了它的价值。
  • embedding切换贡献最大的一步(+8pt),中文场景换中文模型收益确实直接。
  • rerank把Recall@3从0.78拉到0.89,代价是P95延迟从135ms到315ms。考虑到LLM生成本身要1-2秒,这180ms用户无感,性价比极高。
  • 最终首答准确率84%,剩余16%的badcase里,生成阶段问题占了大头(模型漏答、格式错乱),检索侧基本到位了。

线上灰度两周,用户“答非所问”类反馈下降约70%,平均会话轮次从2.8降到1.9,说明一次答对的概率确实上来了。

七、总结

这次优化的核心体会:RAG的瓶颈大概率在检索侧,而检索侧的顺序应该是chunk → embedding → rerank,不要一上来就堆rerank。 chunk边界错了,reranker也救不回来;embedding不匹配语种,召回池里根本没正确结果,精排无从谈起。

另外两个工程细节值得强调:一是评估集必须提前建好,500条人工标注query是这次所有决策的依据,没有它就是在盲调;二是每步单独评估,多变量一起上,效果涨了也不知道是谁的功劳,跌了更抓瞎。

后续计划:把生成侧的prompt做结构化约束(要求按“现象-原因-步骤”输出),再试试query改写(HyDE)看能不能把剩下那16%再啃下来。有进展再写一篇。