一、问题背景

去年底我接手了一个企业内部知识库问答系统,底层是典型的RAG架构:文档切片 → 向量化 → 检索Top-K → 拼接prompt → LLM生成答案。知识库覆盖约1.2万篇内部文档,包括产品手册、运维SOP、API文档和历史工单,总token量大概在800万左右。

上线第一周就被业务方打回来了。典型bad case长这样:

  • 用户问“订单服务的限流阈值是多少”,系统检索到的却是“支付服务限流配置”,答非所问;
  • 用户问“K8s集群升级流程”,检索片段把流程的第三步和第七步混在一起,答案逻辑断裂;
  • 用户问“XX接口是否支持批量”,检索到的是另一个同名接口的文档,直接产生幻觉。

我拉了一批200条真实query做了评测,结果如下:

指标 初始版本
Top-5 命中率 0.63
MRR@10 0.51
答案幻觉率(人工抽检) 22%
P99 端到端延迟 2.7s

问题定位下来有三块:chunk切得太粗暴、embedding模型对中文语义不友好、检索只做了向量召回没有精排。接下来就是逐个击破。

二、环境与版本

整个系统跑在一台 8C32G + A10(24G) 的机器上,技术栈如下:

Python            3.10.13
torch             2.1.2+cu121
transformers      4.36.2
langchain         0.1.10
langchain-community 0.0.28
faiss-cpu         1.7.4          # 1.2万文档,CPU足够
FlagEmbedding     1.2.10
openai            1.12.0         # 早期用ada-002
sentence-transformers 2.5.1
fastapi           0.109.2
uvicorn           0.27.1

LLM 用的是 gpt-3.5-turbo-0125,temperature 设 0.1,max_tokens 1024。评测集固定 200 条,人工标注了每条的 gold chunk,避免调优过程中评测集漂移。

三、方案设计

整体思路分三步走,每一步都单独评测,避免多个变量耦合导致无法归因。

第一步:chunk策略调整。 原来用的是 RecursiveCharacterTextSplitter,chunk_size=500,overlap=0。问题是中文文档里标题、表格、代码块被硬切,一个完整的SOP被切成三段。改成 标题感知 + 递归切分 + 语义边界 的混合策略:

  1. 先用 markdown 标题(#/##/###)做一级切分,保证每段在一个语义单元内;
  2. 对超长段落再用递归切分,chunk_size=800,overlap=100;
  3. 表格和代码块整体保留,不参与切分;
  4. 每个chunk头部拼上所属标题路径(breadcrumb),比如 产品手册 > 订单服务 > 限流配置,提升embedding时的上下文信息。

第二步:embedding模型切换。 text-embedding-ada-002 是1536维,中文短文本区分度不够,尤其在我们这种"同名接口不同文档"的场景下召回经常串味。换成 BAAI/bge-large-zh-v1.5,1024维,中文语义明显更好。切换时有个关键点:bge系列在检索场景下需要在query前面加instruction前缀 为这个句子生成表示以用于检索相关文章:,passage则不加,否则效果反而会掉。

第三步:引入rerank。 向量召回本质是双塔,query和doc没有交互,精排能补这一刀。用 BAAI/bge-reranker-large 做cross-encoder重排,召回Top-20 → rerank取Top-5。

四、核心实现

4.1 标题感知的chunk切分

```python
import re
from typing import List, Dict
from langchain.text_splitter import RecursiveCharacterTextSplitter

HEADER_RE = re.compile(r'^(#{1,3})\s+(.*)$', re.MULTILINE)

def split_by_headers(text: str) -> List[Dict]:
"""按标题切分,保留标题路径 breadcrumb"""
lines = text.split('\n')
chunks, stack, buf = [], [], []
for line in lines:
m = HEADER_RE.match(line)
if m:
# 遇到新标题,先把 buffer 落盘
if buf:
content = '\n'.join(buf).strip()
if content:
chunks.append({
'breadcrumb': ' > '.join(stack),
'content': content
})
buf = []
level = len(m.group(1))
title = m.group(2).strip()
stack = stack[:level-1] + [title]
else:
buf.append(line)
if buf:
content = '\n'.join(buf).strip()
if content:
chunks.append({'breadcrumb': ' > '.join(stack), 'content': content})
return chunks

def is_table_or_code(block: str) -> bool:
s = block.strip()
return s.startswith('```') or s.startswith('|') or '\n|' in s

def build_chunks(text: str, chunk_size=800, overlap=100) -> List[Dict]:
splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=overlap,
separators=['\n\n', '\n', '。', ';', ',', ' ', ''],
length_function=len,
)
result = []
for sec in split_by_headers(text):
# 代码块/表格整体保留
if is_table_or_code(sec['content']) and len(sec['content']) 订单服务 > 限流配置】` 前缀后,短 chunk 的语义会被标题稀释。解决办法是 embedding 时用带 breadcrumb 的文本,但送进 LLM 的 prompt 里只保留原始内容,避免干扰生成。

六、效果数据

每一步的增量效果如下(评测集 200 条 query,人工标注 gold chunk):

版本 Top-5 命中率 MRR@10 幻觉率 P99 延迟
V0 基线(ada-002 + 固定切分) 0.63 0.51 22% 2.7s
V1 + 标题感知切分 0.71 0.58 18% 2.6s
V2 + bge-large-zh-v1.5 0.81 0.69 12% 2.5s
V3 + bge-reranker-large 0.89 0.78 6% 3.1s
V3 + 阈值过滤 0.89 0.79 4% 3.0s

延迟那块说明一下:V3 引入 rerank 后 P99 从 2.5s 涨到 3.1s,主要开销在 reranker 前向。后来把召回从 Top-30 降到 Top-20,加上 fp16 和批处理,P99 压回 3.0s。如果对延迟敏感,可以把 reranker 换成 bge-reranker-base,P99 能到 2.2s,但命中率会掉 3 个点左右,取舍看业务。

另外整个链路还有一个隐性收益:prompt 里的无效片段少了,gpt-3.5-turbo 的输入 token 从平均 3200 降到 1800,单次调用成本降了约 44%。

七、总结

回头看这次调优,最大的体会是:RAG 的效果不是靠堆模型堆出来的,而是每一环都要对齐。chunk 切得烂,再好的 embedding 也救不回来;embedding 再好,没有 rerank 交互建模,双塔的天花板就在那;rerank 再强,不设阈值过滤,幻觉照样存在。

如果让我给后来者排个优先级:切分策略 > embedding 模型 > rerank > 阈值调参。前两步是地基,投入产出比最高,且几乎不增加推理延迟。rerank 是提天花板的一刀,但会带来延迟和显存成本,需要结合业务场景权衡。

下一步我打算试试 query rewrite(用 LLM 把用户口语 query 改写成检索友好的形式)和 hybrid search(BM25 + 向量),看能不能把 Top-5 命中率再往上顶一顶。有兴趣的可以一起交流。