Initial commit: QMDSearch 分层信息检索服务
- FastAPI + Qdrant + Redis + Ollama 技术栈 - L1→L2→L3→chunk 四层分层检索(dense + sparse RRF 融合) - 文档三级总结与 2.5 级回退 - query 解析路由与分类 - /admin 管理页面
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
# Checklist
|
||||
|
||||
## 配置与任务管理器
|
||||
|
||||
- [x] config 含 ingest_max_concurrency(默认 2)/ingest_task_ttl_done(默认 86400)/ingest_task_ttl_failed(默认 604800),.env.example 同步
|
||||
- [x] submit 返回 task_id 且状态初始 pending;后台任务按信号量限流(超额保持 pending)
|
||||
- [x] 状态机按流水线阶段推进:pending→summarizing→classifying→embedding→writing→done
|
||||
- [x] 成功后 result 为完整 IngestionResult;失败 error 含 stage 与 message,partial_summary(已产出总结)保留
|
||||
- [x] 状态写 Redis:done TTL 24h、failed TTL 7 天;Redis 故障降级内存字典且接口不报错
|
||||
|
||||
## API
|
||||
|
||||
- [x] POST /api/v1/documents 合法请求返回 HTTP 202 + {task_id, status:"pending"},不再同步等待流水线
|
||||
- [x] POST 空文本等校验失败仍同步返回 code=1001(不进任务队列)
|
||||
- [x] GET /api/v1/documents/tasks/{task_id} 返回状态/时间戳;done 附 result;failed 附 error.stage/message
|
||||
- [x] 查询不存在 task_id 返回 code=1004
|
||||
- [x] 既有入库相关测试全部适配新契约且通过
|
||||
|
||||
## 管理页面
|
||||
|
||||
- [x] 入库提交后展示 task_id 与状态,2s 轮询直至 done/failed
|
||||
- [x] done 展示 category/置信度/tags/总结层级/chunks_count;failed 展示 stage 与 message
|
||||
- [x] 轮询期间禁止重复提交;5 分钟超时停止轮询并提示
|
||||
- [x] 页面测试覆盖 tasks 轮询路径与新交互标记
|
||||
|
||||
## 集成与文档
|
||||
|
||||
- [x] 内存闭环:提交→轮询 done→GET /documents/{doc_id} 可查→检索命中→删除 全通
|
||||
- [x] 失败路径:FakeOllama 抛错 → 任务 failed、stage 正确、failed TTL=7 天
|
||||
- [x] CLAUDE.md API 清单反映 POST /documents 202 异步与任务查询端点
|
||||
- [x] `uv run pytest` 全部通过;`uv run ruff check app tests scripts` 无错误
|
||||
@@ -0,0 +1,72 @@
|
||||
# 入库异步任务化 Spec
|
||||
|
||||
## Why
|
||||
|
||||
当前 `POST /api/v1/documents` 同步等待整条入库流水线(总结→分类→切分→向量化→写库),真实 Ollama 冒烟单篇耗时 39s+,长文档分钟级,客户端/网关易超时,批量导入不可用。改为后台任务模式:提交即返回 task_id,状态可查,失败任务长期保留便于归因。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **BREAKING**:`POST /api/v1/documents` 由同步返回 IngestionResult(200)改为立即返回 `{task_id, status}`(HTTP 202),流水线在后台 asyncio 任务中执行
|
||||
- **新增** `GET /api/v1/documents/tasks/{task_id}`:查询任务状态(pending/各阶段/done/failed)、完成后的 IngestionResult、失败时的 stage 与错误信息
|
||||
- **新增** `app/core/ingest_tasks.py` IngestTaskManager:提交登记 → 信号量限流并发 → 后台执行 → 状态机推进 → 结果/错误落储
|
||||
- **状态存储**:Redis 为主(key `ingest_task:{task_id}`,JSON);done TTL 24h,**failed TTL 7 天**(便于失败归因);Redis 不可用时降级进程内字典(重启丢失,日志告警),服务不因此拒绝入库
|
||||
- **管理页**:入库区块改为「提交 → 轮询任务状态 → 展示进度/结果/失败详情」
|
||||
- **配置新增**:`ingest_max_concurrency`(默认 2)、`ingest_task_ttl_done`(默认 86400s)、`ingest_task_ttl_failed`(默认 604800s,7 天)
|
||||
- CLAUDE.md API 清单同步更新(标注 POST /documents 行为变更)
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: 文档入库 API、管理页面、任务状态存储
|
||||
- Affected code:
|
||||
- 修改:[document.py](file:///Users/kplam/coding/QMDSearch/app/api/v1/document.py)(POST 改 202 + 新增任务查询端点)、[config.py](file:///Users/kplam/coding/QMDSearch/app/config.py)、[.env.example](file:///Users/kplam/coding/QMDSearch/.env.example)、[admin.html](file:///Users/kplam/coding/QMDSearch/app/static/admin.html)(入库区块轮询)、[CLAUDE.md](file:///Users/kplam/coding/QMDSearch/CLAUDE.md)
|
||||
- 新增:`app/core/ingest_tasks.py`、`tests/test_ingest_tasks.py`、`tests/test_ingest_task_api.py`
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 异步入库提交
|
||||
|
||||
系统 SHALL 将 `POST /api/v1/documents` 改为异步任务模式:校验通过后立即返回 HTTP 202 与 `{task_id, status: "pending"}`,入库流水线在后台任务执行;并发后台任务数受 `ingest_max_concurrency` 信号量限制,超限任务保持 pending 排队。请求体与校验规则(text 非空等)与现状一致,校验失败仍同步返回 1001。
|
||||
|
||||
#### Scenario: 提交即返回
|
||||
|
||||
- **WHEN** 提交合法文档
|
||||
- **THEN** 响应 202 + task_id,无需等待流水线完成;后台任务按并发额度开始执行
|
||||
|
||||
### Requirement: 任务状态查询
|
||||
|
||||
系统 SHALL 提供 `GET /api/v1/documents/tasks/{task_id}`:返回 `{task_id, status, created_at, updated_at}`;status 取值 `pending | summarizing | classifying | embedding | writing | done | failed`(与流水线阶段一致推进);done 时附 `result`(完整 IngestionResult:document_id/category/tags/总结层级/chunks_count);failed 时附 `error: {stage, message}`(stage 来自 IngestionError,已产出总结不丢失一并附在 error.partial_summary)。task_id 不存在返回 code=1004。
|
||||
|
||||
#### Scenario: 生命周期
|
||||
|
||||
- **WHEN** 提交后轮询 task_id
|
||||
- **THEN** 依次观察到 pending→阶段状态→done(result 可查,文档已可检索);失败任务观察到 failed + error.stage
|
||||
|
||||
### Requirement: 状态存储与保留
|
||||
|
||||
任务状态 SHALL 写入 Redis(`ingest_task:{task_id}`,JSON):done TTL=ingest_task_ttl_done(默认 24h),failed TTL=ingest_task_ttl_failed(默认 7 天),进行中状态 TTL 取 done 值。Redis 不可用时降级进程内字典并日志告警,入库与查询照常(重启后历史任务丢失可接受,接口语义不变)。
|
||||
|
||||
#### Scenario: Redis 宕机降级
|
||||
|
||||
- **WHEN** Redis 连接失败
|
||||
- **THEN** 提交与查询仍正常,仅日志 warning;进程重启后任务状态丢失
|
||||
|
||||
### Requirement: 管理页入库区块
|
||||
|
||||
管理页入库表单 SHALL 改为:提交后展示 task_id 与状态进度条/文本,每 2s 轮询任务接口,done 展示分类/标签/总结层级/chunks_count,failed 展示 error.stage 与 message;轮询期间禁止重复提交,超时(5 分钟)停止轮询并提示可稍后手动查询。
|
||||
|
||||
### Requirement: 并发控制
|
||||
|
||||
后台入库 SHALL 用 asyncio 信号量限制并发(默认 2),避免多任务同时打满 Ollama/embedding;排队任务状态保持 pending。
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: POST /api/v1/documents(行为变更)
|
||||
|
||||
原同步返回 IngestionResult 的行为废弃,改为 202 + task_id。所有既有调用方(管理页、既有测试)同步适配;响应体统一响应包装不变(`{code:0, data:{task_id,status}, message:"ok"}`,HTTP 状态码 202)。
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: 同步入库等待
|
||||
|
||||
**Reason**:长耗时流水线导致客户端超时、无法批量导入。
|
||||
**Migration**:调用方改为提交后轮询 `GET /api/v1/documents/tasks/{task_id}`;管理页已内置轮询。
|
||||
@@ -0,0 +1,18 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Task 1: 配置 + IngestTaskManager:config 新增 ingest_max_concurrency/ingest_task_ttl_done/ingest_task_ttl_failed 与 .env.example 同步;新建 app/core/ingest_tasks.py——submit(doc)→task_id、asyncio 信号量限流后台执行、状态机(pending→summarizing→classifying→embedding→writing→done/failed)、Redis 主存储(failed TTL 7 天)+ Redis 故障降级进程内字典、get(task_id) 查询;单测(FakeIngester 成功/失败/IngestionError.stage 透传/partial_summary 保留/并发限流/Redis 降级)
|
||||
- [x] SubTask 1.1: 配置项 + .env.example
|
||||
- [x] SubTask 1.2: IngestTaskManager 实现 + 单测
|
||||
- [x] Task 2: API 改造:POST /api/v1/documents 改 202 返回 {task_id,status}(校验失败仍同步 1001);新增 GET /api/v1/documents/tasks/{task_id}(不存在 1004);适配既有 test_document_api.py 与 test_e2e_integration.py 中入库断言为新契约;新增 tests/test_ingest_task_api.py
|
||||
- [x] Task 3: 管理页入库区块改造:提交→展示 task_id 与状态→2s 轮询→done 展示结果/failed 展示 stage+message→轮询期禁重复提交→5 分钟超时提示;更新 test_admin_page.py 断言(含 tasks 轮询路径)
|
||||
- [x] Task 4: 集成验证 + 文档:内存 Qdrant + FakeOllama 走通 提交→轮询至 done→文档可检索→删除 闭环;失败路径(FakeOllama 抛错)状态 failed 且 stage 正确;Redis 降级路径可用;CLAUDE.md API 清单更新(POST /documents 标注 202 异步、新增任务查询端点);`uv run pytest` 全绿 + ruff 通过
|
||||
|
||||
# Task Dependencies
|
||||
|
||||
- [Task 2] depends on [Task 1]
|
||||
- [Task 3] depends on [Task 2]
|
||||
- [Task 4] depends on [Task 3]
|
||||
|
||||
# Parallelizable
|
||||
|
||||
- 各任务串行依赖,无并行项
|
||||
@@ -0,0 +1,45 @@
|
||||
# Checklist
|
||||
|
||||
## 配置与模型
|
||||
|
||||
- [x] config 含 taxonomy 路径、分类置信度阈值、各层 top-k(l1/l2/l3)、sparse 开关、缓存 TTL,.env.example 同步更新
|
||||
- [x] taxonomy 默认配置文件存在且含 uncategorized 兜底类,加载校验函数可用
|
||||
- [x] CategoryResult(主类+多标签+置信度)、ChunkModel、SearchRequest/SearchResponse/SearchHit 模型齐备且有类型注解
|
||||
|
||||
## 入库链路
|
||||
|
||||
- [x] L2 大纲优先使用文档原生标题树,无结构文本回退 LLM 生成,2.5 级回退逻辑不受影响
|
||||
- [x] 分类器输出主类+多标签+置信度,低置信文档归入 uncategorized,不再硬编码 default
|
||||
- [x] chunk 按标题树切分并记录 section_path,超长 section 二次切分,短文本单 chunk
|
||||
- [x] 入库后 Qdrant 四层集合均有数据:doc_l1/doc_l2/doc_l3/chunks,payload 含 doc_id/category/tags/section_path
|
||||
- [x] chunks 与 doc_l1 同时携带 dense 与 sparse 向量,sparse 由本地分词+BM25 生成、无外部模型依赖
|
||||
- [x] 入库失败时返回明确错误码,已生成总结不丢失(可重试)
|
||||
|
||||
## 检索链路
|
||||
|
||||
- [x] query 解析以 JSON 约束输出意图类目+置信度+rewrite+关键词,解析失败自动降级全库检索
|
||||
- [x] 分类路由:高置信按主类硬过滤,多标签软召回生效,低置信/超类目上限走全库兜底
|
||||
- [x] 三级文档检索沿 L1→L2→L3→chunk 逐层收敛,chunk 候选仅来自 L3 命中范围
|
||||
- [x] 2.5 级文档 L1 命中后直进 L3/chunk 层
|
||||
- [x] L2/L3 空召回时回退上一层范围直搜 chunk,不返回空结果
|
||||
- [x] chunk 层 dense+sparse 双路召回经 RRF 融合,返回 final_k 个结果
|
||||
- [x] 检索结果 text 字段为原文 chunk,摘要仅以 doc_summary 上下文标注出现
|
||||
- [x] Redis 缓存命中时重复 query 不重复调用 Ollama/Qdrant;Redis 宕机检索仍可用
|
||||
|
||||
## API
|
||||
|
||||
- [x] POST /api/v1/documents 入库成功返回 document_id、分类结果、总结层级
|
||||
- [x] POST /api/v1/search 返回统一格式 {"code":0,"data":...,"message":"ok"}
|
||||
- [x] GET /api/v1/knowledge/categories 返回 taxonomy 类目列表
|
||||
- [x] 错误码符合 0=成功、1xxx=客户端错误、2xxx=服务端错误规范
|
||||
|
||||
## 评测
|
||||
|
||||
- [x] 回归集样例 ≥5 篇文档,每篇含应检出/不应检出 query 及 golden 标注
|
||||
- [x] 评测脚本输出 Entity Recall(L1/L3)、Hallucination Rate、Routing F1、Pruning Loss、Precision@5/Recall@10
|
||||
- [x] 报告含平铺 chunk baseline 对比,未达门槛项显式标出
|
||||
|
||||
## 端到端
|
||||
|
||||
- [x] docker compose 环境下 入库→检索→评测 全链路跑通(本机以内存 Qdrant 集成测试 + 真实 Ollama 冒烟等效验证,NAS docker 部署待用户侧执行)
|
||||
- [x] `uv run pytest` 全部通过
|
||||
@@ -0,0 +1,144 @@
|
||||
# 分层摘要索引 + 分类路由检索全链路 Spec
|
||||
|
||||
## Why
|
||||
|
||||
当前 QMDSearch 仅实现了三级总结器(summarizer),入库链路的分类/向量化/写入为 TODO,检索侧整体缺失,无法兑现 CLAUDE.md 中「面向 AI Agent 的分层信息检索服务」定位。本变更按「分层预摘要索引(Hierarchical Summarization Index)+ 分类路由 + 自顶向下剪枝检索」架构补齐全链路:离线侧把 Agentic RAG 中 router/planner 需要的结构信息预先算好,在线侧用「分类过滤 + 摘要树逐层剪枝 + hybrid 检索」实现大规模知识库下又省又准的检索,并附评测脚本骨架守护摘要质量与剪枝召回。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **入库链路补全**:taxonomy 驱动的分类判定(主类 + 多标签 + 置信度,`uncategorized` 兜底)、dense + sparse(BM25)双向量生成、Qdrant 四层集合写入(L1 总结 / L2 大纲节点 / L3 内容大纲节点 / 原文 chunk)
|
||||
- **summarizer 改造**:L2 大纲优先解析文档原生标题树(Markdown 标题/编号标题),无结构文本回退 LLM 生成;新增 chunk 切分器,chunk 关联所属 section 路径
|
||||
- **分层检索引擎(新增)**:query 解析(Ollama 小模型 JSON 约束输出:意图分类 + query rewrite + 关键词)→ 分类路由(主类硬过滤 + 多标签软召回 + 低置信全库兜底)→ L1→L2→L3 摘要树逐层剪枝 → chunk 层 dense+sparse hybrid 召回(RRF 融合)→ 重排返回原文 chunk
|
||||
- **检索边界**:摘要仅用于路由与上下文标注,返回给调用方 Agent 的答案语料只含原文 chunk(防摘要幻觉进入生成)
|
||||
- **降级路径**:分类低置信跳过路由;任一层召回为空回退上一层范围直搜 chunk;2.5 级文档 L1 命中后直进 L3/chunk 层
|
||||
- **API(新增)**:`POST /api/v1/documents` 入库、`POST /api/v1/search` 分层检索、`GET /api/v1/knowledge/categories` 类目查询
|
||||
- **Redis 缓存**:query 解析结果与检索结果缓存(短 TTL)
|
||||
- **评测脚本骨架(新增)**:离线回归集 + Entity Recall / Hallucination Rate / Routing F1 / Pruning Loss / Precision@5 指标,支持与平铺 chunk baseline 对比
|
||||
|
||||
**存储设计说明**:不按类目建集合,采用全局四层集合 + category payload 索引过滤(Qdrant keyword payload index)。理由:支持多标签软召回与 uncategorized 兜底跨类检索,taxonomy 调整无需迁库;此设计取代 CLAUDE.md 中「按分类映射写入对应集合」的设想(该设想从未实现,非破坏性变更)。
|
||||
|
||||
**命名对齐**:用户方案中 L0/L1/L2 对应代码库既有命名 L1 总结 / L2 大纲 / L3 内容大纲(+ L2.5 回退),本 spec 沿用代码库命名。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: 文档入库(ingestion)、文档三级总结(summarizer)、分层检索(新增)、评测(新增)
|
||||
- Affected code:
|
||||
- 修改:[summarizer.py](file:///Users/kplam/coding/QMDSearch/app/core/summarizer.py)、[ingestion.py](file:///Users/kplam/coding/QMDSearch/app/core/ingestion.py)、[document.py](file:///Users/kplam/coding/QMDSearch/app/models/document.py)、[config.py](file:///Users/kplam/coding/QMDSearch/app/config.py)、[main.py](file:///Users/kplam/coding/QMDSearch/app/main.py)
|
||||
- 新增:`app/core/embeddings.py`、`app/core/sparse.py`、`app/core/chunker.py`、`app/core/classifier.py`、`app/core/retriever.py`、`app/core/ranker.py`、`app/core/query_parser.py`、`app/services/qdrant.py`、`app/services/redis.py`、`app/models/search.py`、`app/models/knowledge.py`、`app/api/v1/document.py`、`app/api/v1/search.py`、`app/api/v1/knowledge.py`、`scripts/eval/`(评测脚本与回归集样例)
|
||||
- 基础设施:Qdrant 集合初始化(4 集合 + payload 索引 + sparse 配置),无需新增容器
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: taxonomy 分类判定
|
||||
|
||||
系统 SHALL 提供可配置的类目体系(taxonomy),入库时基于 L1 总结对文档分类,输出主类、多标签与置信度;置信度低于阈值时归入 `uncategorized` 兜底类。taxonomy 通过配置文件定义,支持环境变量指定路径,未配置时使用内置默认类目集。
|
||||
|
||||
#### Scenario: 正常分类
|
||||
|
||||
- **WHEN** 入库一篇内容明确的文档
|
||||
- **THEN** 返回 `main_category`(主类)、`tags`(0~3 个附加标签)、`confidence`(0~1),且主类属于 taxonomy 已定义类目
|
||||
|
||||
#### Scenario: 低置信兜底
|
||||
|
||||
- **WHEN** 文档内容跨类目或无法明确归类(分类置信度低于阈值)
|
||||
- **THEN** `main_category` 为 `uncategorized`,多标签保留候选类目,检索时该文档仅在全库兜底通道与标签软召回中可见
|
||||
|
||||
### Requirement: 双向量索引(dense + sparse)
|
||||
|
||||
系统 SHALL 为 L1 总结、L2 大纲节点、L3 内容大纲节点、原文 chunk 生成 dense 向量;并至少为原文 chunk 与 L1 总结生成 sparse(BM25)向量。dense 向量提供方可配置(openai / local),sparse 向量使用本地分词 + 特征哈希 BM25 实现,不依赖外部模型下载。
|
||||
|
||||
#### Scenario: 四层集合写入
|
||||
|
||||
- **WHEN** 文档完成总结与分类
|
||||
- **THEN** L1/L2/L3 各层节点与原文 chunk 分别写入对应集合,每条数据携带 `doc_id`、`category`、`tags`、`section_path`(chunk/L2/L3 层)payload,chunk 同时携带 dense 与 sparse 向量
|
||||
|
||||
### Requirement: chunk 切分与 section 关联
|
||||
|
||||
系统 SHALL 将原文按结构(标题树)优先、长度兜底切分为 chunk,每个 chunk 记录所属 section 路径,与 L2/L3 节点可互相定位。
|
||||
|
||||
#### Scenario: 结构化文档切分
|
||||
|
||||
- **WHEN** 文档含标题结构
|
||||
- **THEN** chunk 按 section 边界切分,`section_path` 与 L2 大纲节点对应;超长 section 内部按长度二次切分
|
||||
|
||||
### Requirement: query 解析与分类路由
|
||||
|
||||
系统 SHALL 在检索前用 Ollama 小模型以 JSON 约束输出解析 query:意图命中的类目集合 + 置信度、rewrite 后 query、关键词。解析失败或分类置信度低时 SHALL 跳过路由进入全库检索。
|
||||
|
||||
#### Scenario: 明确意图路由
|
||||
|
||||
- **WHEN** query 意图明确命中 1~2 个类目且置信度达标
|
||||
- **THEN** 后续检索仅在命中类目 payload 过滤范围内进行
|
||||
|
||||
#### Scenario: 跨类/模糊兜底
|
||||
|
||||
- **WHEN** 分类置信度低于阈值或命中类目数超过上限
|
||||
- **THEN** 放弃类目过滤,全库检索(不丢召回)
|
||||
|
||||
### Requirement: 自顶向下摘要树剪枝检索
|
||||
|
||||
系统 SHALL 按 L1→L2→L3→chunk 顺序逐层缩小检索范围:L1 层检索落候选文档(top-N),候选文档内检索 L2 落候选 section,候选 section 内检索 L3 定位 chunk 范围,最终在范围内 hybrid 检索 chunk。2.5 级文档无 L2 层,L1 命中后 SHALL 直进 L3/chunk 层。任一层召回为空时 SHALL 回退到上一层范围直接检索 chunk。
|
||||
|
||||
#### Scenario: 三级文档逐层命中
|
||||
|
||||
- **WHEN** query 在路由范围内有匹配文档
|
||||
- **THEN** 检索沿 摘要树逐层收敛,最终 chunk 候选仅来自 L3 命中的 section 范围
|
||||
|
||||
#### Scenario: 剪枝为空回退
|
||||
|
||||
- **WHEN** L2 或 L3 层在候选范围内召回为空
|
||||
- **THEN** 回退到上一层候选文档范围直接 hybrid 检索 chunk,不返回空结果
|
||||
|
||||
### Requirement: hybrid 召回与重排
|
||||
|
||||
chunk 层 SHALL 同时执行 dense 与 sparse 检索,使用 RRF 融合排序,按 `retrieval_top_k` 召回、`retrieval_final_k` 返回。
|
||||
|
||||
#### Scenario: RRF 融合
|
||||
|
||||
- **WHEN** chunk 层执行检索
|
||||
- **THEN** dense 与 sparse 两路结果经 RRF 融合后排序,返回 final_k 个原文 chunk
|
||||
|
||||
### Requirement: 检索结果只含原文
|
||||
|
||||
检索 API SHALL 仅返回原文 chunk 及引用元数据(doc_id、标题、section_path、score、所属文档 L1 总结作为上下文标注),摘要不作为可答题语料返回字段的主体。
|
||||
|
||||
#### Scenario: 结果结构
|
||||
|
||||
- **WHEN** 检索成功
|
||||
- **THEN** 每个 hit 含 `text`(原文)、`doc_id`、`title`、`section_path`、`score`、`doc_summary`(L1,仅上下文标注)
|
||||
|
||||
### Requirement: 检索缓存
|
||||
|
||||
系统 SHALL 使用 Redis 缓存 query 解析结果与检索结果(短 TTL,可配置),缓存键包含 query 文本与路由类目;缓存不可用时不影响主流程。
|
||||
|
||||
### Requirement: 入库与检索 API
|
||||
|
||||
系统 SHALL 提供 `POST /api/v1/documents`(入库,返回 document_id/分类/总结层级)、`POST /api/v1/search`(分层检索)、`GET /api/v1/knowledge/categories`(taxonomy 类目列表),统一响应格式 `{"code": 0, "data": {...}, "message": "ok"}`,错误码 0=成功、1xxx=客户端错误、2xxx=服务端错误。
|
||||
|
||||
### Requirement: 评测脚本骨架
|
||||
|
||||
系统 SHALL 提供离线评测脚本:回归集格式(文档 + golden query 含应检出/不应检出 + golden doc/section/chunk 标注),实现 Entity Recall、Hallucination Rate(LLM-as-judge 反查)、Routing F1、Pruning Loss、Precision@5 / Recall@10 指标,并支持同一 query 集对「平铺 chunk baseline」与本方案做 A/B 对比输出报告。
|
||||
|
||||
#### Scenario: 摘要质量门禁
|
||||
|
||||
- **WHEN** 对回归集运行评测脚本
|
||||
- **THEN** 输出各指标:L1 Entity Recall ≥ 0.85、L3 ≥ 0.9、Hallucination Rate < 2%、Pruning Loss < 8% 作为参考门槛,不达标项在报告中显式标红
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: L2 大纲生成(原 LLM 自由生成)
|
||||
|
||||
L2 大纲 SHALL 优先解析文档原生标题树(Markdown `#`/`##`/`###` 及常见编号标题模式),直接以标题层级作为大纲节点;仅当文档无可解析结构时回退为 LLM 生成大纲。L2 限定为「结构导航」,不写入结论性压缩语句,避免与 L1 总结语义重叠。原文无标题结构且为短文本时仍走既有 2.5 级回退。
|
||||
|
||||
**理由**:用户方案评审结论——LLM 自由生成的三级粒度易出现层级语义重叠、检索两头命中同一段;原生标题树更稳且零成本。
|
||||
|
||||
### Requirement: 分类判定(原 stub 返回 "default")
|
||||
|
||||
原 `Ingester._classify` 占位实现 SHALL 替换为独立 classifier 模块:基于 L1 总结调用 Ollama 小模型 JSON 约束输出主类 + 多标签 + 置信度,对齐 taxonomy;不再硬编码返回 `default`。
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: 按分类映射写入对应集合(CLAUDE.md 设想)
|
||||
|
||||
**Reason**:固定 taxonomy 的按类目分集合会使新业务线/跨类目问题被路由砍死,且多标签软召回与 uncategorized 兜底无法跨集合实现;taxonomy 变更需迁库。
|
||||
**Migration**:从未实现,无需迁移;以全局四层集合 + category payload 索引过滤替代(见存储设计说明)。
|
||||
@@ -0,0 +1,54 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Task 1: 扩展配置与数据模型:config 增加 taxonomy/各层 top-k/sparse 开关/缓存 TTL 等参数;新建 `app/models/knowledge.py`(TaxonomyCategory、CategoryResult)与 `app/models/search.py`(SearchRequest/SearchResponse/SearchHit);`app/models/document.py` 增加 ChunkModel(doc_id、text、section_path、chunk_index)
|
||||
- [x] SubTask 1.1: config.py 增加 taxonomy_path、classify_confidence_threshold、l1_doc_top_n、l2_section_top_n、l3_top_n、sparse_enabled、cache_ttl 等配置项,同步更新 .env.example
|
||||
- [x] SubTask 1.2: 新建 taxonomy 默认配置文件(内置通用类目集 + uncategorized),提供加载与校验函数
|
||||
- [x] SubTask 1.3: 新建 knowledge.py / search.py 模型,document.py 增加 ChunkModel 与 CategoryResult 引用
|
||||
- [x] Task 2: 向量服务:`app/core/embeddings.py`(dense,openai | local 双 provider 统一异步接口,批量编码)+ `app/core/sparse.py`(本地分词 + 特征哈希 BM25 稀疏向量,输出 Qdrant sparse vector 格式,不依赖外部模型)
|
||||
- [x] SubTask 2.1: EmbeddingService 抽象 + openai provider 实现 + local provider(Ollama embedding 接口)实现
|
||||
- [x] SubTask 2.2: SparseEncoder(中文分词 + BM25 权重 + 特征哈希到固定维度),单测验证输出稀疏格式合法
|
||||
- [x] Task 3: Qdrant 服务封装 `app/services/qdrant.py`:启动时初始化 4 个集合(doc_l1 / doc_l2 / doc_l3 / chunks,chunks 与 doc_l1 带 sparse 向量配置),建立 category/tags/doc_id/section_path payload 索引;提供 upsert 与按层检索(支持 payload 过滤 + dense/sparse/hybrid 查询)接口
|
||||
- [x] SubTask 3.1: 集合初始化与 payload 索引(幂等,应用启动时执行)
|
||||
- [x] SubTask 3.2: upsert 接口(按层写入,chunk 携带 dense+sparse)
|
||||
- [x] SubTask 3.3: 分层查询接口(dense 过滤查询 / hybrid 查询)
|
||||
- [x] Task 4: summarizer 改造 + chunk 切分器:L2 大纲优先解析原生标题树(Markdown 标题 + 编号标题正则),无结构回退现有 LLM 生成;新建 `app/core/chunker.py` 按标题树切分 chunk(超长 section 按长度二次切分,短文本整篇单 chunk),chunk 记录 section_path 与 L2 节点对应关系
|
||||
- [x] SubTask 4.1: 标题树解析器(Markdown ATX 标题 + 中文编号标题),输出 section 树
|
||||
- [x] SubTask 4.2: summarizer 接入标题树:有结构时 L2 直接用标题树生成大纲文本,无结构走原 prompt;L3 保持不变
|
||||
- [x] SubTask 4.3: Chunker 实现与单测(结构化/非结构化/短文本三种输入)
|
||||
- [x] Task 5: 分类器 + ingestion 全链路补全:`app/core/classifier.py`(基于 L1 总结,Ollama JSON 约束输出主类+多标签+置信度,低置信归 uncategorized);改造 `app/core/ingestion.py` 串起 总结→分类→chunk 切分→双向量化→Qdrant 写入,返回真实 document_id 与集合信息
|
||||
- [x] SubTask 5.1: Classifier 实现(taxonomy 注入 prompt,JSON 解析容错,置信度阈值判兜底)
|
||||
- [x] SubTask 5.2: Ingester 全链路串联(总结→分类→切 chunk→embedding→sparse→upsert 四层)
|
||||
- [x] SubTask 5.3: 入库失败处理:Qdrant 写入失败不丢已生成总结,返回明确错误码与可重试标识
|
||||
- [x] Task 6: 入库与类目 API:`app/api/v1/document.py`(POST /api/v1/documents)、`app/api/v1/knowledge.py`(GET /api/v1/knowledge/categories),统一响应包装与错误码,main.py 注册路由
|
||||
- [x] Task 7: query 解析与分类路由:`app/core/query_parser.py`(Ollama 小模型 JSON 约束输出:命中类目+置信度、rewrite query、关键词;解析失败/低置信/命中过多类目→全库兜底)
|
||||
- [x] SubTask 7.1: QueryParser 实现与 JSON 容错解析
|
||||
- [x] SubTask 7.2: 路由决策函数(主类硬过滤 + 多标签软召回合并为 Qdrant payload filter + 兜底判定),单测覆盖三类分支
|
||||
- [x] Task 8: 分层检索引擎 + 重排 + 检索 API:`app/core/retriever.py`(L1→L2→L3→chunk 逐层剪枝,2.5 级文档跳过 L2,空召回回退上一层直搜 chunk)+ `app/core/ranker.py`(RRF 融合 + final_k 截断)+ `app/api/v1/search.py`(POST /api/v1/search)
|
||||
- [x] SubTask 8.1: Retriever 逐层 drill-down 主流程
|
||||
- [x] SubTask 8.2: 降级路径(2.5 级跳层、空召回回退、路由兜底直通)
|
||||
- [x] SubTask 8.3: Ranker RRF 融合与单测
|
||||
- [x] SubTask 8.4: 检索 API 与统一响应
|
||||
- [x] Task 9: Redis 缓存 `app/services/redis.py`:query 解析结果与检索结果缓存(短 TTL 可配置),缓存键含 query 与路由类目;Redis 不可用时降级直连不报错
|
||||
- [x] Task 10: 评测脚本骨架 `scripts/eval/`:回归集 JSON 格式定义与样例(≥5 篇文档、每篇 3~5 条应检出 query + 1~2 条不应检出 query,标注 golden doc/section/chunk);指标实现 Entity Recall、Routing F1、Pruning Loss、Precision@5/Recall@10;Hallucination Rate 用 Ollama LLM-as-judge 反查;支持平铺 chunk baseline 对比并输出 Markdown 报告(含门槛标红:L1 Entity Recall≥0.85、L3≥0.9、幻觉率<2%、Pruning Loss<8%)
|
||||
- [x] SubTask 10.1: 回归集 schema + 样例数据
|
||||
- [x] SubTask 10.2: 摘要质量指标(Entity Recall / Hallucination Rate)
|
||||
- [x] SubTask 10.3: 检索效用指标(Routing F1 / Pruning Loss / Precision@5)+ baseline 对比报告
|
||||
- [x] Task 11: 端到端验证:docker compose 起 Qdrant/Redis/Ollama,走通 入库→检索→评测 全链路;`uv run pytest` 全绿;修复发现的问题
|
||||
|
||||
# Task Dependencies
|
||||
|
||||
- [Task 2] depends on [Task 1]
|
||||
- [Task 3] depends on [Task 1]
|
||||
- [Task 4] depends on [Task 1]
|
||||
- [Task 5] depends on [Task 2, Task 3, Task 4]
|
||||
- [Task 6] depends on [Task 5]
|
||||
- [Task 7] depends on [Task 1]
|
||||
- [Task 8] depends on [Task 3, Task 7]
|
||||
- [Task 9] depends on [Task 7, Task 8]
|
||||
- [Task 10] depends on [Task 5, Task 8]
|
||||
- [Task 11] depends on [Task 6, Task 9, Task 10]
|
||||
|
||||
# Parallelizable
|
||||
|
||||
- Task 2 / Task 3 / Task 4 / Task 7 互相独立,可并行
|
||||
- Task 6 与 Task 8 可并行(依赖均已满足后)
|
||||
@@ -0,0 +1,41 @@
|
||||
# Checklist
|
||||
|
||||
## 文档管理 API
|
||||
|
||||
- [x] GET /api/v1/documents 分页返回 items(doc_id/title/category/tags/summary)与 next_offset,空库返回空列表
|
||||
- [x] GET /api/v1/documents/{doc_id} 返回 L1 全部字段 + l2_nodes/l3_nodes + chunks_count
|
||||
- [x] 不存在 doc_id 详情返回 code=1004
|
||||
- [x] DELETE /api/v1/documents/{doc_id} 后四层集合该文档点全部清除,再查详情返回 1004
|
||||
- [x] DELETE 不存在 doc_id 幂等成功且 deleted 统计为 0
|
||||
|
||||
## 统计 API
|
||||
|
||||
- [x] GET /api/v1/knowledge/stats 返回四层集合点数、类目分布、uncategorized_count
|
||||
- [x] stats 数值与内存 Qdrant 实际写入一致(测试验证)
|
||||
|
||||
## 管理页面
|
||||
|
||||
- [x] GET /admin 返回 200 HTML
|
||||
- [x] 页面含 概览/文档管理/入库/检索测试台/类目 五个区块
|
||||
- [x] 页面无外部 CDN/外链资源(无 http(s):// src 或 link)
|
||||
- [x] 所有 fetch 指向 /api/v1/ 路径且与后端路由契约一致(路径、方法、字段名)
|
||||
- [x] 删除操作有二次确认逻辑(confirm 或等价交互)
|
||||
- [x] code≠0 时页面有错误提示处理
|
||||
|
||||
## 单测补全
|
||||
|
||||
- [x] judge.py:正常幻觉率计算、部分断言不支持、解析失败返回 0.0
|
||||
- [x] response.py:ok/error/ApiError 结构与 code
|
||||
- [x] ollama.py:generate 正常/json_mode payload 含 format:is_available 可达与异常分支
|
||||
- [x] run_eval.py:门槛判定(✅/❌)与数值格式化纯函数
|
||||
|
||||
## 部署与文档
|
||||
|
||||
- [x] Dockerfile 含 COPY scripts/ scripts/
|
||||
- [x] CLAUDE.md 项目结构、API 清单(含 documents 管理端点/stats//admin)与实际一致
|
||||
|
||||
## 集成
|
||||
|
||||
- [x] 内存 Qdrant 管理闭环冒烟通过:入库→列表→详情→检索→删除→统计
|
||||
- [x] `uv run pytest` 全部通过(含新增测试)
|
||||
- [x] `uv run ruff check app tests scripts` 无错误
|
||||
@@ -0,0 +1,83 @@
|
||||
# 项目完整性补全:管理 API + 管理页面 + 单测补全 Spec
|
||||
|
||||
## Why
|
||||
|
||||
分层 RAG 全链路已验收(149 测试全绿),但系统对外闭环不完整:已入库文档无法查看/管理(无列表/详情/删除 API),运维人员无 UI 可手工验证检索与入库,judge/response/ollama 客户端等模块无单测覆盖,Dockerfile 未打包评测脚本,CLAUDE.md 项目结构已过时。本变更补齐管理闭环与测试覆盖,使系统达到可运维状态。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **文档管理 API(新增)**:`GET /api/v1/documents`(分页列表,源自 doc_l1 scroll)、`GET /api/v1/documents/{doc_id}`(详情:L1 总结 + L2/L3 节点 + chunk 数)、`DELETE /api/v1/documents/{doc_id}`(按 doc_id 过滤删除四层集合所有点,幂等)
|
||||
- **统计 API(新增)**:`GET /api/v1/knowledge/stats`(四层集合点数、类目分布、uncategorized 数量)
|
||||
- **管理页面(新增)**:FastAPI 托管静态单页 `/admin`,原生 JS + fetch 单文件实现(无 Node 构建链、镜像零新增依赖),含 概览 / 文档管理 / 文档入库 / 检索测试台 / 类目列表 五个区块;删除操作前端二次确认
|
||||
- **单元测试补全**:scripts/eval/judge.py、app/api/response.py、app/services/ollama.py(HTTP 行为)、scripts/eval/run_eval.py 可测纯函数、新增管理 API 端点测试、/admin 页面存在性测试
|
||||
- **完整性修补**:Dockerfile 增加 `COPY scripts/ scripts/`;CLAUDE.md 项目结构与 API 清单更新至当前实现
|
||||
|
||||
无破坏性变更(现有 API 路径与响应结构不变,仅新增)。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: 文档管理(新增)、统计概览(新增)、管理页面(新增)、测试覆盖、部署
|
||||
- Affected code:
|
||||
- 修改:[qdrant.py](file:///Users/kplam/coding/QMDSearch/app/services/qdrant.py)(scroll/delete/count 管理操作)、[main.py](file:///Users/kplam/coding/QMDSearch/app/main.py)(挂载 /admin 静态页)、[document.py](file:///Users/kplam/coding/QMDSearch/app/api/v1/document.py)(新增端点)、[knowledge.py](file:///Users/kplam/coding/QMDSearch/app/api/v1/knowledge.py)(stats)、[Dockerfile](file:///Users/kplam/coding/QMDSearch/Dockerfile)、[CLAUDE.md](file:///Users/kplam/coding/QMDSearch/CLAUDE.md)
|
||||
- 新增:`app/static/admin.html`、`tests/test_judge.py`、`tests/test_response.py`、`tests/test_ollama_client.py`、`tests/test_run_eval.py`、`tests/test_document_admin_api.py`、`tests/test_admin_page.py`
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 文档列表与详情
|
||||
|
||||
系统 SHALL 提供 `GET /api/v1/documents`:基于 doc_l1 集合 scroll 分页返回文档摘要列表(doc_id、title、category、tags、总结层级推断、L1 总结摘要),支持 `limit`/`offset` 游标分页;并提供 `GET /api/v1/documents/{doc_id}` 返回单文档详情:L1 payload 全部字段、L2/L3 节点列表、chunk 数量。doc_id 不存在时返回 code=1004。
|
||||
|
||||
#### Scenario: 分页列表
|
||||
|
||||
- **WHEN** 请求 `GET /api/v1/documents?limit=20`
|
||||
- **THEN** 返回 code=0,data 含 items(每项 doc_id/title/category/tags/summary)与 next_offset(无更多为 null)
|
||||
|
||||
#### Scenario: 详情与不存在
|
||||
|
||||
- **WHEN** 请求已入库 doc_id 的详情
|
||||
- **THEN** 返回 L1 字段 + l2_nodes/l3_nodes 列表 + chunks_count;请求不存在的 doc_id 返回 code=1004
|
||||
|
||||
### Requirement: 文档删除
|
||||
|
||||
系统 SHALL 提供 `DELETE /api/v1/documents/{doc_id}`:按 doc_id payload 过滤删除 doc_l1/doc_l2/doc_l3/chunks 四个集合中的全部点;删除不存在 doc_id 幂等返回成功(deleted_points=0);返回各集合删除点数统计。
|
||||
|
||||
#### Scenario: 删除已入库文档
|
||||
|
||||
- **WHEN** 对已入库 doc_id 执行 DELETE
|
||||
- **THEN** 四层集合该 doc_id 的点全部被清除,再次 GET 详情返回 1004
|
||||
|
||||
### Requirement: 统计概览
|
||||
|
||||
系统 SHALL 提供 `GET /api/v1/knowledge/stats`:返回四层集合各自点数、按 category 的文档分布、uncategorized 文档数。统计基于 Qdrant count 与 doc_l1 轻量 scroll 聚合(仅取 category 字段),属管理端低频接口。
|
||||
|
||||
#### Scenario: 概览数据
|
||||
|
||||
- **WHEN** 请求 stats
|
||||
- **THEN** data 含 collections(四层点数)、categories(类目→文档数)、uncategorized_count
|
||||
|
||||
### Requirement: 管理页面
|
||||
|
||||
系统 SHALL 在 `/admin` 提供静态单页管理界面(单 HTML 文件,内联 CSS/JS,无外部 CDN 依赖),包含五个区块:概览(stats 展示)、文档管理(列表 + 详情查看 + 删除,删除需二次确认)、文档入库(表单提交 text/title/source,展示分类与总结结果)、检索测试台(输入 query 展示 hits/routed_categories/fallback)、类目列表。页面调用同-origin `/api/v1/*`,统一处理 code≠0 的错误提示。
|
||||
|
||||
#### Scenario: 页面可用性
|
||||
|
||||
- **WHEN** 浏览器访问 `/admin`
|
||||
- **THEN** 返回 200 HTML,包含五个功能区块与全部 fetch 调用指向 `/api/v1/` 路径,无外部资源引用
|
||||
|
||||
### Requirement: 单元测试补全
|
||||
|
||||
系统 SHALL 为以下模块补齐单测:judge.py(mock Ollama:断言抽取/支持判定/解析失败返回 0.0)、response.py(ok/error/ApiError 结构)、ollama.py(mock httpx:generate/json_mode payload/is_available 可达与异常分支)、run_eval.py 可测纯函数(格式化与门槛判定)、新增管理 API 与 /admin 页面存在性(TestClient 200 + 关键区块标记)。
|
||||
|
||||
### Requirement: 部署与文档完整性
|
||||
|
||||
Dockerfile SHALL 打包 scripts/ 目录(镜像内可执行评测);CLAUDE.md SHALL 更新项目结构、API 清单与管理页面说明至当前实现。
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: QdrantService(新增管理操作)
|
||||
|
||||
QdrantService SHALL 新增:`scroll_l1(limit, offset) -> (items, next_offset)`(仅取列表所需 payload 字段)、`get_doc_detail(doc_id) -> dict | None`(L1 + L2/L3 节点 + chunk 计数)、`delete_by_doc_id(doc_id) -> dict[str, int]`(四集合按过滤删除,返回各集合删除数)、`count(collection) -> int`。均复用现有客户端与集合常量,不改变既有 upsert/查询行为。
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
无。
|
||||
@@ -0,0 +1,24 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Task 1: QdrantService 管理操作扩展:scroll_l1 分页(仅取 doc_id/title/category/tags/text/level 所需字段)、get_doc_detail(L1+L2/L3 节点+chunk 计数)、delete_by_doc_id(四集合过滤删除返回各集合删除数)、count;内存 Qdrant 单测覆盖
|
||||
- [x] SubTask 1.1: scroll_l1 与 count 实现 + 单测(分页游标、空集合)
|
||||
- [x] SubTask 1.2: get_doc_detail 与 delete_by_doc_id 实现 + 单测(存在/不存在/删除后四层清空)
|
||||
- [x] Task 2: 文档管理 API:`GET /api/v1/documents`(limit/offset 分页)、`GET /api/v1/documents/{doc_id}`(详情,不存在 code=1004)、`DELETE /api/v1/documents/{doc_id}`(幂等,返回各集合删除数);沿用统一响应与懒加载单例模式;TestClient 测试(mock QdrantService)
|
||||
- [x] Task 3: 统计 API:`GET /api/v1/knowledge/stats`(四层点数 + 类目分布 + uncategorized 数);TestClient 测试
|
||||
- [x] Task 4: 管理页面 `app/static/admin.html` 单文件(内联 CSS/JS,零外部依赖)+ main.py 挂载 /admin:概览/文档管理(列表/详情/删除二次确认)/入库表单/检索测试台/类目列表五区块,统一 code≠0 错误提示;TestClient 存在性测试(200 + 五区块标记 + 无 http(s) 外链资源)
|
||||
- [x] Task 5: 单测补全:tests/test_judge.py(mock Ollama:正常判定/部分断言不支持/解析失败返回 0.0)、tests/test_response.py、tests/test_ollama_client.py(mock httpx:generate/json_mode/is_available 分支)、tests/test_run_eval.py(门槛判定与格式化纯函数)
|
||||
- [x] Task 6: 部署与文档修补:Dockerfile 增加 `COPY scripts/ scripts/`;CLAUDE.md 更新项目结构(scripts/eval、static)、API 清单(documents 管理端点、stats、/admin)与管理页面使用说明
|
||||
- [x] Task 7: 集成验证:`uv run pytest` 全绿 + `uv run ruff check app tests scripts` 通过;内存 Qdrant 走通 入库→列表→详情→检索→删除→统计 管理闭环冒烟;确认页面 fetch 路径与实际 API 契约一致
|
||||
- [x] Task 8: 修复验收发现:CLAUDE.md 项目结构中 tests 目录注释「18 个测试文件」与实际 26 个不符,改为不写死数量防止再次漂移
|
||||
|
||||
# Task Dependencies
|
||||
|
||||
- [Task 2] depends on [Task 1]
|
||||
- [Task 3] depends on [Task 1]
|
||||
- [Task 4] depends on [Task 2, Task 3]
|
||||
- [Task 7] depends on [Task 4, Task 5, Task 6]
|
||||
|
||||
# Parallelizable
|
||||
|
||||
- Task 1 / Task 5 / Task 6 互相独立,可并行
|
||||
- Task 2 与 Task 3 可并行(Task 1 完成后)
|
||||
Reference in New Issue
Block a user