docs(rag): 编写完整的 RAG 检索流程逻辑文档

覆盖从 MinerU 解析入库到 LLM 回答的全链路数据流:
- 文档解析与入库流程(MinerU → Chunk → ChromaDB + BM25)
- ChromaDB 存储字段详解(每个 metadata 字段的作用)
- BM25 索引两种实现的差异分析
- distances/scores 语义在各管线阶段的变化
- 检索管线完整数据流图
- 三重救援机制详解(BM25 分歧/词法匹配/章节聚类)
- 配置项完整列表(含死代码标注)
- 单/多知识库路径说明
This commit is contained in:
lacerate551
2026-06-17 19:58:31 +08:00
parent 8c92657490
commit 4afa30a946

View File

@@ -0,0 +1,774 @@
# RAG 检索流程逻辑
本文档描述 RAG 知识库服务从文档解析入库到 LLM 回答的完整数据流,供开发排查和系统优化参考。
---
## 目录
1. [整体架构](#一整体架构)
2. [文档解析与入库流程](#二文档解析与入库流程)
3. [ChromaDB 存储字段详解](#三chromadb-存储字段详解)
4. [BM25 索引两种实现](#四bm25-索引两种实现)
5. [检索管线完整数据流](#五检索管线完整数据流)
6. [distances / scores 语义变化](#六distances--scores-语义变化)
7. [路由层处理流程](#七路由层处理流程)
8. [三重救援机制详解](#八三重救援机制详解)
9. [图片召回与选择](#九图片召回与选择)
10. [返回格式与溯源信息](#十返回格式与溯源信息)
11. [配置项完整列表](#十一配置项完整列表)
12. [单/多知识库路径说明](#十二单多知识库路径说明)
---
## 一、整体架构
```
┌─────────────────────────────────────────────────────────────────┐
│ 文档入库流程 │
│ │
│ 文件上传 → MinerU 解析 → MinerUChunk → 语义增强 → ChromaDB 存储 │
│ ↓ │
│ BM25 索引构建 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 检索问答流程 │
│ │
│ 用户提问 │
│ ↓ │
│ 意图分析(改写/子查询/意图分类) │
│ ↓ │
│ 语义缓存检查 ──命中──→ 直接返回缓存答案 │
│ ↓ 未命中 │
│ 混合检索(向量 + BM25 + 图片 + FAQ
│ ↓ │
│ RRF 融合 → 过滤链 → MMR → Rerank → 后处理 → 扩展 → 自适应 TopK │
│ ↓ │
│ search_hybriddistances → scores 转换) │
│ ↓ │
│ 路由层contexts 构建 → 三重救援 → 排序 → 预算截断 │
│ ↓ │
│ Prompt 构建 → LLM 流式生成 → 后处理 → 返回 │
└─────────────────────────────────────────────────────────────────┘
```
---
## 二、文档解析与入库流程
### 2.1 入口方法
```python
# knowledge/manager.py
def add_file_to_kb(self, kb_name, filepath, embedding_model=None,
extra_metadata=None, enable_table_summary=True,
enable_image_description=False, file_content=None) -> int
```
**完整流程**
```
1. 调用 parsers.parse_document(filepath) 进行 MinerU 解析
2. 合并跨页表格_merge_cross_page_tables
3. 清理同名旧切片(查询 source == filename 的旧切片并删除)
4. 逐切片处理:
a. 获取 chunk_type、page、section_path
b. 构建语义增强内容semantic_content
c. 构建 metadata 字典
d. 生成 embedding 向量
e. 可选LLM 生成表格摘要 / VLM 生成图片描述
5. 批量写入 ChromaDBcollection.add
6. 更新 BM25 索引bm25.add_documents + save
```
### 2.2 MinerU 解析流程
**三个解析入口**
| 函数 | 用途 | 调用方式 |
|------|------|---------|
| `parse_with_mineru()` | 本地 GPU 解析 | 由 `parse_with_mineru_persistent` 在在线失败时调用 |
| `parse_with_mineru_online()` | 在线 API 解析 | 由 `parse_with_mineru_persistent` 优先调用 |
| `parse_with_mineru_persistent()` | 持久化解析(主入口) | 由 `parsers.parse_document()` 调用 |
**实际主入口是 `parse_with_mineru_persistent()`**,内部根据 `config.MINERU_PREFER_ONLINE` 自动选择在线或本地,在线失败自动回退本地。
**解析核心流程**(以在线为例):
1. 文件校验(存在性、大小 ≤ 100MB、格式校验
2. 计算文件 MD5 hash前12位用于隔离输出目录
3. 申请上传链接 → PUT 上传文件 → 轮询查询结果5秒间隔→ 下载 zip 包
4. 解析 content_list优先 v2 格式 `_content_list_v2.json`
5. 逐项解析 content_list 构建 `MinerUChunk`
**`MinerUChunk` 数据结构**
```python
@dataclass
class MinerUChunk:
content: str # 文本内容
chunk_type: str # text / heading / table / image / chart / equation
page_start: int = 1 # 起始页码(1-based)
page_end: int = 1 # 结束页码
text_level: int = 0 # 标题级别 (0=正文, 1=h1, 2=h2, 3=h3)
title: str = "" # 标题文本
section_path: str = "" # 章节路径," > " 连接
bbox: Optional[List[float]] = None # 边界框 [x0, y0, x1, y1]
source_file: str = "" # 源文件名
table_html: Optional[str] = None # 表格 HTML仅 table
image_path: Optional[str] = None # 图片路径(独立图片)
images: Optional[List[Dict]] = None # 关联图片: [{"id":"abc.jpg","order":1}]
context_before: str = "" # 图片前文本上下文
context_after: str = "" # 图片后文本上下文
```
### 2.3 section_path 生成逻辑
维护 `section_stack: [(level, title), ...]`,遇到新标题时:
- 弹出栈中 `level >= 当前 level` 的项
- 压入 `(level, title)`
- `section_path = " > ".join([栈中所有项的 title])`
### 2.4 后处理三阶段
1. **过滤空切片**:移除 content 为空的 chunk
2. **合并碎片**:标题+正文合并、短文本合并(阈值 `MIN_CHUNK_SIZE//2`),表格/图片/公式不参与
3. **拆分超长**:超过 `MAX_CHUNK_SIZE` 的文本用 `split_text_with_limit` 拆分
### 2.5 图片处理
**图片来源三类**
1. 独立图片/图表content_list 中 type=image/chart`chunk.image_path`
2. 表格的图片形式type=table 有 img_path`chunk.image_path`
3. 嵌入表格 HTML 的图片(`<img src="...">`)→ `chunk.images` 列表
**图片路径重映射**`parse_with_mineru_persistent` 中):
1. 从 MinerU 输出找到图片源文件
2. 按文件内容 MD5 重命名,移动到 `.data/images/` 目录
3. 更新 chunk.image_path 和 chunk.images 中的路径
### 2.6 语义内容构建
**文本类型** `_build_semantic_content_for_text()`
```
{标题}
主题:{章节路径截断到3级}
{正文内容}
```
**表格类型** `_build_semantic_content_for_table()`
```
主题:{章节路径}
表格:{标题}(非"表格"时)
字段:{表头列表}
描述该表包含N行数据记录各字段信息
示例字段1=值1, 字段2=值2
表格内容:
| ... | ... |
```
**图片类型** `generate_lightweight_image_description()`
```
图片图2.1,系统架构图,位于「第一章 > 1.2 概述」第5页
前文:系统由三个模块组成...
后文:如图所示,各模块之间...
```
> **设计意图**:语义增强内容存入 ChromaDB 的 `documents` 字段,用于向量检索。增强后的内容比原始文本包含更多上下文信息,提升检索召回率。
---
## 三、ChromaDB 存储字段详解
### 3.1 存储结构
ChromaDB 使用 `collection.add(ids, documents, metadatas, embeddings)` 批量写入。
| 字段 | 类型 | 说明 |
|------|------|------|
| `ids` | `List[str]` | 切片唯一标识,格式 `{filename}_{index}`,如 `"1.docx_14"` |
| `documents` | `List[str]` | 语义增强后的内容(非原始 content |
| `metadatas` | `List[dict]` | 切片元数据(见下表) |
| `embeddings` | `List[List[float]]` | 768维向量`embedding_model.encode(semantic_content)` 生成 |
### 3.2 metadatas 字段详解
| 字段 | 类型 | 来源 | 作用 | 示例 |
|------|------|------|------|------|
| `chunk_id` | str | `{filename}_{index}` | 切片唯一标识(与 ids 相同) | `"1.docx_14"` |
| `chunk_index` | int | 入库时赋值 `i` | 切片在文件中的序号,用于邻居扩展和救援 | `14` |
| `chunk_type` | str | MinerU 解析 | 切片类型,驱动检索和展示逻辑 | `"text"` |
| `source` | str | 文件名 | 源文件名,用于黑名单过滤和来源展示 | `"1.docx"` |
| `collection` | str | 入库参数 | 所属向量库名,确保跨库可追溯 | `"public_kb"` |
| `doc_type` | str | 文件扩展名映射 | 文档类型,驱动前端差异化溯源展示 | `"word"` |
| `page` | int | MinerU 解析 | 起始页码(仅 PDF 可靠) | `5` |
| `page_end` | int | MinerU 解析 | 结束页码 | `5` |
| `section` | str | MinerU `section_path` | 章节路径用于章节过滤、聚类救援、prompt 标题注入 | `"第一章 > 1.1 概述"` |
| `status` | str | 默认 `"active"` | 切片状态,`deprecated` 时被引擎过滤 | `"active"` |
| `version` | str | 默认 `"v1"` | 版本号,用于缓存失效 | `"v1"` |
| `images_json` | str | `json.dumps(chunk.images)` | 关联图片列表的 JSON 序列化,图片选择时反序列化 | `'[{"id":"abc.jpg","order":1}]'` |
| `image_path` | str | MinerU 解析 | 图片文件名(不含目录),用于图片 URL 生成 | `"0569dd285537.jpg"` |
> **注意**`chunk_id` 与 `ids` 重复存储。`ids` 是 ChromaDB 的主键,`chunk_id` 存在 metadata 中便于路由层按 metadata 查找。两者值相同但用途不同。
> **命名不一致**MinerU 中字段名 `section_path`,入库时映射为 metadata 的 `section`。路由层代码中两种名称混用(`meta.get('section')` 和 `meta.get('section_path')`),通常用 `or` 兼容:`meta.get('section', '') or meta.get('section_path', '')`。
### 3.3 chunk_type 取值
| chunk_type | 说明 | 检索行为 | 展示行为 |
|-----------|------|---------|---------|
| `text` | 普通文本 | 正常参与向量检索 | 正常展示 |
| `heading` | 标题 | 正常参与向量检索 | 正常展示 |
| `table` | 表格 | 走图片独立检索通道;有表格保护 | 受 `max_chunks` 豁免;可被表格救援找回 |
| `image` | 图片 | 走图片独立检索通道 | 用 `full_description` 替换 doc |
| `chart` | 图表 | 走图片独立检索通道 | 同 image |
| `equation` | 公式 | 正常参与向量检索 | 正常展示 |
| `faq` | FAQ | FAQ 独立检索通道 | 享受分数加权 |
---
## 四、BM25 索引两种实现
项目中存在两个 `BM25Index` 类,返回格式有重要差异。
### 4.1 对比表
| 维度 | `core/bm25_index.py` | `knowledge/base.py` |
|------|---------------------|---------------------|
| **使用场景** | 单知识库路径(当前不执行) | 多知识库路径(当前活跃) |
| **实例管理** | 全局单例 `RAGEngine.bm25_index` | 每个向量库独立实例 |
| **持久化** | 单一 `bm25_index.pkl` | `{kb_name}.pkl` 每库独立 |
| **search() 返回** | `Dict`ChromaDB 兼容格式) | `Tuple[List, List, List, List]` |
| **返回值解构** | `result['ids'][0]` | `ids, docs, metas, scores = bm25.search()` |
| **列表嵌套** | `[['id1', 'id2']]`(嵌套列表) | `['id1', 'id2']`(扁平列表) |
| **分数字段名** | `distances` | 第4个返回值匿名 |
| **add_documents 模式** | 替换(覆盖) | 追加 + 去重 |
### 4.2 兼容处理
多知识库路径中,`_search_multi_kb` 方法对 `knowledge/base.py` 的 tuple 返回值做了兼容转换:
```python
# core/engine.py _search_multi_kb 中
if isinstance(bm25_res, tuple):
_ids, _docs, _metas, _dists = bm25_res
bm25_res = {
'ids': [_ids], 'documents': [_docs],
'metadatas': [_metas], 'distances': [_dists]
}
```
> **设计建议**:两个 BM25Index 实现有重叠功能,未来可考虑统一为 dict 返回格式,消除兼容转换代码。
---
## 五、检索管线完整数据流
### 5.1 入口方法
```python
# api/chat_routes.py
search_result = search_hybrid(retrieval_query, top_k, candidates, allowed_collections, sub_queries)
# search_hybrid 内部调用
result = engine.search_knowledge(query, top_k, allowed_levels, collections, sub_queries)
# 然后添加 scores 字段
```
### 5.2 search_knowledge 完整流程
```
query (str)
├─ [1] 缓存检查 ──命中──→ 直接返回
├─ [2] 外部子查询IntentAnalyzer 生成的 sub_queries
│ → _search_with_sub_queries() → 合并 → 返回
├─ [3] 查询拆分QueryDecomposer 判断)
│ → _search_with_decomposition() → 合并 → 返回
├─ [4] 查询扩展(当前仅构建 expanded_queries未用于多查询检索
├─ [5] 分支USE_MULTI_KB=True → _search_multi_kb() → 返回
│ USE_MULTI_KB=False → 单知识库路径(当前不执行)
└─ 单知识库路径(以下步骤在 _search_multi_kb 中对称存在):
├─ [6] where 过滤构建security_level / source
├─ [7] 向量编码 → query_vector (768维)
├─ [8] 向量检索 → vector_results
│ {ids:[[]], documents:[[]], metadatas:[[]], distances:[[]]}
├─ [9] 图片独立检索 → image_results → 合并
├─ [10] FAQ 独立检索 → faq_results → 合并
├─ [11] BM25 检索 → bm25_results
│ + 捕获 BM25 原始 top-3 → _bm25_raw_top3
├─ [12] 动态 RRF 权重 → (vector_w, bm25_w)
├─ [13] RRF 融合 → fused_results
│ {distances:[[rrf_score]], '_score_source':'rrf'}
│ ids 带 collection 前缀:"public_kb/filename_3"
├─ [14] 废止过滤 → 移除 status != "active" 的切片
├─ [15] 枚举标记 → fused_results['_enum_query'] = bool
├─ [16] BM25 top3 传递 → fused_results['_bm25_top3']
├─ [17] 章节过滤 → 按查询中的章节关键词过滤
├─ [18] 上下文扩展 1MMR前min_score=0→ 补齐邻居
├─ [19] MMR 去重 → 缩减到 MMR_TOP_K=30
├─ [20] Rerank 重排 → distances 变为 rerank 分数 [0,1]
│ '_reranked'=True, '_score_source' 仍为 'rrf'
├─ [21] FAQ 加权 → FAQ 切片 distances -= 0.1
├─ [22] 黑名单过滤 → 移除黑名单 source
├─ [23] 时间衰减 → 老 FAQ distances += decay
├─ [24] 章节聚类提升 → 低分但聚类的切片 distances = 0.65
├─ [25] 上下文扩展 2Rerank后min_score=0.3)→ 高分种子邻居
├─ [26] 自适应 TopK → 可能截断结果
│ ⚠️ 当前 bug: _score_source='rrf' 导致此步被跳过
└─ [27] 缓存写入 + 返回
```
### 5.3 特殊键在管线中的传递
以下键在过滤/截断方法中需要手动复制(否则会丢失):
```python
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context', '_bm25_top3'):
if key in results:
filtered[key] = results[key]
```
| 键 | 写入位置 | 读取位置 | 作用 |
|----|---------|---------|------|
| `_debug` | 每步追加 step | 返回给前端dev 模式) | 检索调试信息 |
| `_score_source` | RRF 融合写入 `'rrf'` | 自适应 TopK 判断 | 区分 distances 语义 |
| `_enum_query` | 枚举检测后写入 | MMR lambda 选择、自适应 TopK | 枚举查询保留更多上下文 |
| `_expanded_context` | 扩展时写入 | 返回给前端 | 扩展统计 |
| `_bm25_top3` | BM25 检索后写入 | 路由层分歧检测救援 | BM25 原始 top-3 切片信息 |
| `_reranked` | Rerank 后写入 | `search_hybrid` 中判断 | 区分 distances 是距离还是分数 |
| `_cluster_boosted` | 聚类提升写入 meta | 上下文扩展时作为种子资格 | 标记被引擎层提升的切片 |
### 5.4 多知识库并发检索
`_search_multi_kb` 使用 `ThreadPoolExecutor` 并行查询各向量库:
```python
with ThreadPoolExecutor(max_workers=len(target_collections)) as executor:
futures = {executor.submit(_query_single_collection, name): name for name in target_collections}
for future in as_completed(futures):
coll_results, bm25_raw_items = future.result()
all_results.extend(coll_results)
_bm25_raw_top3.extend(bm25_raw_items)
```
每个向量库独立查询:向量检索 + BM25 检索,结果标记 `_collection = coll_name`
### 5.5 子查询路径的 BM25 top3 传递
`_search_with_sub_queries``_search_with_decomposition` 合并结果时,会收集各子查询的 `_bm25_top3`,按 `bm25_score` 降序取全局 top-3确保路由层分歧检测在子查询路径下也能工作。
---
## 六、distances / scores 语义变化
`distances` 字段在管线各阶段含义不同,是理解检索逻辑的关键。
### 6.1 语义变化表
| 阶段 | distances 含义 | 方向 | 范围 | 标记 |
|------|---------------|------|------|------|
| ChromaDB 向量检索 | cosine distance | 越小越好 | [0, 2] | - |
| BM25 检索 | BM25 原始分数 | 越大越好 | [0, ∞) | - |
| RRF 融合后 | RRF 分数 | 越大越好 | [0, ~0.05] | `_score_source='rrf'` |
| Rerank 后 | CrossEncoder 相关性分数 | 越大越好 | [0, 1] | `_reranked=True` |
| FAQ 加权后 | rerank 分数 - 0.1 | 越大越好 | [-0.1, 1] | - |
| 时间衰减后 | 距离 + decay | 越大越差 | - | - |
| 聚类提升后 | 被设为 `1.0 - CLUSTER_SEED_FLOOR` = 0.65 | 越大越好 | - | `_cluster_boosted=True` |
### 6.2 search_hybrid 中的 distances → scores 转换
```python
# api/chat_routes.py search_hybrid()
if result.get('_reranked'):
# Rerank 后distances 就是相关性分数,直接使用
scores = [float(d) for d in distances]
else:
# 未 Rerank将向量距离转为相似度分数
scores = [1.0 - d if d <= 1.0 else 1.0 / (1.0 + d) for d in distances]
result['scores'] = [scores]
```
### 6.3 自适应 TopK 中的距离→相似度转换
```python
# engine.py 第 790 行
top_score = 1.0 - fused_results['distances'][0][0] # 距离转相似度
```
> **⚠️ 已知问题**RRF+Rerank 后,`_score_source` 仍为 `'rrf'`,导致自适应 TopK 被跳过(第 788 行条件 `fused_results.get('_score_source') != 'rrf'` 不满足)。但此时 distances 已是有效的 rerank 分数,自适应 TopK 本应可以应用。此 bug 导致高置信度查询无法收缩结果,低置信度查询无法扩展。
---
## 七、路由层处理流程
### 7.1 generate_stream 完整流程
```
用户提问 (message)
├─ [0] 意图分析 → IntentAnalysis(rewritten_query, need_retrieval, use_context, sub_queries, intent)
│ need_retrieval=False + use_context=True → 直接用历史上下文回答 → return
├─ [0.5] retrieval_query = intent.rewritten_query
├─ [1] 语义缓存检查 ──命中──→ 流式返回缓存答案 → return
├─ [2] 混合检索 → search_hybrid() → search_result
├─ [3] 构建 contexts 列表
│ 从 search_result 的 documents[0] / metadatas[0] / scores[0] 三数组构建
│ contexts = [{'doc': display_doc, 'meta': meta, 'score': score}, ...]
│ 图片/图表切片doc 替换为 meta.full_description如果存在
├─ [3.5] 补充检索:从文本切片提取图号/表号引用,补充检索缺失图片
├─ [4] 懒加载增强(当前禁用)
├─ [5] 图片选择 → select_images()
├─ [6] 三重救援
│ ├─ _rescue_bm25_divergenceBM25 分歧检测救援)
│ ├─ _rescue_lexical_match词法匹配救援
│ └─ _rescue_section_cluster章节聚类救援
├─ [7] 排序 + 预算截断
│ _order_text_contexts_for_prompt() → min_score 过滤 → max_chunks 截断
├─ [8] 表格救援 → _rescue_table_chunks()
├─ [9] Prompt 构建
│ ├─ 置信度分数top-3 平均 rerank 分数)
│ ├─ 图片描述注入
│ ├─ 意图驱动指令注入(对比/推理/操作/枚举)
│ └─ 置信度指令注入
├─ [10] LLM 流式生成 → engine.generate_answer_stream()
└─ [11] 后处理
├─ 答案对齐过滤(提取图号/表号,反向筛选图片)
├─ 去引用标记(移除 [1][2]
├─ 附加引用标注([ref:chunk_id]
├─ 敏感信息过滤
├─ 保存会话
└─ 写入语义缓存
```
### 7.2 contexts 构建关键转换
```python
# search_result 格式(来自 search_hybrid
search_result = {
'documents': [[doc1, doc2, ...]],
'metadatas': [[meta1, meta2, ...]],
'scores': [[score1, score2, ...]], # search_hybrid 添加
'ids': [['public_kb/filename_3', ...]], # 多知识库模式带前缀
'distances': [[rerank_score, ...]], # 原始 distances 仍保留
'_bm25_top3': [{id, doc, meta, bm25_score, rank}, ...],
'_debug': {...},
}
# contexts 列表格式(路由层使用)
contexts = [
{'doc': display_doc, 'meta': meta, 'score': score},
...
]
```
> **注意**contexts 中**没有 `id` 字段**。切片 ID 只能通过 `meta.chunk_id` 获取(格式如 `1.docx_14`),而 engine 的 `ids` 可能带 collection 前缀(如 `public_kb/1.docx_14`)。
### 7.3 min_score 过滤
`RERANK_CONTEXT_MIN_SCORE = 0.05`
`_order_text_contexts_for_prompt` 中:
- `score >= min_score` 的切片直接通过
- **表格保护**:同 section 有切片通过阈值时,同 section 的 table 切片保留下限为 `min_score * 0.3` = 0.015
---
## 八、三重救援机制详解
三重救援是双层保护架构:**引擎层**在 rerank 后提升低分但可信的切片(分数较高),**路由层**在 min_score 过滤前做最终安全网(分数较低)。
### 8.1 BM25 分歧检测救援 `_rescue_bm25_divergence`
**触发条件**
- `BM25_DIVERGENCE_RESCUE_ENABLED = True`
- `search_result._bm25_top3` 非空
- BM25 项的 `rank <= BM25_DIVERGENCE_MAX_RANK`= 3
**保底分数**`CLUSTER_RESCUE_FLOOR = 0.06`
**两种情况**
| 情况 | 条件 | 处理 | 示例 |
|------|------|------|------|
| **A — 分数压制** | 切片在 contexts 中但 `score < min_score` | 提升 score 至 `CLUSTER_RESCUE_FLOOR` | q003: 0.0034 → 0.06 |
| **B — 截断丢失** | 切片不在 contexts 中(被 rerank top_k 截断) | 从 `_bm25_top3` 备份注入新 context | q014: 不在 → 注入 score=0.06 |
**保护机制**
- 仅救援 BM25 rank ≤ 3 的切片top-3 是精确关键词匹配的强信号)
- 救援分数固定为 `CLUSTER_RESCUE_FLOOR`0.06),不会高于 rerank 正常通过的切片
- 匹配使用 `meta.chunk_id`(不带 collection 前缀),与 BM25 top3 的 `id` 字段匹配
**数据来源**`_bm25_top3``engine.search_knowledge` 在 BM25 搜索后保存,格式为:
```python
[{'id': '1.docx_14', 'doc': '...', 'meta': {...}, 'bm25_score': 12.4, 'rank': 1}, ...]
```
### 8.2 词法匹配救援 `_rescue_lexical_match`
**触发条件**
- contexts 非空且 retrieval_query 非空
- 清理后查询长度 ≥ 2
- 能提取 bigram
**保底分数**`CLUSTER_RESCUE_FLOOR = 0.06`
**Phase 1 — 词法匹配救援**
1. 清理查询(去除 markdown 格式和标点)
2. 提取查询 bigram 集合(连续两字组)
3. 对每个 `score < min_score` 的切片,计算 bigram 命中率
4. 命中率 > 0.35 → 提升 score 至 `max(原score, CLUSTER_RESCUE_FLOOR)`
5. 记录被救援的 `(source, chunk_index)` 作为种子
**Phase 2 — 邻居救援**
- 对每个词法匹配救援的种子,同时救援同 source 下 `chunk_index` 后续 8 个相邻切片
- 目的:枚举类问题的 header 切片被救援后,其后续子条目也应被保留
### 8.3 章节聚类救援 `_rescue_section_cluster`
**触发条件**
- `SECTION_CLUSTER_RESCUE_ENABLED = True`
- 存在"全灭 section":某 section 的成员数 ≥ `CLUSTER_MIN_MEMBERS`= 3类型多样性 ≥ `CLUSTER_MIN_TYPES`= 2且所有成员 score < min_score
**保底分数**`CLUSTER_RESCUE_FLOOR = 0.06`
**处理逻辑**
1.`(source, normalized_section)` 分组
2. 检测"全灭 section":所有成员 score < min_score
3. 计算聚类强度 = 成员数 × 类型多样性 × (1 + 查询匹配度)
4. 按强度降序,救援 top `CLUSTER_MAX_SECTIONS`= 3个 section
5. 每个 section 最多救援 `CLUSTER_MAX_RESCUE_PER_SECTION`= 6个切片
### 8.4 引擎层 vs 路由层的双层保护
| 维度 | 引擎层 `_section_cluster_boost` | 路由层 `_rescue_section_cluster` |
|------|-------------------------------|-------------------------------|
| 执行时机 | Rerank 后、扩展前 | min_score 过滤前 |
| 提升方式 | 修改 distances | 修改 contexts score |
| 保底分数 | `CLUSTER_SEED_FLOOR = 0.35`dist=0.65 | `CLUSTER_RESCUE_FLOOR = 0.06` |
| 覆盖范围 | 聚类切片在 rerank 之前就能被后续步骤看到 | 最终安全网,确保不遗漏 |
> **设计意图**引擎层提升分数较高0.35 对应 dist=0.65使得这些切片能在后续的扩展步骤中被当作种子。路由层是最终安全网分数较低0.06)仅确保通过 min_score 过滤。
---
## 九、图片召回与选择
### 9.1 独立图片检索P0 通道)
图片/图表切片走独立检索通道,不被文本切片挤占名额:
```python
image_recall_k = max(5, top_k // 2) # 图片独立召回数量
image_results = _search_image_chunks(query_vector, image_recall_k, where_filter)
```
- 仅检索 `chunk_type``image``chart``table` 的切片
- 多知识库模式下对每个 collection 分别调用
- 图片结果与文本结果通过 `_merge_results()` 合并
### 9.2 图片相关性提升Boost
在路由层对图片/图表切片做相关性评估:
- **编号匹配**:查询提到"图2.1"且图片 caption 匹配 → boost_factor = 2.0
- **语义重叠**caption 与查询有足够字符重叠 → boost_factor = 1.5
- Boost 以 `_image_boost` 标记记录在 metadata 中,不改变排序
### 9.3 图片选择(`select_images`
从召回结果中筛选最终展示给 LLM 的图片:
1. 动态预算:精确查图 2 张,有图片数据 5 张,有引用 3 张,默认 2 张
2. 对有 `image_path` 的切片用 `score_image_relevance` 打分
3. VLM 相关性筛选 + 章节关联检测 + 图号/表号匹配加分
4. 按分数降序取 top `MAX_IMAGES`
### 9.4 图片后置过滤
LLM 生成回答后,用回答内容反向过滤图片:
- 提取回答关键词
- 检查每张图片描述与回答关键词的重叠度
- 超过阈值的保留;兜底:若全部被过滤则保留最高分 1 张
---
## 十、返回格式与溯源信息
### 10.1 SSE 事件序列
| 事件类型 | 说明 |
|---------|------|
| `intent_result` | 意图分析结果(仅 dev |
| `retrieval_debug` | 检索管线调试信息(仅 dev |
| `start` | 开始生成 |
| `sources` | 检索来源列表 |
| `chunks_retrieved` | 召回切片详情(仅 dev |
| `section_cluster_rescue` | 章节聚类救援(仅 dev |
| `chunk` | 每个 token流式 |
| `finish` | 完成事件(含完整回答和元数据) |
### 10.2 来源信息sources
```json
{
"source": "文件名.docx",
"page": 12,
"page_end": 14,
"page_range": "12-14",
"section": "第三章 > 第二节 > 小节名",
"chunk_type": "text",
"doc_type": "word",
"section_chunk_id": 5,
"score": 0.892
}
```
### 10.3 引用格式citations
`_attach_citations()` 自动插入 `[ref:chunk_id]` 标记:
```
根据相关规定,安全检查应包括以下几个方面[ref:3.docx_154]...
```
---
## 十一、配置项完整列表
### 检索管线配置
| 配置项 | 默认值 | 作用 | 状态 |
|-------|--------|------|------|
| `USE_MULTI_KB` | `True` | 多向量库模式 | ✅ 活跃 |
| `USE_HYBRID_SEARCH` | `True` | 向量+BM25混合检索 | ✅ 活跃 |
| `USE_RERANK` | `True` | Rerank 重排 | ✅ 活跃 |
| `RERANK_CANDIDATES` | `20` | Rerank 候选数 | ✅ 活跃 |
| `RERANK_CONTEXT_MIN_SCORE` | `0.05` | 路由层最低分数阈值 | ✅ 活跃 |
| `RERANK_BACKEND` | `"local"` | local/cloud/fallback | ✅ 活跃 |
| `RERANK_USE_ONNX` | env("true") | ONNX 加速 | ✅ 活跃 |
| `DYNAMIC_RRF_ENABLED` | `True` | 动态 RRF 权重 | ✅ 活跃 |
| `RRF_K` | `60` | RRF 常数 | ✅ 活跃 |
| `MMR_ENABLED` | `True` | MMR 去重 | ✅ 活跃 |
| `MMR_USE_EMBEDDING` | env("false") | 高精度/轻量版 | ✅ 活跃 |
| `MMR_TOP_K` | `30` | MMR 保留数量 | ✅ 活跃 |
| `MMR_LAMBDA` | `0.5` | 相关性vs多样性 | ✅ 活跃 |
| `ENUM_QUERY_MMR_LAMBDA` | `0.85` | 枚举查询 lambda | ✅ 活跃 |
| `QUERY_EXPANSION_ENABLED` | `True` | 查询扩展 | ⚠️ 构建但未用于多查询 |
| `SECTION_FILTER_ENABLED` | `True` | 章节过滤 | ✅ 活跃 |
| `ADAPTIVE_TOPK_ENABLED` | `True` | 自适应 TopK | ⚠️ RRF+Rerank 后被跳过bug |
| `CONTEXT_EXPANSION_ENABLED` | `True` | 上下文扩展 | ✅ 活跃 |
| `CONTEXT_EXPANSION_BEFORE` | `1` | 向前扩展数 | ✅ 活跃 |
| `CONTEXT_EXPANSION_AFTER` | `8` | 向后扩展数 | ✅ 活跃 |
| `CONTEXT_EXPANSION_MAX_CHUNKS` | `50` | 最大扩展总数 | ✅ 活跃 |
| `SECTION_CLUSTER_BOOST_ENABLED` | `True` | 引擎层聚类提升 | ✅ 活跃 |
| `SECTION_CLUSTER_RESCUE_ENABLED` | `True` | 路由层聚类救援 | ✅ 活跃 |
| `BM25_DIVERGENCE_RESCUE_ENABLED` | `True` | BM25 分歧救援 | ✅ 活跃 |
| `BM25_DIVERGENCE_MAX_RANK` | `3` | 救援 BM25 排名阈值 | ✅ 活跃 |
| `RAG_SEARCH_TOP_K` | `30` | 传给 search_hybrid 的 top_k | ✅ 活跃 |
| `RAG_SEARCH_CANDIDATES` | `100` | 传给 search_hybrid 的 candidates | ❌ 不传递给 engine |
| `ENABLE_WEB_SEARCH` | `False` | 网络搜索 | ❌ 预留接口,未实现 |
| `VECTOR_WEIGHT` / `BM25_WEIGHT` | `0.5` | 静态 RRF 权重 | ⚠️ 动态 RRF 启用时被覆盖 |
### 缓存配置
| 配置项 | 默认值 | 作用 | 状态 |
|-------|--------|------|------|
| `QUERY_CACHE_ENABLED` | `True` | 查询缓存 | ✅ 活跃 |
| `EMBEDDING_CACHE_ENABLED` | `True` | Embedding 缓存 | ✅ 活跃 |
| `RERANK_CACHE_ENABLED` | `True` | Rerank 分数缓存 | ✅ 活跃 |
| `SEMANTIC_CACHE_ENABLED` | `True` | 语义缓存 | ✅ 活跃 |
### 聚类/救援配置
| 配置项 | 默认值 | 作用 | 状态 |
|-------|--------|------|------|
| `CLUSTER_MIN_MEMBERS` | `3` | 触发聚类最小成员数 | ✅ 活跃 |
| `CLUSTER_MIN_TYPES` | `2` | 触发聚类最小类型数 | ✅ 活跃 |
| `CLUSTER_SEED_FLOOR` | `0.35` | 引擎层聚类提升阈值 | ✅ 活跃 |
| `CLUSTER_RESCUE_FLOOR` | `0.06` | 路由层救援保底分数 | ✅ 活跃 |
| `CLUSTER_MAX_BOOST_PER_SECTION` | `8` | 引擎层每 section 最大提升数 | ✅ 活跃 |
| `CLUSTER_MAX_SECTIONS` | `3` | 全局最大提升 section 数 | ✅ 活跃 |
| `CLUSTER_MAX_RESCUE_PER_SECTION` | `6` | 路由层每 section 最大救援数 | ✅ 活跃 |
| `CLUSTER_SECTION_PREFIX_LEVELS` | `1` | section 归一化层级 | ✅ 活跃 |
### LLM 预算配置
| 配置项 | 默认值 | 作用 | 状态 |
|-------|--------|------|------|
| `MAX_LLM_CALLS_PER_QUERY` | `2` | 每查询 LLM 调用上限 | ⚠️ 仅 llm_budget.py 使用(模块未集成到主流程) |
| `MAX_QUERY_REWRITES` | `1` | 查询改写上限 | ⚠️ 同上 |
---
## 十二、单/多知识库路径说明
### 12.1 当前配置
`USE_MULTI_KB = True`(硬编码在 `config.py`
**效果**`search_knowledge` 在第 612 行直接分支到 `_search_multi_kb()`,跳过第 628-811 行的单知识库路径。
### 12.2 两条路径对比
| 维度 | 单知识库路径 | 多知识库路径 |
|------|------------|------------|
| 向量库 | `self.collection` | `self.kb_manager.get_collection(coll_name)` |
| BM25 | `self.bm25_index`core/bm25_index.py | `kb_manager.get_bm25_index(coll_name)`knowledge/base.py |
| 并发 | 无 | `ThreadPoolExecutor` 并行查各库 |
| RRF 权重 | `[VECTOR_WEIGHT, BM25_WEIGHT]` | `[vector_w, bm25_w, ...]` 交替 |
| ID 前缀 | 无 | 带 collection 前缀 |
| 安全过滤 | ChromaDB where 过滤 | source 过滤 |
### 12.3 单知识库路径保留原因
- 逻辑与多知识库路径对称,作为回退方案
- 小规模部署可能不需要多知识库
- `self.bm25_index`core/bm25_index.py仅在此路径使用
### 12.4 独立检索路径
`knowledge/search.py``SearchMixin``SearchResult` 是独立于 engine 的检索路径:
-`knowledge/router.py` 使用(知识库路由推荐功能)
- **不走 engine 主流程**
- 返回 `SearchResult` dataclass扁平列表格式与 engine 的 ChromaDB 格式不兼容