RAG 实战:从文档切分到重排与评估
检索增强生成(Retrieval-Augmented Generation,RAG)是当前把大语言模型(LLM)落地到企业知识库、客服、文档问答等场景时最稳妥的一项工程范式。它的核心思想很简单:在让模型生成答案之前,先从一个可信的知识库里检索出相关片段,把这些片段作为上下文喂给模型,从而让回答建立在”有出处”的证据之上。本文沿着一条完整的工程链路,从文档切分一直讲到重排与评估,给出可运行的示例代码与选型建议。
RAG 是什么、为什么需要
LLM 本身擅长语言生成,但存在三个固有短板:一是幻觉,模型会用流畅的语气编造它并不掌握的事实;二是知识时效,模型权重冻结于训练时刻,难以覆盖之后的信息;三是私有知识,企业内部的制度、手册、代码库无法进入公开训练数据。
RAG 通过”检索—增强—生成”三段式流程缓解上述问题:
- 检索(Retrieve):根据用户问题,从文档集合中找出最相关的若干片段。
- 增强(Augment):把检索到的片段与原始问题拼接成提示(prompt)。
- 生成(Generate):LLM 基于给定上下文生成答案,并尽量引用来源。
def rag_pipeline(query: str, retriever, llm) -> str:
# 1. 检索相关上下文
contexts = retriever.search(query, top_k=5)
# 2. 组装提示
prompt = build_prompt(query, contexts)
# 3. 生成答案
return llm.generate(prompt)
需要强调:RAG 不是”接上数据库就万事大吉”。检索质量直接决定上限,生成只是把检索到的内容转化成自然语言。下面逐段拆解每个环节的工程取舍。
文档解析与切分
原始文档往往是 PDF、Word、HTML 或 Markdown,第一步是解析为纯文本。解析环节要保留标题层级、表格、页码等结构信息,因为结构本身就是高质量的元数据。
切分(chunking)是把长文档拆成若干”块”的过程,块是检索的最小单位。常见策略有三种:
- 按段落/结构切分:依据标题层级或自然段落切,语义边界最自然,但块长不均。
- 固定窗口切分:按固定 token 数(如 256/512)滑动窗口切,实现简单、长度可控,但可能把一个完整句子拦腰截断。
- 语义切分:先用 Embedding 计算句间相似度,在相似度骤降处切分,能保持语义完整,但计算开销更大。
chunk_size 与 overlap 是核心超参。chunk_size 太小会丢失上下文,太大则检索噪声高、且容易超出上下文窗口;overlap(相邻块的重叠 token 数)能避免关键信息恰好落在切分边界而丢失,但过大会增加冗余与存储成本。经验上,构建知识库时常用 256–512 token 的块配 10%–20% 的 overlap。
def sliding_window_chunks(text: str, chunk_size: int = 512, overlap: int = 64):
step = chunk_size - overlap
chunks = []
for start in range(0, len(text), step):
chunk = text[start: start + chunk_size]
if chunk.strip():
chunks.append(chunk)
return chunks
切分时务必保留元数据:来源文件名、章节标题、页码、修改时间等。这些元数据既能用于过滤(例如只检索某个产品手册),也能在答案中给出引用出处,提升可信度。
实践中,主流框架多采用”递归字符切分”:优先在段落边界切,切不开再退到换行、再退到空格,尽量不破坏句子。需要特别注意,chunk_size 应以token 数而非字符数衡量,因为 LLM 的上下文窗口是按 token 计的;中英文混排时同样字符数对应的 token 数差异很大。若文档含大量表格或代码,还应使用专门的表格/代码识别解析器,避免把一行代码拆成两半导致语义断裂。
Embedding 选型
检索阶段需要把文本变成向量,这一步依赖 Embedding 模型。选型时关注三点:
- 维度:维度越高通常表达能力越强,但存储与计算成本也更高,常见为 768、1024 或 1536 维。
- 归一化:检索前应将向量 L2 归一化,这样点积等价于余弦相似度,计算更快。
- 语种与领域:中文场景要选对中文友好的模型。
国际上常用 OpenAI 的 text-embedding 系列;国内可无障碍使用的是 BAAI 的 bge 系列(如 bge-large-zh)与 m3e 系列(m3e-base)。bge 在多项中文/多语检索榜单上表现靠前,且社区活跃;m3e 对中文场景训练更充分、部署轻量。若数据涉隐私,应优先选择可本地部署的开源模型,而不是调用云端 API。
import numpy as np
def embed(texts, model):
vecs = model.encode(texts)
# L2 归一化,使余弦相似度 = 点积
norms = np.linalg.norm(vecs, axis=1, keepdims=True)
return vecs / norms
def cosine_search(query_vec, doc_vecs, top_k=5):
scores = doc_vecs @ query_vec # 已归一化,点积即余弦
idx = np.argsort(-scores)[:top_k]
return idx, scores[idx]
务必注意:查询向量与文档向量必须使用同一个 Embedding 模型,否则相似度毫无意义。
向量检索与混合检索
把全部文档块向量化后写入向量库(如 FAISS、Milvus、pgvector 等),查询时做最近邻搜索,得到 Top-K 候选。但纯向量检索有两个问题:一是对精确关键词(如产品型号、报错码)不敏感;二是在语料分布偏移时容易召回偏差。
混合检索(Hybrid Search) 把稀疏检索(BM25,基于词频统计)与稠密检索(向量)结合,用加权或倒数排名融合(RRF)合并结果。BM25 擅长”字面匹配”,向量擅长”语义匹配”,两者互补能显著提升召回率。
def rrf_merge(bm25_rank, vec_rank, k=60):
scores = {}
for rank_list in (bm25_rank, vec_rank):
for rank, doc_id in enumerate(rank_list):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank + 1)
return sorted(scores, key=scores.get, reverse=True)
Top-K 并非越大越好:K 太小召回不足,K 太大会引入噪声并挤占上下文。通常先取较大的候选(如 20–50)交给后续重排环节筛选。
重排序(Rerank)
向量检索是”快但粗”的近似匹配,重排序则是”慢但准”的精排。经典做法是两阶段检索:先用向量/BM25 快速召回一批候选,再用 Cross-Encoder 重排模型对”查询—文档”对逐对打分,按相关性重新排序。
为什么 Cross-Encoder 更有效?Bi-Encoder(向量模型)把查询与文档分别编码、靠余弦距离比较,二者从未”见面”;而 Cross-Encoder 把查询与文档拼接后联合编码,能捕捉细粒度的交互特征,排序质量更高,代价是每次都要前向推理,无法离线建索引,因此只适合对小候选集精排。
from transformers import AutoTokenizer, AutoModelForSequenceClassification
tok = AutoTokenizer.from_pretrained("BAAI/bge-reranker-base")
model = AutoModelForSequenceClassification.from_pretrained("BAAI/bge-reranker-base")
def rerank(query, docs, top_n=5):
pairs = [(query, d) for d in docs]
inputs = tok(pairs, padding=True, truncation=True, return_tensors="pt", max_length=512)
scores = model(**inputs).logits.squeeze(-1)
order = scores.argsort(descending=True)[:top_n]
return [docs[i] for i in order]
两阶段检索兼顾了召回的广度与排序的精度,是生产级 RAG 的标配。
需要权衡的是延迟与成本:重排模型通常比 Embedding 大、推理更慢,对每块都要做一次前向计算,因此候选集规模要控制(一般 20–50 块),否则会成为链路瓶颈。如果查询多为精确的术语匹配(如报错码、料号),应调高 BM25 的融合权重;若查询偏口语化、同义多,则更应依赖向量召回。理想做法是用评估集做小范围网格搜索,找出适合自身语料的融合系数与 Top-K。
上下文压缩与提示组装
重排后得到的 Top-N 块仍可能过长。直接全部拼进提示会导致两个问题:一是超出上下文窗口;二是无关句子充当噪声,稀释关键证据,反而降低答案质量。
上下文压缩指在送入 LLM 前,对检索块做裁剪或抽取,只保留与问题相关的句子。简单做法是用重排模型对块内句子再打分,只保留高分句;也可用 LLM 自身做”压缩摘要”。最后按相关性从高到低组装提示,并在每块前标注来源,便于模型引用与用户核查。
def build_prompt(query, ranked_chunks, max_tokens=3000):
parts = ["你是一个严谨的助手,请仅依据下方资料回答,并注明来源。\n"]
used = 0
for i, chunk in enumerate(ranked_chunks):
block = f"[来源 {i+1}] {chunk['source']}\n{chunk['text']}\n"
if used + len(block) > max_tokens:
break
parts.append(block)
used += len(block)
parts.append(f"问题:{query}\n回答:")
return "\n".join(parts)
压缩的尺度要拿捏:过度压缩会丢掉限定条件(如”除北京外""在 v2 版本中”),让模型给出以偏概全的答案;完全不压缩又会引入干扰。一个实用经验是,先保留重排分数最高的若干块整体,再仅对剩余块做句级裁剪,这样关键证据优先完整保留,次要证据才被精简。
评估:RAGAS 与人工评估
没有评估,就无法迭代。RAG 的评估应覆盖”检索”与”生成”两个维度。RAGAS(Retrieval Augmented Generation Assessment)是一套无需人工标注真值的自动化评估框架,由 Shahul Es 等人于 2023 年提出(arXiv:2309.15217,最新修订于 2025 年 4 月)。它定义了若干核心指标:
- Faithfulness(忠实度):答案是否被检索到的上下文所支撑,衡量幻觉程度。
- Answer Relevancy(答案相关性):答案是否切题、回应了用户问题。
- Context Precision(上下文精度):检索到的相关块是否排在前面。
- Context Recall(上下文召回):是否检索到了回答问题所需的全部相关信息。
from ragas import evaluate
from ragas.metrics import (
faithfulness, answer_relevancy, context_precision, context_recall
)
result = evaluate(
dataset=test_samples, # 含 question / answer / contexts / reference
metrics=[faithfulness, answer_relevancy, context_precision, context_recall],
)
print(result)
RAGAS 适合做回归测试与版本对比。但在上线前,仍建议对一批典型问题做人工评估,重点看答案的事实准确性与来源合理性——自动化指标无法完全替代人的判断。
解读分数时需注意:Faithfulness 与 Answer Relevancy 衡量的是”生成”质量,若这两项低,问题多半出在提示组装或模型本身;Context Precision 与 Context Recall 衡量的是”检索”质量,若这两项低,则应回头调切分策略、Embedding 或重排。把四类指标拆开看,才能定位瓶颈在哪一段。RAGAS 的分数本身没有放之四海皆准的”及格线”,更合理的用法是建立基线后做版本间的相对对比,例如更换 Embedding 或调整 chunk_size 后观察各项指标的变化趋势。
常见失效模式
实践中 RAG 常在以下环节翻车:
- 检索不准:切分过碎、Embedding 与语料不匹配、缺少混合检索,导致召回的块不相关。
- 上下文污染:Top-K 过大或未按相关性排序,无关块混入,模型被噪声带偏。
- 压缩丢信息:过度裁剪把关键句删掉,模型”巧妇难为无米之炊”。
- 提示组装失当:未标注来源,模型无法引用,可信度下降。
定位问题时,应把检索结果和最终提示分别打印出来人工核查,而非只盯最终答案——多数”答非所问”的根源在检索而非生成。
进阶方向
当文档关系复杂、需要多跳推理时,可关注两个方向:GraphRAG 把实体与关系构造成图,支持全局性、总结性问答;Self-RAG 让模型在生成过程中自行决定”是否需要检索""检索是否充分”,实现自适应检索。两者都建立在本文所述基础链路之上,可作为后续的深化路线。
小结
RAG 的价值在于用”检索到的证据”约束模型的生成,从而缓解幻觉、时效与私有知识三类问题。一条生产级链路通常是:先做结构化解析与切分并保留元数据,再选用合适的 Embedding 并归一化建库,接着用向量与 BM25 做混合召回,然后用 Cross-Encoder 重排精排,再做上下文压缩与带来源组装,最后用 RAGAS 等指标持续评估。每一环都有取舍:chunk_size 与 overlap 决定检索粒度,Top-K 与重排平衡召回与精度,压缩在”信息完整”与”噪声控制”之间权衡。把检索质量当作第一优先级,RAG 才能真正可靠。
参考与延伸阅读
- RAGAS 论文(arXiv:2309.15217):https://arxiv.org/abs/2309.15217
- RAGAS 官方文档:https://docs.ragas.io/
- LangChain 官方文档:https://python.langchain.com/docs/
- LlamaIndex 官方文档:https://docs.llamaindex.ai/
- BAAI FlagEmbedding(bge 系列,含 bge-reranker):https://github.com/FlagOpen/FlagEmbedding
- m3e 项目主页:https://github.com/moka-ai/m3e