RAG 两段式切块 + 四级检索流水线 — 设计文档

日期: 2026-07-03
状态: 待实现
作者: 协作设计(用户 + Claude)

目标

优化现有 RAG 管道的检索质量与上下文完整性。现有管道单一固定切块(1000字/300重叠),检索命中后直接送 LLM,存在两个问题:

  1. 上下文割裂 — 命中的 chunk 可能正好切断了一个完整景点介绍,LLM 拿到的是残缺上下文
  2. 检索粗放 — 只有 BM25→向量→reranker 三级,早期级无门控,低质量查询会浪费昂贵的 reranker API 调用

本设计通过 两段式文档切块 + 四级检索流水线 + 多级检索门控 解决上述问题。

非目标

  • 不改 TravelRAG 的对外接口(query() / query_with_metrics() / get_rag() 签名不变)
  • 不引入新的外部依赖(继续用 jieba / rank-bm25 / sentence-transformers / qwen3-rerank / DeepSeek)
  • 不做磁盘持久化缓存(用内存按城市懒缓存即可)
  • 不改 Agent 侧调用代码、不改 API 端点签名

总体架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
用户问题

0. LLM 查询重写(沿用现有 _rewrite_query)

1. 城市提取 + 文档匹配(沿用现有,按城市懒缓存)

2. 两段式切块(新)
├─ Parent 切块:~1500 字,重叠 200
└─ 每个 Parent 切 Child:~300 字,重叠 50

3. 四级检索流水线(新)
一级 BM25 召回 全部 child → top 30 [门控: 召回数 ≥ 5]
二级 向量召回 top 30 child → top 15 [门控: top 余弦 ≥ 0.30]
三级 邻居滑窗交叉重排 top 15 → 展窗 → rerank top 5 窗口 [门控: top ≥ 0.25]
四级 证据打包 top 5 窗口 → 取 parent → 去重 → 证据包

4. 门控 + DeepSeek 生成回答(沿用现有 _ANSWER_PROMPT)

接口不变TravelRAG.query() / query_with_metrics() 签名和行为不变,api/rag.pyagent/tools.pytests/rag_eval.py 零改动。

详细设计

第 2 阶段:两段式切块

数据结构

1
2
3
4
5
6
7
8
9
10
11
12
@dataclass
class ChildChunk:
idx: int # 全局 child 序号(在该城市内唯一)
parent_idx: int # 所属 parent 序号
text: str # child 文本(~300字)
position_in_parent: int # 在 parent 内的位置(0,1,2...)

@dataclass
class ParentChunk:
idx: int
text: str # parent 文本(~1500字)
child_idxs: list[int] # 包含的 child 序号

切块参数

chunk_size overlap 分隔符
Parent 1500 200 \n\n, \n, , , , , ., , ""
Child 300 50 同上

流程

  1. RecursiveCharacterTextSplitter 切 parent(size=1500, overlap=200)
  2. 对每个 parent,再用 splitter 切 child(size=300, overlap=50)
  3. 记录每个 child 的 parent_idxposition_in_parent
  4. 同一城市的所有 parent/child 存进该城市的 CityIndex 缓存

设计要点

  • child overlap=50:child 已小,大 overlap 会导致大量重复向量编码;50 字足够防止句子被硬切。
  • 图片文档:沿用现有 _call_vision_api 转文字后,同样走两段式切块。
  • parent-child 映射:child 通过 parent_idx 反查 parent,是证据打包的基础。

第 3 阶段:四级检索流水线

一级 — BM25 召回

  • 输入:该城市全部 child(从 CityIndex.bm25 查询)
  • 输出:top 30 child(按 BM25 分)
  • 门控:召回数 < 5 → 拒绝(error=bm25_low_recall
  • 说明:k 从现有 20 放宽到 30,因为 child 更小更碎,需要更高召回保证不漏。

二级 — 向量召回

  • 输入:top 30 child
  • 方法:用 BGE 编码 query + child,余弦相似度排序(沿用现有 normalize_embeddings=True + np.dot
  • 输出:top 15 child
  • 门控:top 余弦分 < 0.30 → 拒绝(error=vector_low_score
  • 说明:向量分数从 CityIndex.child_vectors 直接取,无需重编码 child(只编码 query)。

三级 — 邻居滑窗交叉重排

  • 窗口构建:对 top 15 的每个 child,同 parent 内取前后各 2 个邻居 child,拼成窗口(5 child,1500 字,约等于一个 parent)
  • 边界处理:parent 边缘的 child 邻居不足 2 个时,取实际可得的(窗口可能 3-4 个 child)
  • 去重:多个命中 child 可能生成相同窗口,按窗口文本哈希去重后再 rerank
  • 重排:把每个窗口的拼接文本作为 document,送 qwen3-rerank 打分(沿用现有 _qwen3_rerank,document 内容从 child 单块变成窗口拼接文本)
  • 输出:top 5 窗口(带 rerank 分)
  • 门控:top rerank 分 < 0.25 → 拒绝(error=rerank_low_score

四级 — 证据打包

  • 取 parent:对 top 5 窗口,每个取其所属 parent 作为证据
  • parent 去重:同 parent 的多个窗口只保留一份,取最高分
  • 排序:按窗口 rerank 分排序 parent
  • 输出:证据包(去重后的 parent 列表,最多 5 个)
  • 送 LLM:拼接 parent 文本送 DeepSeek 生成回答(沿用现有 _ANSWER_PROMPT

第 4 阶段:检索门控

门控条件 拒绝 error 码 拒绝行为
城市匹配 无文档 no_docs_matched 返回”未找到相关资料”
一级 BM25 召回数 < 5 bm25_low_recall 同上
二级 向量 top 余弦 < 0.30 vector_low_score 同上
三级 rerank top 分 < 0.25 rerank_low_score 同上

拒绝行为统一:设 metrics.answer_refused = True,记录 metrics.errormetrics.gate_failed_at,立即返回”未找到与您问题足够相关的旅行资料”提示,不进入下一级。

阈值校准:初始阈值如上,上线后通过 rag_eval.py 实测结果调整。

按城市懒预计算缓存

数据结构

1
2
3
4
5
6
7
8
@dataclass
class CityIndex:
city: str
parents: list[ParentChunk]
children: list[ChildChunk]
child_vectors: np.ndarray # (n_children, dim)
bm25: BM25Okapi # 建在 child 语料上
built_at: float # 构建时间戳

缓存管理

  • TravelRAG 新增实例属性:self._city_cache: dict[str, CityIndex]
  • 构建时机:某城市首次被查询时,_build_city_index(city, pdf_paths) 切块 + 编码所有 child 向量 + 建 BM25,存入缓存
  • 后续查询:命中缓存直接用,跳过 PDF 读取和切块,只需编码 query 向量
  • 多城市查询(如”上海去苏州和杭州”):每个匹配城市独立建/查 CityIndex 缓存。一级 BM25 在每个城市的缓存索引上各跑一次,各取 top 30,合并去重成一个候选池;二级起在合并池上操作。child 向量直接从各城市 child_vectors 汇总,无需重编码。这样缓存对多城市查询同样有效。
  • 模型共享embedding_model 仍是单例延迟加载,被所有城市共享

性能预期

场景 现有延迟 新延迟
首次查某城市 5-15s 10-20s(多切 child + 编码所有 child 向量)
复查同城市 5-15s(每次重算) 1-2s(仅 query 向量 + rerank API)

首次略慢是建立缓存的代价,复查大幅提速。

RAGMetrics 扩展

新增字段追踪新流程(现有字段保留):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@dataclass
class RAGMetrics:
# 现有字段保留(部分语义微调,见下)
question: str = ""
rewritten_query: str = ""
cities_detected: list[str] = field(default_factory=list)
docs_matched: int = 0
# bm25_candidates / vector_candidates / noise_removed 保留兼容现有评测脚本
bm25_candidates: int = 0 # = 一级输出(等同 bm25_recall,保留旧名兼容)
noise_removed: int = 0 # 新流程不再有独立噪声过滤级,恒为 0,保留字段兼容
vector_candidates: int = 0 # = 二级输出(等同 vector_recall,保留旧名兼容)
rerank_top_score: float = 0.0
rerank_lowest_score: float = 0.0
rerank_candidates: int = 0
reranker_used: str = "qwen3-rerank"
score_gate_passed: bool = False
answer_refused: bool = False
error: str = ""
# 新增
parent_count: int = 0
child_count: int = 0
bm25_recall: int = 0 # 一级输出(与 bm25_candidates 同值,新名更清晰)
vector_top_score: float = 0.0 # 二级 top 余弦分
vector_recall: int = 0 # 二级输出(与 vector_candidates 同值)
window_count: int = 0 # 三级展开的窗口数(去重后)
evidence_count: int = 0 # 四级证据包大小
gate_failed_at: str = "" # 哪级门控拒的(空=通过)
cache_hit: bool = False # 城市缓存是否命中

字段兼容说明bm25_candidates/vector_candidates/noise_removed 保留是为了不破坏现有 rag_eval.pyrag_eval_results.json 的字段名。新流程中 bm25_candidatesbm25_recall 同值,vector_candidatesvector_recall 同值,noise_removed 恒为 0(新流程无独立噪声过滤级,噪声过滤融入一级 BM25 之后的 child 质量由向量+rerank 保证)。

测试与评测

  • tests/rag_eval.py 沿用,重写前先跑存基线 JSON,重写后再跑对比
  • 新增字段全部进 rag_eval_results.json
  • 汇总打印新增:各级门控拒绝数、平均 vector top 分、缓存命中率
  • 评测脚本会自动 warm up(首次查某城市建缓存),复查查询能体现缓存收益

文件改动范围

文件 改动
app/rag/pipeline.py 重写 TravelRAG 内部;新增 ParentChunk/ChildChunk/CityIndex;新增两段切块、四级流水线、门控、城市缓存;扩展 RAGMetrics
tests/rag_eval.py 适配新 metrics 字段
docs/tech-rag.md 更新技术文档
docs/api-reference.md RAG 端点行为说明(接口不变,仅补门控说明)

接口签名零改动query() / query_with_metrics() / get_rag() 不变。

风险与缓解

风险 缓解
首次查某城市变慢(建缓存) 评测脚本 warm up;生产可预热常见城市
阈值定不准导致误拒 初始值偏保守,通过 rag_eval 实测调整
窗口拼接后文本过长超 reranker 限制 窗口 ~1500 字,qwen3-rerank 单 document 上限远大于此,安全
多城市查询缓存膨胀 128 本 PDF 总量有限,内存可承受;必要时加 LRU

成功标准

  • 知识库覆盖范围内查询命中率 ≥ 现有水平(100%)
  • 复查同城市延迟从 5-15s 降到 1-2s
  • 低质量/无效查询通过早期门控拒绝,不浪费 reranker API 调用
  • rag_eval.py 评测结果可对比基线