本文记录了从知识蒸馏、结构感知分块、双路混合检索到端到端问答的完整技术实践,以如何将数百篇垂直领域专业文章变成一个轻量级的、能回答专业问题的 AI 知识库。
前言
说明:本文为技术方案公开版,聚焦于 Pipeline 架构设计与技术原理。出于数据保密要求,文中涉及的数据集内容(文章标题、作者、具体蒸馏产物示例等)已做脱敏处理,具体数值以量级和比例呈现。
我们手头有数百篇垂直领域的专业文章,涵盖多个子方向。文章来源于内部知识库,质量参差不齐——有的是万字长文的深度拆解,有的是行业演讲的翻译整理,还有的是个人随笔式的经验感悟。
目标很明确:构建一个 RAG(Retrieval-Augmented Generation)系统,让团队成员能用自然语言提问,系统从这些文章中检索相关知识,生成专业、有据可查的回答。
但在动手之前,我们需要回答一个架构层面的核心问题:原始文章应该直接切片入库,还是先做一轮知识蒸馏?
传统 RAG 的做法是"原文 → 分块 → 向量化 → 入库",简单直接。但垂直领域专业文章有一个显著特点:知识密度极低。一篇 15KB 的文章中,真正有价值的核心知识可能只占 15%,其余 85% 是过渡句、个人感受、修辞铺垫。如果直接滑窗分块,大量 Chunk 会被噪声淹没,检索精度必然受损。
因此,我们选择了一条更重的路线:先蒸馏,再入库。
1 | N 篇原始文章 |
这条路线的代价是多了一个离线蒸馏阶段(N 篇 × 2 次 LLM 调用),但换来的是:知识密度从 ~15% 提升到 ~90%,每个 Chunk 都是一个语义完整的知识单元。后续的实验数据将证明,这个选择是值得的。
本文将完整记录这条路线的每一个技术决策、实现细节和实验数据。当然,该项目是面向轻量级的、技术预研的数据库搭建,因此选择了轻量级的架构设计,不涉及生产阶段,后续将进行生产阶段的优化与迁移/
知识蒸馏
为什么需要蒸馏
以某篇典型文章为样本做蒸馏测试,结果如下:
| 指标 | 原文 | 蒸馏后 | 变化 |
|---|---|---|---|
| 文件大小 | 15.37 KB | 10.62 KB | 体积缩减 30.9% |
| 行数 | 283 行 | 100 行 | 行数缩减 64.7% |
| 知识密度 | ~15% | ~90% | 提升 6 倍 |
| 内容性质 | 自然语言叙述、过渡句、附录链接 | 纯结构化 YAML,每条均为可操作知识单元 | — |
体积压缩幅度有限(约 30%),因为蒸馏不是"删减"而是"重组"——将散落在全文各处的知识点提取并结构化。真正的价值在于知识密度的 6 倍提升。蒸馏产出为标准 YAML 格式,可直接被程序解析、被 Agent System Prompt 引用、或作为 RAG 的高质量数据源。
三阶段蒸馏 Pipeline
蒸馏 Pipeline 分为三个阶段,每个阶段解决一个特定问题:
1 | STAGE 1: 分类 (Classify) STAGE 2: 知识提取 (Extract) STAGE 3: 结晶组装 (Assemble) |
Stage 1:文章分类(Classify)
第一阶段用 LLM 判断每篇文章的类型。这一步只需要文章前 3000 字 + 尾部 1000 字,token 消耗极低,但它的输出决定了后续使用哪套提取 Prompt。
我们定义了四种文章类型:
| 类型 | 名称 | 特征 | 预估占比 |
|---|---|---|---|
| A | 思路/观点型 | 个人见解、主观分析、通用讨论 | ~40% |
| B | 案例拆解型 | 分析具体产品的做法和效果 | ~30% |
| C | 方法论型 | 结构化方法、步骤流程、可复用框架 | ~20% |
| D | 公式/算法型 | 具体公式、算法、数值参数 | ~10% |
每篇文章生成一个 JSON 文件,包含分类结果和元数据:
1 | { |
为什么要先分类再提取(两次 LLM 调用)? 因为不同类型文章的知识形态差异极大。思路型文章的价值在于"框架"和"论点",案例型的价值在于"做了什么"和"效果如何",公式型的价值在于"精确的数学表达"。统一 Prompt 会导致 LLM 对所有文章做同质化提取,丢失各类型特有的知识结构。分类本身的 token 消耗极低,但能显著提升后续提取的精度。
Stage 2:知识提取(Extract)
第二阶段根据文章类型,使用差异化 Prompt 模板提取结构化知识。四套模板分别针对四种文章类型的知识形态:
| 文章类型 | 提取重点 | 输出结构 |
|---|---|---|
| A(思路型) | 框架、心智模型、分类体系 | frameworks + key_arguments |
| B(案例型) | 做了什么、为何有效、为何失败 | cases + techniques |
| C(方法论型) | 步骤、流程、检查清单 | methods + checklists |
| D(公式型) | 公式、算法、数值模式 | formulas + algorithms + numerical_patterns |
以 B 型(案例拆解型)的提取结果为例,每个 case 都是一个自包含的知识单元:
1 | article_meta: |
若文章知识密度极低(纯水文),LLM 会输出 LOW_DENSITY: true 标记并跳过。
Stage 3:结晶组装(Assemble)
第三阶段将所有个体提取结果合并为一个统一的知识晶体文件(knowledge_crystal.yaml),包含六大模块:
| 模块 | 来源 | 用途 |
|---|---|---|
knowledge_map |
全部文章 | 领域→文章索引,引导层 |
design_patterns |
A+C 型 | 设计范式库 |
case_fingerprints |
B 型 | 案例指纹库(产品做了什么、效果如何) |
formulas_and_algorithms |
D 型 | 公式/算法库 |
methods_and_checklists |
C 型 | 方法与清单 |
anti_patterns |
全部 | 避坑清单(失败案例) |
蒸馏产出的目录结构
1 | distill/output/ |
每个文件以 8 位 hex 前缀(doc_id)命名,classifications 和 extractions 通过 doc_id 一一对应。这个设计为后续的合并分块奠定了基础。
结构感知分块
为什么不用滑窗分块
传统 RAG 系统最常用的分块策略是固定窗口滑窗(如 512 token 一块,128 token 重叠)。这种方法简单通用,但有一个致命缺陷:它不理解语义边界。
一个案例分析可能跨越 300-800 字符,滑窗会在案例中间切一刀,导致上半段有"做法"没"结论",下半段有"结论"没"背景"。检索到这样的 Chunk,LLM 也无法生成有意义的回答。
而我们的蒸馏数据天然具有结构化边界——每个 case、每个 technique、每个 method 都是一个语义完整的知识单元。利用这些天然边界做分块,比任何分块算法都要好。
数据探查
在实现分块逻辑之前,我们对全部 YAML 文件做了全量数据探查。结果发现了一个技术路线中未预见的情况:蒸馏产物存在 5 种不同的 YAML 结构模式,而非预设的单一 cases + techniques 结构。
| Schema | 顶层字段(除 article_meta) |
文件数 | 占比 |
|---|---|---|---|
| A | cases + techniques |
~50% | 主流 |
| B | methods + checklists |
~44% | 次主流 |
| C | frameworks + key_arguments |
~5% | 少量 |
| D | formulas + algorithms + numerical_patterns |
<1% | 极少 |
| E | frameworks + key_arguments + practices |
<1% | 极少 |
这意味着原技术路线仅预设了 Schema A 的处理逻辑,如果不做扩展,将丢失近 50% 的蒸馏数据。
10 种 Chunk Builder 的调度架构
为覆盖全部 5 种 Schema 变体,我们实现了 10 种 Chunk Builder,加上必有的 article_summary,共 11 种 Chunk 类型:
1 | 文档 (doc_id) |
调度逻辑通过一个 SECTION_BUILDERS 字典实现——YAML 的顶层字段名直接映射到对应的 Builder 函数:
1 | SECTION_BUILDERS = { |
这个设计的优雅之处在于:新增一种 Schema 只需要写一个 Builder 函数并在字典中注册一行,完全开闭原则。
Chunk 文本构造
每个 Chunk 的 text 字段遵循统一的结构化模板:
1 | 【类型标签】标题/名称 |
以一个 method 类型的 Chunk 为例:
1 | 【方法论】<方法名称> |
这个模板有三个设计意图:
- 类型标签(如
【案例】、【方法论】、【公式】)为 Sparse 检索提供强信号词。当用户搜索"方法论"时,Sparse 路能精确匹配到这些标签 来源文章字段建立 Chunk → 文章的溯源链路,LLM 生成回答时可以直接引用- 结构化排列有利于 LLM 在生成阶段解析上下文——模型能清晰地区分"目的"、“步骤”、"适用场景"等字段
Metadata Payload
每个 Chunk 携带丰富的元数据 Payload,支持后续的过滤检索与结果增强:
1 | { |
元数据字段根据 Chunk 类型动态扩展:game/topic 仅 case 类型有,technique_name/applicable_to 仅 technique 类型有,formula_name 仅 formula 类型有。这些字段后续会在 Qdrant 中建立 Payload 索引,支持检索时的精确过滤。
无效数据处理
全部 YAML 文件中,约 22.5% 是无效的:
| 类型 | 数量 | 原因 |
|---|---|---|
| 极短文件(< 100 bytes) | 26 | 蒸馏失败,仅含 LOW_DENSITY: true 或空内容 |
| YAML 解析失败 | 19 | 蒸馏产物中存在未转义引号、缩进错误等语法问题 |
处理策略是:无效 YAML 对应的文档仍通过 JSON 元数据中的 one_line 摘要生成 article_summary 级 Chunk。这确保了全部文章零丢失——即使蒸馏失败的文章,也至少有一个摘要级别的 Chunk 可供检索。
100 bytes 的阈值是通过实测确定的:26 个空文件 ≤ 17 bytes,19 个解析失败文件 ≥ 126 bytes,100 bytes 是安全的分界线。
分块结果
执行 prepare_chunks.py 后,得到以下结果:
| 指标 | 数值 |
|---|---|
| 输入文档总数 | N 篇 |
| 有效蒸馏文档 | ~77.5% |
| 仅摘要文档 | ~22.5% |
| 输出 Chunk 总数 | ~8× 文档数 |
| 覆盖 doc_id 数 | N(零丢失) |
Chunk 类型分布如下:
| Chunk 类型 | 占比 | 说明 |
|---|---|---|
technique |
~27% | 设计技法(最多) |
method |
~21% | 方法论步骤 |
case |
~17% | 案例分析 |
article_summary |
~12% | 文章级摘要(每篇必有) |
checklist |
~11% | 检查清单 |
key_argument |
~5% | 核心论点 |
framework |
~4% | 设计框架 |
numerical_pattern |
~1% | 数值规律 |
formula |
~1% | 公式 |
algorithm |
<1% | 算法 |
practice |
<1% | 实践案例 |
Chunk 文本长度统计:平均约 200+ 字符,中位数约 170+ 字符,最长约 1,200+ 字符。所有 Chunk 均远低于 BGE-M3 的 8192 token 上限,无需截断处理。
各子领域分布均衡(各约 22%-27%),无明显偏斜。
与技术路线预估对比,最大的偏差在于 Chunk 类型数:预估 3 种,实际 11 种。这是因为发现了 5 种 Schema 变体,扩展为 10+1 种 Builder。总 Chunk 数也超出预估约 18%,多出的 Chunk 来自 Schema B/C/D/E 的贡献。
Embedding 编码与向量数据库构建
BGE-M3
BGE-M3(BAAI General Embedding - Multi-Functionality, Multi-Linguality, Multi-Granularity)是由北京智源人工智能研究院(BAAI)于 2024 年发布的多功能文本嵌入模型(论文:M3-Embedding: Multi-Linguality, Multi-Functionality, Multi-Granularity Text Embeddings Through Self-Knowledge Distillation,Chen et al., 2024)。其核心创新在于单一模型同时支持三种检索范式:Dense Retrieval(稠密向量检索)、Sparse Retrieval(稀疏向量检索)和 ColBERT 式的 Multi-Vector Retrieval(多向量检索)。模型基于 XLM-RoBERTa 架构,支持 100+ 种语言,最大输入长度 8192 tokens,在 MTEB、C-MTEB 等主流评测基准上均达到 SOTA 水平。
选择 BGE-M3 作为 Embedding 模型的核心理由是:它能同时输出 Dense 和 Sparse 两种向量表示,省去了维护两个模型的复杂度。
- Dense 向量(稠密向量):文本经 Transformer 编码后,取
[CLS]token 的最终隐藏状态并 L2 归一化,得到一个固定维度(1024d)的连续浮点向量。该向量捕获文本的全局语义信息,适用于语义相似度计算(Semantic Similarity)。在信息检索领域,这种方法被称为 Dense Passage Retrieval(DPR, Karpukhin et al., 2020),是当前神经信息检索(Neural IR)的主流范式。 - Sparse 向量(稀疏向量):文本经同一个 Transformer 编码后,通过一个额外的线性层将每个 token 的隐藏状态映射为标量权重,构成一个高维稀疏向量(维度 = 词表大小 ~250K),仅出现在文本中的 token 对应维度有非零值。这种方法被称为 Learned Sparse Representation(学习型稀疏表示,Formal et al., 2021),相比传统的 BM25 统计方法,它能利用 Transformer 的上下文理解能力为每个词项赋予语境感知的权重。
| 参数 | 值 |
|---|---|
| 模型 | BAAI/bge-m3 |
| 模型大小 | 2.16 GB(30 个文件) |
| Dense 向量维度 | 1024 |
| Sparse 表示 | 词汇权重字典(token_id → weight) |
| 最大输入长度 | 8192 tokens |
编码流程:
1 | chunks.jsonl (N texts) |
Dense 向量捕获语义相似性,Sparse 向量捕获精确关键词匹配(专有术语精确匹配)。两者互补,是后续双路检索的基础。
Qdrant Local:轻量嵌入式知识库
Qdrant(读作 /kwɒdrənt/)是一款开源的高性能向量相似度搜索引擎,使用 Rust 语言编写,专为大规模向量检索场景设计(GitHub: qdrant/qdrant)。与同类产品(Milvus、Weaviate、Pinecone 等)相比,Qdrant 的核心技术特点包括:
- HNSW 图索引(Hierarchical Navigable Small World, Malkov & Yashunin, 2018):一种基于多层跳表思想的近似最近邻(ANN)搜索算法,将检索复杂度从暴力搜索的 O(N) 降至 O(log N),在百万级向量规模下仍能保持毫秒级响应;
- 原生多向量支持:单个 Point(数据记录)可同时存储 Dense 向量和 Sparse 向量,并支持在同一次查询中对两种向量分别检索后融合(Prefetch + Fusion),这正是我们实现双路混合检索的基础;
- Payload 过滤:支持对结构化元数据(Payload)建立索引并在向量检索时做条件过滤,实现“向量相似度 + 元数据筛选”的混合查询;
- 嵌入式模式(Qdrant Local):除了常规的 Client-Server 部署模式外,Qdrant 提供了基于 SQLite 的嵌入式模式,无需启动独立服务进程,数据存储在本地文件中,适合原型验证和小规模场景。
对于千级别数据的规模,我们选择 Qdrant Local(嵌入式 SQLite 模式),无需启动独立服务端,单文件便于备份和迁移。
集合配置:
1 | client.create_collection( |
每个 Chunk 对应一个 Qdrant Point,包含 Dense 向量 + Sparse 向量 + Payload(元数据 + 原文)。分批 Upsert(128 个/批)。
为 5 个高频过滤字段创建 Payload 索引:
| 索引字段 | 类型 | 用途 |
|---|---|---|
category |
KEYWORD | 按文章分类过滤 |
chunk_type |
KEYWORD | 按 Chunk 类型过滤 |
domains |
KEYWORD | 按领域标签过滤 |
game |
KEYWORD | 按产品名过滤 |
article_type |
KEYWORD | 按文章类型过滤 |
入库验证
使用第一个 Chunk 的向量作为测试 Query,执行 Dense + Sparse 双路检索 + RRF 融合:
1 | Query: <某篇文章的 article_summary Chunk> |
自身精确匹配 score=1.0,同文档不同 Chunk 按相关性递减排列,RRF 融合逻辑正确。验证通过。
存储数据
| 指标 | 数值 |
|---|---|
| Qdrant Points 数 | N(零丢失) |
| Dense 存储 | 约 4 KB/Chunk |
| Sparse 平均词项/Chunk | ~73 |
| 数据库磁盘大小 | 约 12 KB/Chunk |
Sparse 向量平均每个 Chunk 包含约 73 个有效词项,说明蒸馏后的结构化文本具有丰富的关键词信号——这正是蒸馏带来的额外收益:结构化标签(【案例】、【方法论】)和字段名(做法:、成功原因:)本身就是高质量的关键词。
检索与重排:Dense + Sparse + Rerank
在工业级 RAG 系统中,检索环节通常遵循一个经典的漏斗式架构:在线召回(Retrieval)→ 粗排(Coarse Ranking)→ 精排(Fine Ranking)。这一多阶段排序架构(Multi-Stage Ranking Pipeline)源自信息检索(Information Retrieval, IR)领域数十年的工程实践,最早可追溯到搜索引擎的 L2R(Learning to Rank)系统(Burges et al., 2005; Liu, 2009)。其核心思想是:
- 在线召回(Retrieval / Candidate Generation):从全量索引中以极低的计算成本快速筛出候选集。典型方法包括倒排索引(Inverted Index)、ANN 向量检索(Approximate Nearest Neighbor)等,复杂度通常为 O(log N) 或亚线性,要求毫秒级响应。
- 粗排(Coarse Ranking / Pre-Ranking):对召回的候选集进行初步排序,融合多路召回信号并统一排名。常见方法包括 Reciprocal Rank Fusion(RRF, Cormack et al., 2009)、加权分数融合、轻量级模型打分等,目标是在亚秒级内将候选集从数百缩小到数十。
- 精排(Fine Ranking / Re-Ranking):使用计算成本更高但精度更强的模型对少量候选做逐对精细打分。在神经 IR 中,主流方法是 Cross-Encoder(Nogueira & Cho, 2019),它将 Query 和 Document 拼接后联合输入 Transformer,通过交叉注意力实现深层语义交互,精度远高于 Bi-Encoder 但计算成本也更高(复杂度与候选数线性相关)。
这个三级架构的本质是一个精度-效率权衡,每一级都在缩小候选集的同时提升排序质量,最终将数千个候选文档收敛到个位数的高质量结果。
我们的系统完整实现了这三个阶段。在展开技术细节之前,先看一下与工业级系统的对比,以建立全局视角。
与工业级 RAG 系统的架构对比
作为参照,我们选取了一个典型的企业级 RAG 系统——ISearchChat(一个支持 iWiki、KM 等内部知识库的智能问答系统)。它的在线检索链路如下:
1 | 用户请求 → Query 预处理(分词 / 纠错 / 去停用词) |
将其与我们的系统做对比:
| 维度 | ISearchChat(工业级) | 本系统(轻量级) |
|---|---|---|
| 召回路数 | 3 路(向量 + ES 关键词 + ES 全文) | 2 路(Dense + Sparse) |
| 召回模型 | m3e-large(768d) | BGE-M3(1024d,同时输出 Dense + Sparse) |
| 向量召回量 | Top 200 | Top 40 × 2 路 |
| 粗排策略 | 多轮规则重排(时效惩罚 + 句子匹配惩罚 + Jaccard) | RRF 排名融合 |
| 精排策略 | 关键词计数 + Jaccard 相似度 | Cross-Encoder 神经网络精排 |
| 精排模型 | 无独立精排模型 | bge-reranker-v2-m3(~1.1 GB) |
| 数据规模 | 数千篇文档,持续增量更新 | 数百篇文档,离线一次性入库 |
| Query 预处理 | jieba 分词 + 去停用词 + 纠错 + 意图识别 | 无(直接编码) |
| 向量类型 | 单一 Dense | Dense + Sparse 双表示 |
| 部署模式 | 分布式微服务(GPU 推理 + ES 集群 + MongoDB) | 单机嵌入式(Qdrant Local + CPU) |
两个系统的设计哲学截然不同:ISearchChat 面向大规模、高并发、持续更新的生产环境,因此采用多组件分布式架构,用 ES 做关键词召回(成熟稳定),用规则化的多轮重排(可控可调);而我们面向小规模、高质量、离线构建的知识库场景,因此选择了更重的模型(BGE-M3 一个模型同时输出两种表示,省去维护 ES 的复杂度)和更重的精排(Cross-Encoder 神经网络,精度远高于规则重排)。
一个值得注意的差异是 Query 预处理。ISearchChat 在检索前做了大量的 Query 预处理工作——jieba 分词、去停用词、提取产品名称、意图识别等。这些预处理在传统 IR 系统中至关重要,因为关键词召回的质量高度依赖于 Query 的清洁度。而我们的系统跳过了这一步,原因是:BGE-M3 的 Transformer 编码器本身就具备强大的文本理解能力,能自动处理停用词、同义词等问题;同时,我们的 Sparse 路是学习型稀疏表示(而非传统 BM25),权重由神经网络计算,对 Query 噪声的鲁棒性更强。
另一个关键差异是 向量入库的粒度。ISearchChat 对每个 Chunk 提取三种向量文本——chunk_title(标题)、highlight(高亮句子/子标题)、sentence(常规文本分句),每种类型独立入库,检索时分别取 Top-N 再合并打分。这种细粒度的向量化策略在大规模数据上能显著提升召回率(ISearchChat 的评测数据显示 title 类型贡献了 77% 的命中)。而我们的系统将整个 Chunk 作为一个整体入库——这是因为蒸馏后的 Chunk 本身就是语义完整的知识单元(平均 216 字符),粒度已经足够细,无需再做子句级拆分。
Pipeline 总览
理解了工业级系统的全貌后,回到我们自己的 Pipeline。整体架构如下:
score = Σ 1/(k + rank_i) → Top 20三个阶段各自解决一个问题:
| 阶段 | 输入规模 | 输出规模 | 核心任务 | 速度要求 |
|---|---|---|---|---|
| 在线召回 | 全量 Chunks | ~80 候选 | 从全量数据中快速筛出候选集 | 极快(毫秒级) |
| 粗排 | ~80 候选 | 20 候选 | 融合多路信号,统一排序 | 快(亚秒级) |
| 精排 | 20 候选 | 5 最终结果 | 逐对精细打分,确保 Top-K 质量 | 可慢(秒级) |
这是一个典型的倒金字塔结构:越往下,候选越少,模型越重,精度越高。
第一级:在线召回——Dense + Sparse 双路检索
在线召回是整个 Pipeline 的入口,它的核心任务是从全量 Chunk 中快速筛出最可能相关的候选集。我们采用 Dense + Sparse 双路并行检索,两路各取 Top 40,合计约 80 个候选(去重后通常 50-70 个)。
Dense 路:语义向量检索
核心思想:将文本映射到一个连续的高维向量空间中,语义相近的文本在空间中距离更近。检索时,计算 Query 向量与所有文档向量的余弦相似度,取最近的 Top-K。
编码过程:
1 | "产品核心系统平衡性设计" |
Transformer 的自注意力机制是 Dense 路语义理解能力的根基。在编码 “产品核心系统平衡性设计” 时,“平衡性” 这个 token 不仅编码了自身的词义,还通过注意力权重融合了 “产品”、“核心系统”、“设计” 等上下文信息。最终的 [CLS] 向量是整句话的全局语义压缩表示,它不再是各个词义的简单拼接,而是一个理解了"这句话在说什么"的整体表征。
检索过程:
Query 向量 和文档向量 均为 1024 维实数向量。两者的相似度通过余弦相似度计算:。由于 L2 归一化后 = = 1,余弦相似度退化为内积:。
L2 归一化是一个关键的工程优化:归一化后余弦相似度等价于内积,而内积运算可以被 SIMD 指令集(如 AVX-512)高度并行化。Qdrant 在底层使用 HNSW(Hierarchical Navigable Small World)图索引加速近似最近邻搜索,将检索复杂度从 O(N) 降至 O(log N)。
Dense 路的优势与局限:
| 优势 | 局限 |
|---|---|
| 语义理解:能理解同义词、近义词、上下位关系 | 专有名词不敏感:不同专有术语在语义空间中可能距离很近 |
| 跨表述匹配:“怎么让战斗更爽” 能匹配到 “战斗体验优化” | 精确匹配弱:搜某个缩写可能召回语义相近但不同的缩写 |
| 模糊意图:用户表述不精确时仍能召回相关内容 | 低频词丢失:对训练语料中罕见的术语编码质量较差 |
Sparse 路:学习型稀疏向量检索
核心思想:将文本表示为一个稀疏向量,维度对应词表中的每个 token(约 250K 维),值为该 token 的重要性权重。只有文本中出现的(或模型认为相关的)token 对应的维度有非零值,其余全为 0。
BGE-M3 的 Sparse 表示不是传统的 BM25,而是一种学习型稀疏表示(Learned Sparse Representation)。这个区别至关重要:
编码过程:
1 | "产品核心系统平衡性设计" |
这里有一个精妙的设计:Dense 路和 Sparse 路共享同一个 Transformer 编码器。这意味着 Sparse 路的权重计算也受益于 Transformer 的全局注意力。“平衡” 这个词在 “产品核心系统平衡性设计” 中的权重(0.91)会高于在 “工作生活平衡” 中的权重,因为 Transformer 理解了上下文。
与传统 BM25 的关键区别:
| 维度 | BM25 | BGE-M3 Sparse |
|---|---|---|
| 权重来源 | 统计公式 TF-IDF:w = tf × log(N/df) |
神经网络学习:w = ReLU(W · h + b) |
| 词汇扩展 | 只匹配原文出现的词 | 可以激活原文未出现但语义相关的 token |
| 上下文感知 | 同一个词在任何上下文中权重相同 | 同一个词在不同上下文中权重不同 |
| 训练方式 | 无需训练,纯统计 | 在大规模检索数据集上对比学习训练 |
| 可解释性 | 高(TF-IDF 公式透明) | 中(可以看到哪些词被激活,但权重来自黑盒) |
检索过程:
1 | Query 稀疏向量: {"产品": 0.78, "核心": 0.65, "平衡": 0.91, "设计": 0.73} |
只有两个向量共同非零的维度才参与计算,这使得稀疏向量检索天然具有精确匹配的特性,如果 Query 中有某个专有术语而文档中没有,这个维度的贡献为 0。
Sparse 路的优势与局限:
| 优势 | 局限 |
|---|---|
| 精确关键词匹配:专有术语精确匹配,不会语义漂移 | 语义理解有限:虽然比 BM25 好,但仍以词汇匹配为主 |
| 专有名词强:产品名、术语、缩写的匹配非常精准 | 同义词覆盖不完整,可能匹配不上 |
| 可解释性:可以直接看到哪些词匹配上了、权重多少 | 依赖词表覆盖:词表中没有的 token 无法被表示 |
| 比 BM25 更智能:能做一定程度的词汇扩展 |
双路互补的本质
两路召回的设计不是冗余,而是互补。它们的错误模式恰好相反:
1 | Dense 路的典型错误:语义漂移 |
当两路结果取交集时,Dense 路的语义漂移噪声会被 Sparse 路过滤(因为噪声文档通常不包含 Query 的关键词),Sparse 路的漏召回会被 Dense 路补上(因为语义相近的文档在 Dense 空间中距离很近)。
消融实验中 T6(精确术语型)和 T7(模糊语义型)的结果完美验证了这一点,T6 中 Sparse 路贡献了精确的术语匹配,T7 中 Dense 路理解了口语化表述的语义意图。
第二级:粗排——RRF 排名融合
两路召回各返回 Top 40 个结果,合计约 80 个候选。粗排的任务是将两路异构的分数统一为一个排名,输出 Top 20 给精排。
为什么不能直接加权融合
一个直觉的做法是:final_score = α × dense_score + β × sparse_score。但这行不通,因为两路的分数量纲完全不同:
| 路径 | 分数类型 | 值域 | 分布特征 |
|---|---|---|---|
| Dense 路 | 余弦相似度 | [-1, 1],实际多在 [0.3, 0.9] | 近似正态分布 |
| Sparse 路 | 稀疏内积 | [0, +∞),实际多在 [0.5, 5.0] | 长尾分布 |
直接加权融合会导致 Sparse 路的高分文档(内积可达 5.0)碾压 Dense 路的结果(余弦最高 1.0)。即使做 min-max 归一化,不同 Query 的分数分布差异也很大,很难找到一组通用的归一化参数。
ISearchChat 的做法是设计复杂的规则化重排,包括时效惩罚、句子匹配惩罚、Jaccard 相似度等多个信号加权。这种方法在大规模系统中有效(因为可以针对具体场景调参),但对我们的小规模系统来说过于复杂。
RRF:只看排名,不看分数
RRF(Reciprocal Rank Fusion)提供了一个优雅的解决方案:完全忽略原始分数,只使用排名信息。
核心公式:
,其中求和遍历 。
其中 k 是平滑常数(通常为 60),rank_i(d) 是文档 d 在第 i 路中的排名(从 1 开始)。
如果一个文档在 Dense 路排第 1、Sparse 路排第 3,它的 RRF 分数为 1/(60+1) + 1/(60+3) = 0.01639 + 0.01587 = 0.03226。如果另一个文档在 Dense 路排第 2、Sparse 路排第 2,它的 RRF 分数为 1/(60+2) + 1/(60+2) = 0.01613 + 0.01613 = 0.03226。两者几乎相同,说明RRF 天然倾向于在两路中都排名靠前的文档。
k=60 的含义:k 值越大,排名差异的影响越小(趋向于均匀加权);k 值越小,头部排名的优势越大。k=60 是 Cormack et al. (2009) 在 TREC 实验中通过大量实验确定的经验最优值,已成为业界默认标准。
RRF 的三个优势:
- 无需训练:不需要标注数据,不需要调参(k=60 几乎是万能的)
- 异构友好:不关心原始分数的量纲和分布,只关心排名
- 鲁棒性强:即使某一路的分数分布异常(如 Sparse 路在某些 Query 上全部低分),RRF 仍能正常工作
Qdrant 内置了 RRF 融合,通过 Prefetch + FusionQuery 实现:
1 | results = client.query_points( |
这段代码的执行逻辑是:Qdrant 先并行执行两个 Prefetch(Dense Top 40 + Sparse Top 40),然后在服务端完成 RRF 融合,返回融合后的 Top 20。整个过程在一次网络请求中完成,延迟约 100ms。
第三级:精排——Cross-Encoder 交叉注意力
粗排输出的 Top 20 候选已经具备较高的相关性,但排序质量仍有提升空间。精排使用 bge-reranker-v2-m3(~1.1 GB)对每个候选做逐对精细打分,输出最终的 Top 5。
Bi-Encoder 和 Cross-Encoder 的本质区别
前面的 Dense 路使用的是 Bi-Encoder(双塔编码器),而精排使用的是 Cross-Encoder(交叉编码器)。两者的本质区别在于信息交互的时机:
Bi-Encoder(召回阶段):
1 | Query → Transformer → q ─┐ |
Query 和 Doc 分别独立编码,最后才计算相似度。两者之间没有注意力交互。
Cross-Encoder(精排阶段):
1 | [CLS] Query [SEP] Doc [SEP] |
Query 和 Doc 拼接后一起编码,每个 token 都能看到对方的所有 token。
为什么 Cross-Encoder 更准确? 因为在 Bi-Encoder 中,Query 的编码完全不知道 Doc 的内容(反之亦然),两者的交互仅限于最后的余弦相似度计算。而 Cross-Encoder 让 Query 和 Doc 的每个 token 在 Transformer 的每一层都能做交叉注意力,信息交互是全方位的。
举个具体例子:当 Query 是 “某产品的核心系统是怎么设计的”,Doc 中有一句 “某个子模块的优先级仅次于最高级状态”。在 Bi-Encoder 中,“核心系统设计” 和 “子模块优先级” 的语义距离可能不够近(因为它们是独立编码的)。但在 Cross-Encoder 中,“核心系统” 的 token 能直接注意到 “子模块优先级” 的 token,理解这是核心系统设计的一个具体方面,从而给出更高的相关性分数。
为什么不直接用 Cross-Encoder 做召回? 因为计算量太大。Bi-Encoder 可以预计算所有文档的向量(离线完成),在线检索时只需要编码 Query 一次,然后做向量内积(O(N) 但可以用 ANN 索引加速到 O(log N))。而 Cross-Encoder 必须对每个 (Query, Doc) 对实时编码,全量 Chunk 就需要过 N 次 Transformer,不可接受。
这就是为什么需要三级漏斗:用廉价的 Bi-Encoder 快速筛出候选,再用昂贵的 Cross-Encoder 精细排序。
精排的计算过程
对 Top 20 的每个候选,Cross-Encoder 的计算过程如下:
1 | # 伪代码 |
20 个候选 × 每个过一遍完整的 Transformer = 20 次前向传播。
8 个测试用例
我们设计了 8 个覆盖不同场景的测试用例:
| ID | 查询类型 | 测试重点 |
|---|---|---|
| T1 | 精确实体 + 语义理解 | 包含具体产品名的技术问题 |
| T2 | 跨文章方法论聚合 | 需要多篇文章综合回答 |
| T3 | 概念理解 + 多文章召回 | 抽象概念的定义与实现 |
| T4 | 领域专精检索 | 特定子领域的深度问题 |
| T5 | 跨领域方法论 | 需要跨子领域聚合 |
| T6 | 精确术语匹配 | 包含专有名词/缩写 |
| T7 | 模糊语义查询 | 口语化、非精确表述 |
| T8 | 元数据过滤检索 | 结合 category 过滤 |
检索质量数据
| ID | 关键词命中率 | Top5 独立文章数 | Rerank 最高分 |
|---|---|---|---|
| T1 | 100% | 1 | 0.99 |
| T2 | 100% | 1 | 0.98 |
| T3 | 100% | 4 | 0.58 |
| T4 | 100% | 1 | 1.00 |
| T5 | 100% | 2 | 0.99 |
| T6 | 100% | 1 | 0.40 |
| T7 | 100% | 2 | 0.53 |
| T8 | 100% | 3 | 0.99 |
关键词命中率 100%:所有 8 个测试的期望关键词均在 Top 5 结果中出现。这说明 Dense + Sparse 双路互补效果显著:Dense 路负责语义理解(口语化表述 → 专业内容),Sparse 路负责精确匹配(专有术语 → 包含该术语的文档)。
几个值得展开的结果:
T3(概念理解型) 是最令人满意的测试。Top 5 跨 4 篇不同文章召回,类型覆盖 100%(case + technique + method + article_summary),展现了优秀的跨文档聚合能力。
T4(领域专精型) 的 Rerank 分数接近 1.0,几乎是完美匹配。这说明蒸馏后的结构化文本与用户查询之间的语义对齐度极高。
T6(精确术语型) 验证了 Sparse 路的独特价值,专有术语和缩写是 Dense 路容易混淆的对象,但 Sparse 路能精确匹配到包含该关键词的 Chunk。
消融实验
在接入 LLM 之前,我们用业界标准 IR 指标做了一组消融实验,量化每个组件的贡献。
实验设计
对比 4 种检索策略:
| 策略 | 代号 | 描述 |
|---|---|---|
| Dense Only | A | 仅 BGE-M3 Dense 向量余弦检索 |
| Sparse Only | B | 仅 BGE-M3 Sparse 词汇权重检索 |
| Hybrid (RRF) | C | Dense + Sparse 双路 + RRF 融合 |
| Hybrid + Rerank | D | 完整 Pipeline(C + Cross-Encoder 精排) |
构建了 16 个测试查询,每个查询标注了相关文章(Ground Truth),覆盖 Easy、Medium、Hard 三种难度。
消融实验结果
| 指标 | A: Dense | B: Sparse | C: Hybrid | D: Hybrid+Rerank | 最优 |
|---|---|---|---|---|---|
| Hit Rate@1 | 0.875 | 0.813 | 0.750 | 0.813 | A |
| Hit Rate@5 | 0.938 | 0.938 | 0.938 | 0.938 | 全部 |
| MRR | 0.903 | 0.872 | 0.849 | 0.863 | A |
| NDCG@5 | 0.993 | 1.036 | 1.056 | 1.099 | D |
| NDCG@10 | 1.471 | 1.485 | 1.505 | 1.546 | D |
| Precision@5 | 0.650 | 0.688 | 0.700 | 0.775 | D |
| Recall@10 | 0.590 | 0.556 | 0.600 | 0.538 | C |
Hybrid + Rerank (D) 在排序质量上全面领先。
| 指标 | D vs A (Dense Only) | 提升幅度 |
|---|---|---|
| NDCG@5 | 0.993 → 1.099 | +10.6% |
| Precision@5 | 0.650 → 0.775 | +19.2% |
| NDCG@3 | 0.882 → 0.925 | +4.9% |
Cross-Encoder Reranker 显著提升了排序质量和精确度。NDCG(Normalized Discounted Cumulative Gain)考虑了结果的位置权重,排在第 1 位的相关结果比排在第 5 位的更有价值。Reranker 的交叉注意力机制能更精确地判断 query-document 的相关性,将最相关的结果推到更靠前的位置。
Dense Only (A) 在首条命中率上最强。
Dense 路的语义理解能力使其在"第一条就命中"方面表现最好(Hit Rate@1 = 87.5%,MRR = 0.903)。RRF 融合和 Rerank 虽然提升了整体排序质量,但有时会将 Dense 路的最佳结果排到第 2-3 位。这是一个有趣的 trade-off:如果只需要首条结果,Dense Only 反而更好;如果需要 Top 5 的整体质量,Hybrid + Rerank 更优。
Sparse 路在精确术语匹配上有独特优势。
在精确术语型查询中,NDCG@5:Sparse = 0.79,Dense = 0.17,差距 4.6 倍。在特定产品型查询中:Sparse = 1.00,Dense = 0.39。这些案例说明,当查询包含精确的领域术语时,Sparse 路的关键词匹配能力是 Dense 路无法替代的。
全部策略在 Q09 上失败。
某个关于"随机性和概率设计"的查询,所有策略 NDCG@5 = 0。原因是期望命中的文章标题中不包含"随机"或"概率"关键词,且蒸馏后的 Chunk 文本中这些词出现频率也很低。这暴露了蒸馏的一个局限:蒸馏后的文本是"提炼过的语言",与用户的自然语言查询之间可能存在词汇 gap。后续可通过查询扩展来弥补。
逐查询热力图
为了更直观地展示各策略的优劣势,以下是 16 个查询在 NDCG@5 上的逐查询对比(摘选关键差异项):
| Query 类型 | Dense | Sparse | Hybrid | Rerank | 分析 |
|---|---|---|---|---|---|
| 概念理解型 | 0.70 | 0.53 | 0.53 | 0.87 | Rerank +24%,深度语义理解 |
| 模糊语义型 | 1.00 | 0.55 | 0.87 | 1.00 | Dense 语义理解 + Rerank 精排 |
| 精确术语型 | 0.17 | 0.79 | 0.35 | 0.28 | Sparse 精确匹配碾压 |
| 方法论型 | 1.50 | 2.02 | 2.32 | 1.56 | Hybrid 融合最优 |
| 跨文档对比型 | 0.80 | 0.39 | 0.64 | 1.00 | Rerank 精排提升显著 |
这张表清晰地展示了现阶段使用的设计模式没有银弹,不同查询场景下,最优策略不同。但 Hybrid + Rerank 在大多数场景下都能给出最优或接近最优的结果,是综合最优的选择。
消融实验结论
最终推荐架构(已验证):
1 | Query |
- NDCG@5 = 1.099(最优)
- Precision@5 = 0.775(最优)
端到端 RAG:LLM 模型生成层
Prompt 设计
Prompt 设计是 RAG 系统中最容易被低估的环节。一个好的 Prompt 需要做到三件事:定义角色、注入上下文、约束输出格式。
System Prompt 定义角色为资深领域顾问,核心约束包括:
- 优先引用检索内容,标注来源文章标题
- 结构化回答(标题、要点、案例)
- 案例对比分析
- 可落地设计建议
- 诚实边界声明(检索内容不足时明确说明)
User Prompt 的关键设计是元数据增强,每个检索结果不仅包含原文,还附加了结构化的元数据标签:
1 | ### 参考资料 1 |
这些元数据标签帮助 LLM 理解每个检索结果的"身份",包括它来自哪篇文章、属于什么分类、涉及哪个产品。LLM 可以据此做出更精准的引用和对比分析。
LLM 模型接入
通过 LLM API 接入大语言模型。SDK 提供异步消息流式接收,API Key 通过本地配置文件管理。
一个工程细节:SDK 返回的消息格式比较复杂(嵌套的 content blocks),需要专门的解析逻辑来提取纯文本回答。我们实现了一个递归解析器,能处理 TextBlock、ToolUseBlock 等多种 content 类型。
端到端测试结果
8 个测试查询,100% 成功率,平均质量分 0.918 / 1.0。
| ID | 查询类型 | 难度 | 质量分 | 回答长度 | 总延迟 |
|---|---|---|---|---|---|
| RAG01 | 精确实体型 | Easy | 0.736 | ~2,400字 | ~36s |
| RAG02 | 方法论聚合型 | Medium | 0.945 | ~2,900字 | ~38s |
| RAG03 | 概念理解型 | Medium | 0.950 | ~2,800字 | ~36s |
| RAG04 | 领域专精型 | Easy | 0.926 | ~2,600字 | ~24s |
| RAG05 | 跨领域方法论 | Medium | 0.971 | ~2,500字 | ~29s |
| RAG06 | 技术细节型 | Medium | 0.952 | ~2,800字 | ~28s |
| RAG07 | 跨文档对比型 | Hard | 0.952 | ~2,200字 | ~30s |
| RAG08 | 通用方法论 | Medium | 0.910 | ~2,900字 | ~28s |
评估维度分析
| 评估维度 | 平均得分 | 说明 |
|---|---|---|
| 结构化 | 1.000 | 所有回答均使用标题、表格、列表等结构化格式 |
| 标准命中 | 0.930 | 各维度评估标准的平均命中率 |
| 来源引用 | 0.913 | 7/8 个回答引用了来源文章标题 |
| 长度质量 | 0.871 | 回答长度适中(2000-3000字),信息密度高 |
结构化程度 100% 是最亮眼的指标,所有回答均使用 Markdown 标题、表格、代码块、列表等格式,信息层次清晰。这得益于 System Prompt 中对输出格式的明确约束。
数据流转全景
1 | N 篇原始文章 (各 5-30 KB) |