RAG 核心引擎深度解析:分块、混合检索引擎与重排序

引言

上一篇文章我们从宏观视角梳理了 RAG 从 2020 年到 2026 年的完整演进脉络。这篇文章将深入技术内部,逐一剖析 Advanced RAG 流水线的三个核心环节:

  1. 分块策略:如何将原始文档切分为语义完整的检索单元
  2. 混合检索:如何融合稠密向量的语义匹配与 BM25 的关键词匹配
  3. 重排序:如何用更精确的模型对候选结果进行二次筛选

我们将以 CazzKB 开源项目(https://github.com/YangCazz/CazzKB)的真实实现代码作为参照,展示每个技术决策的工程考量。

graph LR
    Raw[原始文档] --> Parse[语义解析<br/>Markdown → Elements]
    Parse --> Chunk[语义分块<br/>合并 + 拆分]
    Chunk --> Embed[向量嵌入]
    Chunk --> BM25[BM25 索引]
    Embed --> Chroma[(ChromaDB)]
    Query[用户查询] --> Dense[稠密检索]
    Query --> Sparse[稀疏检索]
    Chroma --> Dense
    BM25 --> Sparse
    Dense --> RRF[RRF 融合]
    Sparse --> RRF
    RRF --> Rerank[重排序]
    Rerank --> LLM[LLM 生成]

    style Parse fill:#1a237e,stroke:#4299e1,color:#e8edf5
    style Chunk fill:#1b2d3a,stroke:#667eea,color:#e8edf5
    style RRF fill:#1a1f3a,stroke:#7c3aed,color:#e8edf5
    style Rerank fill:#2a1a2e,stroke:#ed64a6,color:#e8edf5
    style LLM fill:#1a2a1a,stroke:#48bb78,color:#e8edf5

分块策略:语义完整性的艺术

分块(Chunking)是 RAG 系统最基础也最容易被低估的环节。分块质量直接决定了检索的精度上限——如果知识被打散在不合理的边界上,后续的嵌入、检索和重排序都无法弥补这一信息损失。

固定大小分块的问题

最简单的分块方式是固定 token 数切分(如每 512 token 一块,重叠 50 token)。但这种策略有严重的缺陷:

  • 语义断裂:一段完整的推理可能在中间被截断
  • 上下文丢失:列表项与引文段落被分到不同块中
  • 代码破坏:函数定义与实现体被拆分

固定分块在处理叙述性散文时勉强可用,但在处理技术文档(包含代码块、表格、公式、图表的 Markdown 文件)时表现糟糕。

CazzKB 的语义分块方案

CazzKB 采用了两步语义分块策略:先解析语义元素,再按 token 预算合并。

第一步:Markdown 语义解析

parser.py 将 Markdown 文本解析为结构化的 Element 序列,每个元素标注了类型、层级和在文档中的位置:

class ElementType(str, Enum):
    HEADER = "header"        # 标题(记录层级 1-6)
    CODE = "code"            # 代码块(记录语言)
    TABLE = "table"          # 表格
    MATH = "math"            # 数学公式块
    LIST = "list"            # 列表
    BLOCKQUOTE = "blockquote" # 引用块
    TEXT = "text"            # 普通文本
    MERMAID = "mermaid"      # Mermaid 图表
    FRONTMATTER = "frontmatter" # YAML 头部元数据

@dataclass
class Element:
    content: str
    type: ElementType
    level: int = 0
    header_path: str = ""   # 例:/SSM基础/连续时间模型
    meta: dict = field(default_factory=dict)

解析器维护一个 header_stack,追踪当前元素所处的文档层级路径。例如,在”状态空间模型基础”章节下的”连续时间模型”子章节中,每个元素的 header_path 都是 /状态空间模型基础/连续时间模型。这个路径信息随后被写入每个 chunk 的元数据,用于检索结果的上下文展示。

关键设计决策——保护类型:代码块、表格、数学公式和 Mermaid 图表被标记为”受保护”元素。它们在后续的合并阶段永远不会与其他元素合并,也不会被中途截断。这确保了技术文档中最具信息密度的内容保持完整。

第二步:Token 感知的合并与拆分

chunker.py 的 SemanticChunker 完成分块的两阶段操作:

合并阶段(_merge_elements):将相邻的小文本元素(段落、列表项、引用)合并为接近 max_tokens(默认 512)的块。遇到以下情况时触发”刷新”(结束当前块,开始新块):

  1. 遇到标题元素(标题总是开启一个新块)
  2. 遇到受保护类型(每个受保护元素独占一块)
  3. 当前缓冲区的 token 数超过 max_tokens
# 核心合并逻辑(简化为关键路径)
for elem in elements:
    if elem.type == ElementType.HEADER:
        flush()  # 标题开新块
        merged.append((elem.content, elem.type, elem.header_path))
    elif elem.type in protected_types:
        flush()  # 受保护类型独占一块
        merged.append((elem.content, elem.type, elem.header_path))
    elif would_exceed_max_tokens(buffer + elem):
        flush()  # 超预算则开新块
    buffer += elem.content

拆分阶段(_split_oversized):对于合并后仍然超过 max_tokens 的长文本块,按段落边界进行拆分,并保留 overlap_tokens(默认 50)的词级重叠,确保跨块边界的语义连续性。

Token 估算:不需要 tiktoken

CazzKB 使用了一个轻量级的 token 估算函数,避免了对语言特定分词器(如 tiktoken)的依赖:

def _estimate_tokens(text: str) -> int:
    chars = len(text)
    cjk = sum(1 for c in text if "一" <= c <= "鿿")
    return max(1, (chars - cjk) // 4 + cjk // 2)

中文约 2 字符 = 1 token,英文约 4 字符 = 1 token。虽然不如真实 tokenizer 精确,但对于分块边界判断已经足够——分块需要的是相对大小,而非绝对精确计数。

分块策略对比

策略 语义完整性 实现复杂度 适用场景
固定大小 低——经常截断语义单元 最低 散文、叙述性文本
递归分块 中——按分隔符层级切分 低 通用文档
语义分块 高——基于元素类型保护 中 技术文档、带代码的文本
动态分块 最高——LLM 参与判断边界 高(慢) 高质量要求场景
上下文增强分块 高——每块附加上下文摘要 高(需 LLM) 专业领域、高精度场景

语义分块在大多数技术文档场景下是最佳性价比的选择。LLM 参与的分块方式虽然可能更精确,但索引阶段的成本增加 10-20 倍,仅在检索质量有极高要求时才值得。


混合检索:语义与关键词的共生

纯向量检索存在一个根本性的局限:嵌入模型擅长近义词泛化,但在精确匹配上不如关键词检索。例如查询”Transformer 的多头注意力机制”,向量检索可能返回大量关于”注意力”的泛化结果,而 BM25 能精确锁定包含”多头注意力”的文档。

稠密检索:ChromaDB + 嵌入模型

CazzKB 使用 ChromaDB 作为向量存储。Chroma 是一个嵌入式向量数据库——它在进程内运行,不需要外部服务。对于个人知识库场景,这种零依赖的架构远比 Qdrant 或 Weaviate 等需要 Docker 的解决方案实用。

嵌入提供者采用工厂模式,支持 OpenAI(text-embedding-3-small,1536 维)和本地 Ollama(bge-m3,1024 维)两种方案:

# 嵌入提供者的注册与获取
_registry: dict[str, type[EmbeddingProvider]] = {}

@register_embedding
class OpenAIEmbedding(EmbeddingProvider):
    _FACTORY_NAME = "openai"

@register_embedding
class OllamaEmbedding(EmbeddingProvider):
    _FACTORY_NAME = "ollama"

配置文件中选择一行即可切换:

embedding:
  factory: ollama    # 或 openai
  model: bge-m3      # 或 text-embedding-3-small
  dimension: 1024    # 或 1536

对于个人或小团队使用,本地 Ollama 部署可以完全消除 API 调用成本。bge-m3 的 1024 维向量在中文技术文本上的表现与 OpenAI 的 1536 维差距不大,但延迟和成本有数量级的优势。

稀疏检索:BM25

BM25 是经典的概率检索模型,基于词频(TF)和逆文档频率(IDF)计算查询与文档的相关性:

\[\text{BM25}(q, d) = \sum_{t \in q} \text{IDF}(t) \cdot \frac{f(t,d) \cdot (k_1 + 1)}{f(t,d) + k_1 \cdot (1 - b + b \cdot \frac{|d|}{\text{avgdl}})}\]

CazzKB 使用 rank-bm25 库实现 BM25,并维护了精心设计的停用词表。中英文混合的技术博客场景有特殊的挑战——中文单字虚词(的、了、在、是)和高频英文功能词(the、a、is、of)虽然 IDF 已经为其分配了较低权重,但明确过滤能防止它们污染稀疏向量空间,改善融合质量[1]。

def _tokenize(text: str) -> list[str]:
    cleaned = text.lower().replace(".", " ").replace(",", " ")...
    tokens = cleaned.split()
    return [t for t in tokens if t not in _STOP_WORDS and len(t) > 1]

关键设计:BM25 索引在所有文档摄入完成后一次性构建(build()),而非增量更新。这避免了每次添加文档时重建整个索引的开销。

RRF 融合:让两个检索器对话

稠密检索和稀疏检索各自产生一个排序列表,但它们的得分尺度完全不同——余弦相似度在 0-1 区间,BM25 得分则是无界的正实数。直接加权求和几乎不可能合理。

Reciprocal Rank Fusion(RRF) 巧妙地绕过了得分归一化问题——只用排名,不看绝对分数[2]:

\[\text{RRF}(d) = \sum_{r \in R} \frac{1}{k + \text{rank}_r(d)}\]

CazzKB 的完整实现:

def reciprocal_rank_fusion(
    dense_results: list[tuple[str, str, float]],
    sparse_results: list[tuple[str, str, float]],
    k: int = 60,
    dense_weight: float = 0.7,
    top_k: int = 8,
) -> list[SearchResult]:
    # 构建排名映射
    dense_rank = {chunk_id: rank for rank, (chunk_id, _, _)
                  in enumerate(dense_results, start=1)}
    sparse_rank = {chunk_id: rank for rank, (chunk_id, _, _)
                   in enumerate(sparse_results, start=1)}

    all_ids = set(dense_rank.keys()) | set(sparse_rank.keys())

    # 对每个文档计算加权 RRF
    fused = []
    for chunk_id in all_ids:
        d_rank = dense_rank.get(chunk_id, len(dense_results) + 1)
        s_rank = sparse_rank.get(chunk_id, len(sparse_results) + 1)
        rrf = (dense_weight * (1.0 / (k + d_rank)) +
               (1 - dense_weight) * (1.0 / (k + s_rank)))
        fused.append((chunk_id, rrf))

    fused.sort(key=lambda x: x[1], reverse=True)
    return fused[:top_k]

几个值得注意的细节:

缺失文档的惩罚排名:如果某篇文档只被一个检索器找到,它在另一个检索器中的排名被设为 len(results) + 1(比最后一名还差一名)。这确保了单源文档不会被不合理地拔高排名。

dense_weight = 0.7:稠密检索的权重略高于稀疏。这是因为语义匹配在大多数查询中比关键词匹配更可靠——用户更容易用不同措辞表达相同意图(需要语义泛化),而非使用完全相同的关键词。

k = 60:平滑常数。较大的 k 值降低了排名差异的影响,使融合更”民主”;较小的 k 值则让高排名结果更具优势。60 是经验上的最佳平衡点[2]。

候选扩展:每个检索器实际检索 top_k * 2 个候选(而非仅 top_k),给融合阶段更大的选择空间。融合后再取 top_k * candidate_multiplier(默认 3 倍)送入重排序阶段。


重排序:二次精筛的性价比博弈

混合检索将候选文档从零压缩到约 24 篇。重排序的任务是从这 24 篇中挑出最相关的 5-8 篇送入 LLM。

为什么需要重排序

双编码器(嵌入模型)在检索时存在一个不可克服的限制:查询和文档被独立编码,无法在编码阶段看到对方的内容。这导致了”检索盲区”——一篇文档可能语义上接近查询,但缺乏关键细节;另一篇文档的嵌入向量可能不够接近,但包含了查询所需的精确信息。

Cross-Encoder 解决了这个问题:将查询和文档拼接后一起输入模型,让 Transformer 的注意力机制在查询和文档的每个 token 之间建立交互。代价是计算量激增——每对查询-文档都需要一次完整的前向传播。

CazzKB 的三种重排序策略

CazzKB 实现了三种重排序器,通过工厂模式统一切换:

class NoopReranker(RerankerProvider):
    """透传:不做重排序,直接返回 RRF 结果"""
    _FACTORY_NAME = "none"

class BGEReranker(RerankerProvider):
    """使用 FlagEmbedding 的 Cross-Encoder 重排序"""
    _FACTORY_NAME = "bge"
    # 内部使用 BAAI/bge-reranker-v2-m3

class LLMReranker(RerankerProvider):
    """使用 LLM 对每篇文档打分(0-1)"""
    _FACTORY_NAME = "llm"

NoopReranker:零成本透传。适合检索质量已经足够高的场景,或在资源受限时作为后备方案。

BGEReranker:本地 Cross-Encoder 模型。bge-reranker-v2-m3 是 BAAI 发布的多语言重排序模型[3],在中文技术文本上表现优异。它以 FP16 精度运行,单次推理约 10-30ms,对 24 篇候选排序总计约 0.5 秒。这是性价比最高的方案。

LLMReranker:用 LLM 作为评判者。对每篇候选文档发送评分 prompt:

prompt = (
    "Score how relevant this document is to the query. "
    "Output ONLY a number between 0 and 1 (e.g. 0.87).\n\n"
    f"Query: {query}\n\n"
    f"Document: {doc.content[:1500]}"
)

LLMReranker 的优点是可以捕捉更微妙的语义关联(如反讽、隐含立场),但代价高昂——24 篇文档需要 24 次 LLM 调用。实际使用中应开启缓存或仅对少量候选使用。

性能递减定律

回顾整个检索流水线的性能分布:

阶段 候选数量 计算时间 精度贡献
向量检索(嵌入) 10,000 → 16 ~50ms 60%
BM25 检索 全量 → 16 ~5ms 20%
RRF 融合 32 → 24 ~1ms 10%
重排序 24 → 8 ~500ms 10%

重排序花费了约 90% 的计算时间,但只贡献了约 10% 的精度提升。这不是浪费——在检索流水线中,每一阶段的精度损失都是不可逆的。如果粗筛阶段丢失了正确文档,重排序无法凭空找回。因此重排序的价值不在于它贡献了多少提升,而在于它阻止了错误文档进入生成阶段。


检索结果的上下文增强

CazzKB 在返回检索结果时,会将存储时的元数据一并返回:

@dataclass
class SearchResult:
    chunk_id: str
    content: str
    score: float
    dense_score: float = 0.0
    sparse_score: float = 0.0
    source_file: str = ""       # 来源文件
    title: str = ""             # 文章标题
    header_path: str = ""       # 章节路径
    element_type: str = ""      # 元素类型

这些元数据在生成阶段被组装为结构化的上下文 prompt:

[source-1] 标题: Mamba 综述 / 章节: /SSM基础/连续时间模型
内容: 连续时间状态空间模型的数学表示为 h'(t) = A·h(t) + B·x(t)...

结构化的元数据不仅帮助 LLM 理解知识来源,还支持 CazzKB 的 引用溯源机制——每个生成的回答都可以追溯到具体的文档和章节。用户看到的不是”据内部知识库”,而是”来自《Mamba综述》/SSM基础/连续时间模型”。


总结

本篇文章深入剖析了 Advanced RAG 的三个核心技术环节,并结合 CazzKB 的实际代码展示了工程实现。

核心要点回顾:

  • 语义分块是检索质量的基石。Markdown 的结构信息(标题层级、代码块、表格)是免费的语义标签,不应在分块时丢弃。保护代码、公式、图表等信息密集型元素不被截断,远比追求精确的 token 计数重要。

  • 混合检索是当前性价比最优的检索方案。稠密向量的语义泛化 + BM25 的精确关键词匹配,通过 RRF 排名融合取长补短。额外的候选扩展和重排序开销很小(~500ms),但对精度的提升显著。

  • 重排序是精度的最后一道防线。Cross-Encoder 的双向注意力消除了嵌入检索的盲区。但要注意控制候选数量——重排序的成本与候选数线性相关。

在下一篇文章中,我们将从技术引擎上升到系统架构层面,讨论如何将这些组件组装为一个可部署的生产级 RAG 系统。


参考文献

  1. BM25: The Best-known Term Weighting Scheme in IR. Robertson S, Zaragoza H. 2009.
    https://doi.org/10.1561/1500000019
  2. Reciprocal Rank Fusion outperforms Condorcet and individual rank learning methods. Cormack G V, et al. SIGIR, 2009.
    https://doi.org/10.1145/1571941.1572114
  3. BGE: C-Pack — Packaged Resources to Advance Chinese Embedding. Xiao S, et al. arXiv, 2023.
    https://arxiv.org/abs/2309.07597 · 代码仓库:https://github.com/FlagOpen/FlagEmbedding
↑
文章目录