RAG 实战:从文档切分到重排与评估

检索增强生成(Retrieval-Augmented Generation,RAG)是当前把大语言模型(LLM)落地到企业知识库、客服、文档问答等场景时最稳妥的一项工程范式。它的核心思想很简单:在让模型生成答案之前,先从一个可信的知识库里检索出相关片段,把这些片段作为上下文喂给模型,从而让回答建立在”有出处”的证据之上。本文沿着一条完整的工程链路,从文档切分一直讲到重排与评估,给出可运行的示例代码与选型建议。

RAG 是什么、为什么需要

LLM 本身擅长语言生成,但存在三个固有短板:一是幻觉,模型会用流畅的语气编造它并不掌握的事实;二是知识时效,模型权重冻结于训练时刻,难以覆盖之后的信息;三是私有知识,企业内部的制度、手册、代码库无法进入公开训练数据。

RAG 通过”检索—增强—生成”三段式流程缓解上述问题:

  1. 检索(Retrieve):根据用户问题,从文档集合中找出最相关的若干片段。
  2. 增强(Augment):把检索到的片段与原始问题拼接成提示(prompt)。
  3. 生成(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_sizeoverlap 是核心超参。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 才能真正可靠。

参考与延伸阅读

本文累计阅读