0%

垂直领域轻量级本地 RAG 知识库:从蒸馏到检索的全链路技术实践

本文记录了从知识蒸馏、结构感知分块、双路混合检索到端到端问答的完整技术实践,以如何将数百篇垂直领域专业文章变成一个轻量级的、能回答专业问题的 AI 知识库。

前言

说明:本文为技术方案公开版,聚焦于 Pipeline 架构设计与技术原理。出于数据保密要求,文中涉及的数据集内容(文章标题、作者、具体蒸馏产物示例等)已做脱敏处理,具体数值以量级和比例呈现。

我们手头有数百篇垂直领域的专业文章,涵盖多个子方向。文章来源于内部知识库,质量参差不齐——有的是万字长文的深度拆解,有的是行业演讲的翻译整理,还有的是个人随笔式的经验感悟。

目标很明确:构建一个 RAG(Retrieval-Augmented Generation)系统,让团队成员能用自然语言提问,系统从这些文章中检索相关知识,生成专业、有据可查的回答。

但在动手之前,我们需要回答一个架构层面的核心问题:原始文章应该直接切片入库,还是先做一轮知识蒸馏?

传统 RAG 的做法是"原文 → 分块 → 向量化 → 入库",简单直接。但垂直领域专业文章有一个显著特点:知识密度极低。一篇 15KB 的文章中,真正有价值的核心知识可能只占 15%,其余 85% 是过渡句、个人感受、修辞铺垫。如果直接滑窗分块,大量 Chunk 会被噪声淹没,检索精度必然受损。

因此,我们选择了一条更重的路线:先蒸馏,再入库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
N 篇原始文章

▼ Stage 1: LLM 分类

▼ Stage 2: 差异化知识提取

▼ Stage 3: 结晶组装

▼ 结构感知分块

▼ BGE-M3 双表示编码

▼ Qdrant 向量数据库

▼ Dense + Sparse 双路检索 + RRF 融合

▼ Cross-Encoder 精排

▼ LLM 生成回答

这条路线的代价是多了一个离线蒸馏阶段(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
2
3
4
5
STAGE 1: 分类 (Classify)          STAGE 2: 知识提取 (Extract)       STAGE 3: 结晶组装 (Assemble)
│ │ │
▼ ▼ ▼
classifications/*.json → extractions/*.yaml → knowledge_crystal.yaml
knowledge_crystal.json

Stage 1:文章分类(Classify)

第一阶段用 LLM 判断每篇文章的类型。这一步只需要文章前 3000 字 + 尾部 1000 字,token 消耗极低,但它的输出决定了后续使用哪套提取 Prompt。

我们定义了四种文章类型:

类型 名称 特征 预估占比
A 思路/观点型 个人见解、主观分析、通用讨论 ~40%
B 案例拆解型 分析具体产品的做法和效果 ~30%
C 方法论型 结构化方法、步骤流程、可复用框架 ~20%
D 公式/算法型 具体公式、算法、数值参数 ~10%

每篇文章生成一个 JSON 文件,包含分类结果和元数据:

1
2
3
4
5
6
7
8
9
{
"type": "C",
"confidence": "high",
"domains": ["<领域标签1>", "<领域标签2>", "<领域标签3>"],
"one_line": "文章提出某领域的三阶段框架...",
"title": "<文章标题>",
"category": "<分类>",
"author": "<作者>"
}

为什么要先分类再提取(两次 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
article_meta:
title: "<文章标题>"
category: "<分类>"
type: B

cases:
- game: "<产品名>"
topic: "<分析主题>"
what_they_did:
- "<具体做法1>"
- "<具体做法2>"
why_it_worked: "<成功原因分析>"
key_numbers: "<关键数据>"
takeaway: "<核心启示>"

techniques:
- name: "<技法名称>"
description: "<技法描述>"
example: "<具体案例>"
applicable_to: "<适用场景>"

若文章知识密度极低(纯水文),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
2
3
4
5
6
7
8
9
10
11
12
distill/output/
├── classifications/ # Stage 1 输出:N 个 .json
│ ├── <doc_id_1>_<文章标题1>.json
│ ├── <doc_id_2>_<文章标题2>.json
│ └── ...
├── extractions/ # Stage 2 输出:N 个 .yaml
│ ├── <doc_id_1>_<文章标题1>.yaml
│ ├── <doc_id_2>_<文章标题2>.yaml
│ └── ...
├── knowledge_crystal.yaml # Stage 3 最终产物(人类可读)
├── knowledge_crystal.json # Stage 3 最终产物(程序可解析)
└── .progress.json # 断点续传进度文件

每个文件以 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
2
3
4
5
6
7
8
9
10
11
12
文档 (doc_id)
├── article_summary ← 必有(来自 JSON 元数据的 one_line 摘要)
├── case ← Schema A: 案例分析
├── technique ← Schema A: 设计技法
├── method ← Schema B: 方法论步骤
├── checklist ← Schema B: 检查清单
├── framework ← Schema C/E: 设计框架
├── key_argument ← Schema C/E: 核心论点
├── formula ← Schema D: 公式
├── algorithm ← Schema D: 算法
├── numerical_pattern ← Schema D: 数值规律
└── practice ← Schema E: 实践案例

调度逻辑通过一个 SECTION_BUILDERS 字典实现——YAML 的顶层字段名直接映射到对应的 Builder 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
SECTION_BUILDERS = {
"cases": build_case_chunks,
"techniques": build_technique_chunks,
"methods": build_method_chunks,
"checklists": build_checklist_chunks,
"frameworks": build_framework_chunks,
"key_arguments": build_key_argument_chunks,
"formulas": build_formula_chunks,
"algorithms": build_algorithm_chunks,
"numerical_patterns": build_numerical_pattern_chunks,
"practices": build_practice_chunks,
}

def process_document(doc_id, cls_data, ext_data):
chunks = []
# 1) 必有的文章级摘要
chunks.append(build_article_summary_chunk(doc_id, cls_data, ext_meta))
# 2) 按 YAML 字段自动路由到对应 Builder
if ext_data:
for section_key, builder_fn in SECTION_BUILDERS.items():
if section_key in ext_data and ext_data[section_key]:
chunks.extend(builder_fn(doc_id, cls_data, ext_data))
return chunks

这个设计的优雅之处在于:新增一种 Schema 只需要写一个 Builder 函数并在字典中注册一行,完全开闭原则。

Chunk 文本构造

每个 Chunk 的 text 字段遵循统一的结构化模板:

1
2
3
4
5
【类型标签】标题/名称
来源文章:{article_title}
字段1:{value1}
字段2:{value2}
...

以一个 method 类型的 Chunk 为例:

1
2
3
4
5
6
7
8
9
10
11
【方法论】<方法名称>
来源文章:<文章标题>
目的:<方法论的目标描述>
步骤:
步骤1. <第一步描述>...
步骤2. <第二步描述>...
步骤3. <第三步描述>...
步骤4. <第四步描述>...
输入:<输入条件>
输出:<预期产出>
适用场景:<适用场景描述>

这个模板有三个设计意图:

  1. 类型标签(如 【案例】【方法论】【公式】)为 Sparse 检索提供强信号词。当用户搜索"方法论"时,Sparse 路能精确匹配到这些标签
  2. 来源文章 字段建立 Chunk → 文章的溯源链路,LLM 生成回答时可以直接引用
  3. 结构化排列有利于 LLM 在生成阶段解析上下文——模型能清晰地区分"目的"、“步骤”、"适用场景"等字段

Metadata Payload

每个 Chunk 携带丰富的元数据 Payload,支持后续的过滤检索与结果增强:

1
2
3
4
5
6
7
8
9
10
11
{
"doc_id": "<8位hex>",
"title": "<文章标题>",
"category": "<分类>",
"domains": ["<领域1>", "<领域2>", "<领域3>"],
"author": "<作者>",
"article_type": "C",
"source_url": "<来源链接>",
"method_name": "<方法名称>",
"use_when": "<适用场景>"
}

元数据字段根据 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
2
3
4
5
6
7
8
9
10
11
chunks.jsonl (N texts)


BGE-M3 Tokenizer (XLMRobertaTokenizerFast)
│ pre-tokenize: batches × 64 texts/batch

BGE-M3 Inference (CPU, FP32)
│ ~2-3 texts/sec (CPU)

├── Dense Vectors: shape (N, 1024), float32
└── Sparse Weights: N dicts {token_id: weight}

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
2
3
4
5
6
7
8
9
client.create_collection(
collection_name="knowledge_base",
vectors_config={
"dense": VectorParams(size=1024, distance=Distance.COSINE)
},
sparse_vectors_config={
"sparse": SparseVectorParams()
}
)

每个 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
2
3
4
5
6
7
8
Query: <某篇文章的 article_summary Chunk>

结果:
[1] score=1.0000 | article_summary | <同一文章>
[2] score=0.5833 | method | <同一文章>
[3] score=0.5000 | method | <同一文章>
[4] score=0.4500 | checklist | <同一文章>
[5] score=0.3250 | checklist | <同一文章>

自身精确匹配 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
2
3
4
5
6
7
8
9
10
11
12
用户请求 → Query 预处理(分词 / 纠错 / 去停用词)

├─ Vector Search:EF 向量召回 (Top 200)
├─ Keyword Search:ES 关键词召回 (KM 5条 / iWiki 3条)
└─ Full-text Search:ES 全文检索召回


第一轮重排:关键词匹配 + Jaccard 相似度
第二轮重排:finalScore × 提升系数


LLM 总结生成 (Final Answer Generation)

将其与我们的系统做对比:

维度 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。整体架构如下:

用户 Query
⚙️ BGE-M3 Encode ~180ms
→ Dense Vector (1024d) → Sparse Weights (dict)
Dense 路
Qdrant COSINE → Top 40
Sparse 路
Qdrant 稀疏 → Top 40
🧩 RRF Fusion — 粗排 ~100ms
score = Σ 1/(k + rank_i)Top 20
🔥 BGE-Reranker-v2-m3 — 精排 ~32s CPU / ~1.5s GPU
Cross-Encoder 交叉注意力打分 → Top 5 最终结果

三个阶段各自解决一个问题:

阶段 输入规模 输出规模 核心任务 速度要求
在线召回 全量 Chunks ~80 候选 从全量数据中快速筛出候选集 极快(毫秒级)
粗排 ~80 候选 20 候选 融合多路信号,统一排序 快(亚秒级)
精排 20 候选 5 最终结果 逐对精细打分,确保 Top-K 质量 可慢(秒级)

这是一个典型的倒金字塔结构:越往下,候选越少,模型越重,精度越高。

第一级:在线召回——Dense + Sparse 双路检索

在线召回是整个 Pipeline 的入口,它的核心任务是从全量 Chunk 中快速筛出最可能相关的候选集。我们采用 Dense + Sparse 双路并行检索,两路各取 Top 40,合计约 80 个候选(去重后通常 50-70 个)。

Dense 路:语义向量检索

核心思想:将文本映射到一个连续的高维向量空间中,语义相近的文本在空间中距离更近。检索时,计算 Query 向量与所有文档向量的余弦相似度,取最近的 Top-K。

编码过程

1
2
3
4
5
6
7
8
"产品核心系统平衡性设计"
↓ BGE-M3 Encoder (XLM-RoBERTa 架构, 12 层 Transformer)
↓ Tokenization → subword tokens
↓ 12 层自注意力编码(每个 token 都能"看到"所有其他 token)
↓ [CLS] token pooling → 取首个 token 的最终隐藏状态
↓ L2 归一化 → 映射到单位超球面

[0.023, -0.156, 0.891, ..., 0.034] ← 1024 维浮点向量

Transformer 的自注意力机制是 Dense 路语义理解能力的根基。在编码 “产品核心系统平衡性设计” 时,“平衡性” 这个 token 不仅编码了自身的词义,还通过注意力权重融合了 “产品”、“核心系统”、“设计” 等上下文信息。最终的 [CLS] 向量是整句话的全局语义压缩表示,它不再是各个词义的简单拼接,而是一个理解了"这句话在说什么"的整体表征。

检索过程

Query 向量 qq 和文档向量 did_i 均为 1024 维实数向量。两者的相似度通过余弦相似度计算:sim(q,di)=cos(q,di)=(qdi)/(q×di)sim(q, d_i) = cos(q, d_i) = (q · d_i) / (‖q‖ × ‖d_i‖)。由于 L2 归一化后 q‖q‖ = di‖d_i‖ = 1,余弦相似度退化为内积:cos(q,di)=qdicos(q, d_i) = q · d_i

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
"产品核心系统平衡性设计"
↓ BGE-M3 Encoder(与 Dense 路共享同一个 Transformer)
↓ 每个 token 的隐藏状态 h_i ∈ R^1024
↓ 线性投影层:w_i = ReLU(W · h_i + b) ← 单独的稀疏头
↓ 同一 token 多次出现时取 max pooling

{
"产品": 0.78, ← 核心实体,高权重
"核心": 0.65,
"平衡": 0.91, ← 核心概念,最高权重
"性": 0.12, ← 功能词,低权重
"设计": 0.73,
"优化": 0.31, ← 模型推断出的隐含相关词(原文未出现!)
"数值": 0.28, ← 同上
...
}

这里有一个精妙的设计: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
2
3
4
5
6
7
Query 稀疏向量: {"产品": 0.78, "核心": 0.65, "平衡": 0.91, "设计": 0.73}
文档 稀疏向量: {"产品": 0.76, "核心": 0.58, "平衡": 0.88, "数值": 0.45, "调整": 0.33}

相似度 = 共有维度的加权内积
= 0.78×0.76 + 0.65×0.58 + 0.91×0.88
= 0.593 + 0.377 + 0.801
= 1.771

只有两个向量共同非零的维度才参与计算,这使得稀疏向量检索天然具有精确匹配的特性,如果 Query 中有某个专有术语而文档中没有,这个维度的贡献为 0。

Sparse 路的优势与局限

优势 局限
精确关键词匹配:专有术语精确匹配,不会语义漂移 语义理解有限:虽然比 BM25 好,但仍以词汇匹配为主
专有名词强:产品名、术语、缩写的匹配非常精准 同义词覆盖不完整,可能匹配不上
可解释性:可以直接看到哪些词匹配上了、权重多少 依赖词表覆盖:词表中没有的 token 无法被表示
比 BM25 更智能:能做一定程度的词汇扩展

双路互补的本质

两路召回的设计不是冗余,而是互补。它们的错误模式恰好相反:

1
2
3
4
5
6
7
Dense 路的典型错误:语义漂移
Query: "<专有术语A>"
错误召回: "<专有术语B>"(语义相近但不是同一个东西)

Sparse 路的典型错误:词汇 gap
Query: "怎么让战斗更爽"
漏召回: "战斗体验优化方法论"(语义相同但没有共同关键词)

当两路结果取交集时,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)提供了一个优雅的解决方案:完全忽略原始分数,只使用排名信息

核心公式:

RRFscore(d)=Σ1/(k+ranki(d))RRF_score(d) = Σ 1 / (k + rank_i(d)),其中求和遍历 idense,sparsei ∈ {dense, sparse}

其中 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 的三个优势

  1. 无需训练:不需要标注数据,不需要调参(k=60 几乎是万能的)
  2. 异构友好:不关心原始分数的量纲和分布,只关心排名
  3. 鲁棒性强:即使某一路的分数分布异常(如 Sparse 路在某些 Query 上全部低分),RRF 仍能正常工作

Qdrant 内置了 RRF 融合,通过 Prefetch + FusionQuery 实现:

1
2
3
4
5
6
7
8
9
results = client.query_points(
collection_name="knowledge_base",
prefetch=[
Prefetch(query=q_dense, using="dense", limit=40),
Prefetch(query=SparseVector(...), using="sparse", limit=40),
],
query=FusionQuery(fusion=Fusion.RRF),
limit=20
)

这段代码的执行逻辑是: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
2
3
Query  → Transformer → q ─┐
├→ cos(q, d) → score
Doc → Transformer → d ─┘

Query 和 Doc 分别独立编码,最后才计算相似度。两者之间没有注意力交互

Cross-Encoder(精排阶段)

1
2
3
4
5
[CLS] Query [SEP] Doc [SEP]

Transformer(Query 和 Doc 的 token 之间有完整的交叉注意力)

[CLS] → 线性层 → sigmoid → score ∈ [0, 1]

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 伪代码
for doc in top_20_candidates:
# 1. 拼接 Query 和 Doc
input_text = f"[CLS] {query} [SEP] {doc.text} [SEP]"

# 2. Tokenize
tokens = tokenizer(input_text, max_length=512, truncation=True)

# 3. 过 Transformer(12 层交叉注意力)
hidden_states = transformer(tokens)

# 4. 取 [CLS] 的隐藏状态,过线性层 + sigmoid
score = sigmoid(linear(hidden_states[0])) # score ∈ [0, 1]

doc.rerank_score = score

# 5. 按 rerank_score 降序排列,取 Top 5
final_results = sorted(top_20_candidates, key=lambda d: d.rerank_score, reverse=True)[:5]

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
2
3
4
5
6
Query
→ BGE-M3 Encode
→ Dense + Sparse 双路检索 (Hybrid)
→ RRF 融合 Top 20
→ Cross-Encoder Rerank Top 5
→ 输出给 LLM
  • NDCG@5 = 1.099(最优)
  • Precision@5 = 0.775(最优)

端到端 RAG:LLM 模型生成层

Prompt 设计

Prompt 设计是 RAG 系统中最容易被低估的环节。一个好的 Prompt 需要做到三件事:定义角色注入上下文约束输出格式

System Prompt 定义角色为资深领域顾问,核心约束包括:

  • 优先引用检索内容,标注来源文章标题
  • 结构化回答(标题、要点、案例)
  • 案例对比分析
  • 可落地设计建议
  • 诚实边界声明(检索内容不足时明确说明)

User Prompt 的关键设计是元数据增强,每个检索结果不仅包含原文,还附加了结构化的元数据标签:

1
2
3
4
5
6
7
8
9
### 参考资料 1
[来源] 《<文章标题>》
[分类] <分类> | [领域] <领域1>、<领域2> | [类型] 案例分析 | [产品] <产品名>

【案例】<产品名> — <分析主题>
来源文章:<文章标题>
做法:<具体做法描述>
成功原因:<成功原因分析>
核心启示:<核心启示总结>

这些元数据标签帮助 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
N 篇原始文章 (各 5-30 KB)

▼ Stage 1: LLM 分类
200 个 classification JSON (各 ~450 B)

▼ Stage 2: LLM 知识提取
200 个 extraction YAML (155 有效 + 45 无效)

▼ 结构感知分块 (prepare_chunks.py)
~8N 个 Chunk (chunks.jsonl)

▼ BGE-M3 编码 (build_vectordb.py)
~8N × Dense (1024d) + ~8N × Sparse (avg ~73 terms)

▼ Qdrant 入库
knowledge_base 集合 (5 个 Payload 索引)

▼ 检索 + 重排 (search_pipeline.py)
Query → Top 40×2 → RRF Top 20 → Rerank Top 5

▼ LLM 生成 (rag_pipeline.py)
结构化专业回答 (avg 2,639 字, 质量分 0.918)