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

775 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 格式不兼容