一、问题背景:一个“能跑但不好用”的知识库
去年年底我接手了一个内部知识库问答系统,底层是标准的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,内积+归一化等价余弦)。
三、方案设计:三步走
优化思路很直接,按“召回 → 精排 → 生成”的链路逐层处理:
- Chunk策略调整:从固定长度切分改为“语义分层切分 + 父子块”,子块用于检索,父块用于喂给LLM。
- Embedding模型切换:从OpenAI text-embedding-ada-002换成bge-large-zh-v1.5,中文语义更强,且可本地部署。
- 引入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,但如果召回的内容本身就不对,再强的模型也白搭。
三条可复用的经验:
- 切分要贴合语义结构,父子块是一个性价比极高的方案,子块保证召回精度,父块保证上下文完整;
- 中文场景优先考虑BGE系列,bge-large-zh-v1.5 + bge-reranker-large 是目前开源里很稳的组合,注意query加指令、doc不加;
- Rerank是提升最大的一步,但要注意去重、top_n选择和延迟控制。
下一步打算试试query改写(HyDE)和混合检索(BM25+向量),有进展再写一篇。