一、问题背景:一个“能跑但不好用”的知识库

去年年底我接手了一个内部知识库问答系统,底层是标准的RAG(Retrieval-Augmented Generation)架构:文档切分 → 向量化入库 → 向量检索 → 拼接上下文 → LLM生成答案。

上线第一周就收到一堆吐槽:

  • 问“报销流程需要哪些材料”,检索出来的却是“差旅报销标准”的片段,答非所问;
  • 问一个跨章节的问题,比如“新员工入职第一周要完成哪些事”,召回的都是零碎的单句,模型拼不出完整答案;
  • 有些明明在文档里的内容,检索就是召不回来,用户说“我明明看到过”。

我拉了一批真实query做了离线评估,结果很难看:

指标 数值
Top-5 召回率 0.72
答案准确率(人工评估) 61%
P99 延迟 1.8s
文档规模 约2000篇,平均每篇1200字

问题定位下来主要在三块:chunk切得太粗暴、embedding对中文语义捕捉不够、没有重排序。下面按优化顺序记录。

二、环境与版本

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

Python            3.10.13
langchain         0.1.16
langchain-community 0.0.32
faiss-cpu         1.8.0
sentence-transformers 2.7.0
FlagEmbedding     1.2.10
openai            1.23.2   # 仅用于LLM生成
torch             2.2.1 (cu121)

LLM部分用的是内部部署的Qwen1.5-14B-Chat,温度0.1,max_tokens 1024。检索层是本文重点,向量库用FAISS(IndexFlatIP,内积+归一化等价余弦)。

三、方案设计:三步走

优化思路很直接,按“召回 → 精排 → 生成”的链路逐层处理:

  1. Chunk策略调整:从固定长度切分改为“语义分层切分 + 父子块”,子块用于检索,父块用于喂给LLM。
  2. Embedding模型切换:从OpenAI text-embedding-ada-002换成bge-large-zh-v1.5,中文语义更强,且可本地部署。
  3. 引入Rerank:召回Top-20后用bge-reranker-large做cross-encoder精排,取Top-5。

每一步单独评估,避免“一起改完不知道是谁的功劳”。

四、核心实现

4.1 Chunk策略:从512定长到父子块

最初的切分就是RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50)。问题在于:它按字符数硬切,经常把一段完整逻辑切碎,且检索到的是碎片,上下文不完整。

改成两层结构:

  • 子块(child):约200字,按标点/段落边界切,用于向量检索,粒度细、命中准;
  • 父块(parent):子块所属的完整章节(约800-1200字),检索命中子块后返回父块给LLM。
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.docstore.document import Document
import uuid

def build_parent_child_chunks(raw_text: str, doc_id: str):
    # 父块:按标题/大段落切
    parent_splitter = RecursiveCharacterTextSplitter(
        chunk_size=1000,
        chunk_overlap=100,
        separators=["\n## ", "\n### ", "\n\n", "\n", "。", ""],
    )
    # 子块:更细粒度
    child_splitter = RecursiveCharacterTextSplitter(
        chunk_size=200,
        chunk_overlap=30,
        separators=["\n", "。", ";", ",", ""],
    )

    parents, children = [], []
    for p_text in parent_splitter.split_text(raw_text):
        p_id = str(uuid.uuid4())
        parents.append(Document(page_content=p_text,
                                metadata={"doc_id": doc_id, "parent_id": p_id}))
        for c_text in child_splitter.split_text(p_text):
            children.append(Document(
                page_content=c_text,
                metadata={"doc_id": doc_id, "parent_id": p_id}
            ))
    return parents, children

检索时只对children建索引,命中后通过parent_id回查父块。这一步单独做完,Top-5召回率从0.72涨到0.78。

4.2 Embedding切换:bge-large-zh-v1.5

ada-002在中文短句上的区分度确实一般,尤其是近义表述(“报销材料”vs“报销所需凭证”)。换成BGE中文模型后提升明显。

from sentence_transformers import SentenceTransformer
import numpy as np
import faiss

MODEL_PATH = "BAAI/bge-large-zh-v1.5"
model = SentenceTransformer(MODEL_PATH, device="cuda")
# BGE官方建议:query侧加指令前缀,doc侧不加
QUERY_INSTRUCTION = "为这个句子生成表示以用于检索相关文章:"

def embed(texts, is_query=False):
    if is_query:
        texts = [QUERY_INSTRUCTION + t for t in texts]
    emb = model.encode(texts, normalize_embeddings=True,
                       batch_size=64, show_progress_bar=False)
    return np.asarray(emb, dtype="float32")

# 建索引
child_texts = [d.page_content for d in children]
child_embs = embed(child_texts, is_query=False)
index = faiss.IndexFlatIP(child_embs.shape[1])
index.add(child_embs)

def search(query, top_k=20):
    q = embed([query], is_query=True)
    scores, idx = index.search(q, top_k)
    return [(children[i], float(s)) for s, i in zip(scores[0], idx[0])]

注意两个坑:一是query必须加指令前缀,doc不加,这是BGE的推荐用法,不加会掉几个点;二是normalize_embeddings=True配合IndexFlatIP,等价于余弦相似度。

这一步做完,Top-5召回率从0.78到0.82,但延迟也上来了——bge-large在单张3090上单条query编码约35ms,比调API慢,不过可接受。

4.3 Rerank:引入cross-encoder精排

向量检索是双塔(bi-encoder),query和doc各自编码,快但精度有限。Rerank用cross-encoder把query和doc拼在一起过模型,精度高但慢,所以只对Top-20做精排。

from FlagEmbedding import FlagReranker

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

def rerank(query, candidates, top_n=5):
    pairs = [[query, doc.page_content] for doc, _ in candidates]
    scores = reranker.compute_score(pairs, normalize=True)
    ranked = sorted(zip(candidates, scores),
                    key=lambda x: x[1], reverse=True)
    return ranked[:top_n]

# 完整检索流程
def retrieve(query):
    coarse = search(query, top_k=20)      # 向量召回
    fine = rerank(query, coarse, top_n=5) # 精排
    # 用parent_id回查父块,去重后拼接
    seen, contexts = set(), []
    for (doc, _), score in fine:
        pid = doc.metadata["parent_id"]
        if pid in seen:
            continue
        seen.add(pid)
        contexts.append(parent_map[pid])
    return contexts

reranker用fp16推理,20对约180ms(3090)。这一步是提升最大的一步:Top-5召回率直接从0.82跳到0.89。

五、踩坑与优化

坑1:加了指令前缀但用错侧。 一开始query和doc都加了前缀,召回率反而降了。查了BGE文档才知道doc侧不能加。

坑2:父子块导致上下文重复。 多个子块命中同一父块时会重复拼接,浪费token。加了parent_id去重(上面代码里的seen集合)。

坑3:rerank后top_n取太小。 一开始取Top-3,答案准确率反而降了,因为有些问题的答案分散在多个父块。调到Top-5后稳定。

坑4:延迟。 引入rerank后P99从1.8s涨到2.4s。做了两件事缓解:一是reranker用fp16+批处理,二是把向量召回Top-20减到Top-15(召回率几乎无损,rerank耗时降了约25%)。

坑5:FAISS索引没归一化。 早期用IndexFlatL2但embedding没归一化,导致距离度量不一致,排查了半天。

六、效果数据

同一批200条真实query,人工评估答案准确率(答案覆盖问题要点即算对):

阶段 Top-5召回率 答案准确率 P99延迟
基线(512定长 + ada-002) 0.72 61% 1.8s
+父子块切分 0.78 68% 1.9s
+bge-large-zh-v1.5 0.82 74% 2.0s
+bge-reranker-large 0.89 84% 2.4s

延迟增加0.6s,换来召回率+17个点、准确率+23个点,这个trade-off我认为非常值。如果对延迟敏感,可以把rerank换成bge-reranker-base,召回率大约0.86,延迟只增加约80ms。

七、总结

这次优化的核心体会是:RAG的效果瓶颈往往在检索层,而不是生成层。很多人一上来就换更大的LLM,但如果召回的内容本身就不对,再强的模型也白搭。

三条可复用的经验:

  1. 切分要贴合语义结构,父子块是一个性价比极高的方案,子块保证召回精度,父块保证上下文完整;
  2. 中文场景优先考虑BGE系列,bge-large-zh-v1.5 + bge-reranker-large 是目前开源里很稳的组合,注意query加指令、doc不加;
  3. Rerank是提升最大的一步,但要注意去重、top_n选择和延迟控制。

下一步打算试试query改写(HyDE)和混合检索(BM25+向量),有进展再写一篇。