Files
backend/docs/12-搜索功能实施计划.md
34047007@qq.com 73f9468384
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
fix: 第8轮搜索深度审计修复 — 缓存失效、Redis重试、中文标签、翻页稳定性等12项
CRITICAL:
- invalidate_search_cache 清理 atm:* 缓存(MeSH ATM扩展不再用过期结果)
- Pro 方案 api_quota_per_day 1000→10000(修复低于 Free 的数据错误)
- CacheService/RateLimitMiddleware Redis 连接失败60秒自动重试(原永久降级)
- 普通搜索中文输入自动匹配 GlobalTag.name_zh(如"肺癌"通过MeSH标签关联文献)
- AdvancedPubSearchView resolveQuery 添加 seen Set 检测交替 #N 循环引用

HIGH:
- 限速器 _burst_windows 每500请求清理过期条目(防止内存泄漏)
- cron daily_ftp_update 末尾调用 invalidate_search_cache()(自动管道不再用过期缓存)

MEDIUM:
- _apply_order_by ASC 排序加 id tiebreaker(title/journal/first_author翻页跳行/重复)
- _keyset_condition 所有 is_(None) 加 id tiebreaker + __NULL__ 哨兵值
- _field_condition("all") 默认tsvector路径加 journal/journal_iso ILIKE 兜底
- SearchView restoreFromQuery date_preset/year_from/year_to 优先顺序修复

docs: 更新 12/13 搜索文档,移除 CLAUDE.md 陈旧 SQLite 提及
2026-07-28 11:02:29 +08:00

313 lines
14 KiB
Markdown
Raw Permalink 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.
# 搜索功能实施计划
> 计划日期:2026-07-24
> **最后更新**:2026-07-28(八轮修复已完成)
> **硬性目标:搜索功能必须与 PubMed 完全一致。不允许"暂缓/可以忽略/不急"的降级。**
> **当前状态:1007 项测试全部通过,前端构建成功,搜索缓存失效、Redis 重试、中文标签、翻页稳定性等全面加固。**
> 基于 9 Agent 审计 + 真实数据库 1,662 篇字段覆盖率验证
---
## 总体进度
| 阶段 | 状态 | 修复数 | 涵盖范围 |
|------|------|--------|---------|
| 阶段 0 — 关键 Bug 修复 | ✅ 已完成 | 12 项 | search_engine + pubmed_api + 前端 |
| 阶段 1 — P0 功能 | ✅ 已完成 | 8 项 | parser + engine + 前端 |
| 阶段 2 — P1 功能 | ✅ 已完成 | 16 项 | parser + engine + 前端 |
| 阶段 3 — P2 完整覆盖 | ⏳ 部分完成 | — | 标签字段 50+ 已开,搜索历史等待办 |
| 轮次 2 — 第二轮审计修复 | ✅ 已完成 | 8 项 | De Morgan, noexp, 重复 NOT 等 |
| 轮次 3 — 第三轮审计修复 | ✅ 已完成 | 14 项 | SB/STAT 门控, 日期精度, [ALL] 注册等 |
| 轮次 4 — 第四轮字段补全 | ✅ 已完成 | 11 项 | 8 字段注册, OT/GEN/PMC 语义, phraseto_tsquery |
## 阶段 0 — 关键 Bug 修复 ✅(已完成)
**目标**:解除搜索阻塞 + 修复严重 Bug,不引入新功能。修改全部测试通过后提交。
| # | 修复项 | 文件 | 修改说明 | 状态 |
|---|--------|------|---------|------|
| 0.1 | `_expand_mesh_tag_ids` 移除 INNER JOIN | `search_engine.py:518` | `select(GlobalTag.id).join(GlobalTagTreeNumber)``select(GlobalTag.id).where(...)` | ✅ |
| 0.2 | `recent_subq` 条件化 | `search_engine.py:314-319` | 有搜索词/历史查询时禁用 | ✅ |
| 0.3 | OR 布尔运算符修复 | `search_engine.py:394-414` | 同 field 按 boolean_operator 用 `or_()` 组合 | ✅ |
| 0.4 | 多 [MH] 词 AND 组合 | `search_engine.py:516-519` | 逐 term 独立 subq,按 boolean 组合 | ✅ |
| 0.5 | PMC/trial_reg XPath 上下文 | `pubmed_api.py:440,447` | `article.findall``article_elem.findall` | ✅ |
| 0.6 | 构造函数 5 字段遗漏 | `pubmed_api.py:914-944` | 补 `pubmed_revised`/`citation_status`/`date_completed`/`article_date`/`suppl_mesh_list` | ✅ |
| 0.7 | `ArticleTitle` itertext | `pubmed_api.py:409` | `.text``"".join(itertext())` | ✅ |
| 0.8 | PMC_ID 格式统一 | `pubmed_api.py:264` | `(art.get("pmcid") or "").lstrip("PMC")` | ✅ |
| 0.9 | 搜索端点 try/except | `features.py:68` | 加异常处理返回 400 | ✅ |
| 0.10 | 前端 field:all 硬编码 | `SearchView.vue:83` | 替换为动态 `field.value` | ✅ |
| 0.11 | `journal_iso` 导入修复 | `pubmed_api.py` | 覆盖率 0% → 100%,增补 ISOAbbreviation 解析 | ✅ |
| 0.12 | `keywords` 导入修复 | `pubmed_api.py` | 覆盖率 0% → ~50%,增补 KeywordList 解析 | ✅ |
**阶段 0 验证命令**
```bash
cd backend && python -m pytest tests/ -v --no-cov -k "not wechat"
# 运行 pipeline 回填已有数据
curl -X POST localhost:8000/api/v1/admin/pipeline/run \
-H "Authorization: Bearer $(admin_token)"
# 验证 journal_iso 数据
psql -U scilit_dev -d scilit_dev -c \
"SELECT pmid, pmid, journal_iso FROM global_literature WHERE journal_iso IS NOT NULL LIMIT 10"
# 验证 [MH] 搜索
curl -X POST localhost:8000/api/v1/features/search/advanced \
-H "Content-Type: application/json" \
-d '{"query": "lung neoplasms[MH]"}'
```
---
## 阶段 1 — P0 功能 ✅(大部分已完成)
**目标**[MH]/[MAJR] 搜索恢复正常,tree_numbers 填充,EDAT 搜索可用,ATM 第一版。
| # | 任务 | 涉及文件 | 状态 |
|---|------|---------|------|
| 1.1 | **导入完整 MeSH 树号**NLM `mtrees2025.bin` | `scripts/import_mesh_tags.py` | ✅ Done |
| 1.2 | **解析 PubmedData/History → `entrez_date`** | `pubmed_api.py` + `models/literature.py` + 迁移 | ✅ Done |
| 1.3 | `[AD]`(机构)字段标签映射 | `search_engine.py` + `pubmed_query_parser.py` | ✅ Done |
| 1.4 | `[LA]`(语言)字段标签映射 | `search_engine.py` + `pubmed_query_parser.py` | ✅ Done |
| 1.5 | `[EDAT]`(入库日期)字段标签 | `search_engine.py` + `pubmed_query_parser.py` | ✅ Done(含 CRDT/MHDA/LR/DCOM/DEP |
| 1.6 | **tsvector `setweight()` 迁移** | 迁移脚本 | ⏳ 待办 |
| 1.7 | **ATM 引擎 v1**:整合 SynonymExpander | `services/query_expansion.py` | ✅ Done |
| 1.8 | `best_match` 权重调优 + setweight 配合 | `search_engine.py:556-558` | ⏳ 待 setweight 后调优 |
### 1.1 MeSH 树号导入
**操作步骤**
1. 从 NLM 下载 `mtrees2025.bin`
```bash
wget https://nlmpubs.nlm.nih.gov/projects/mesh/MESH_FILES/meshtrees/mtrees2025.bin
```
2. 修改 `scripts/import_mesh_tags.py`,添加树号导入逻辑(解析 `DescriptorName;TreeNumber` 行)
3. 为现有 99 个标签补充 tree_number 关联
4. 导入非 C04 树号(当前仅 manual 标签)
### 1.2 PubmedData/History → `entrez_date`
**模型修改**
```python
# models/literature.py 新增字段
entrez_date: datetime | None = None # PubMed 入库日期 → [EDAT]
```
**解析修改**`pubmed_api.py` `_parse_pubmed_xml()`):
```python
# 解析 PubmedData/History/PubMedPubDate[@PubStatus="entrez"]
history = article_elem.find(".//PubmedData/History")
if history is not None:
for pd in history.findall("PubMedPubDate"):
if pd.get("PubStatus") == "entrez":
# 解析年月日...
entrez_date = datetime(yr, mo, dy, tzinfo=UTC)
```
### 1.3-1.5 字段标签映射
**解析器修改**`pubmed_query_parser.py`):
```python
# _FIELD_TAG_MAP 添加
"AD": "affiliation", # 机构
"LA": "language", # 语言
"EDAT": "edat", # 入库日期
```
**SQL 映射**`search_engine.py` `_field_condition()`):
```python
"affiliation": GlobalLiterature.authors.cast(JSONB),
"language": GlobalLiterature.language,
"edat": GlobalLiterature.entrez_date,
```
### 1.6 tsvector setweight 迁移
```sql
-- 新迁移脚本:重建 search_tsv 带权重
ALTER TABLE global_literature DROP COLUMN IF EXISTS search_tsv;
ALTER TABLE global_literature ADD COLUMN search_tsv tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(abstract, '')), 'B') ||
setweight(to_tsvector('english',
coalesce((SELECT string_agg(value->>'family', ' ') FROM jsonb_array_elements(authors::jsonb)), '')
), 'A') ||
setweight(to_tsvector('english',
coalesce((SELECT string_agg(value->>'affiliation', ' ') FROM jsonb_array_elements(authors::jsonb)), '')
), 'B')
) STORED;
CREATE INDEX IF NOT EXISTS ix_global_literature_search_tsv ON global_literature USING GIN(search_tsv);
```
### 1.7 ATM 引擎 v1
**新建文件** `services/query_expansion.py`
```python
class QueryExpander:
"""PubMed ATM 简化版:MeSH 翻译 + Journal 翻译 + 同义词展开"""
EXPANSION_LIMIT = 10 # 每个词最多展开为 10 个 OR 选项
async def expand(self, db: AsyncSession, query: str) -> str:
"""对 query 中的每个词执行 ATM 展开"""
words = query.split()
expanded = []
for w in words:
# 1. MeSH Translation Table — 查 exact 匹配的 descriptor
mesh = await self._lookup_mesh(db, w)
if mesh:
expanded.append(f"({w}[MH] OR {' OR '.join(mesh)})")
continue
# 2. Journal Translation — 查缩写/全称
journal = await self._lookup_journal(db, w)
if journal:
expanded.append(f"({w}[TA] OR {' OR '.join(journal)})")
continue
# 3. 同义词展开(整合 SynonymExpander
syns = synonym_expander.expand(w)
if syns:
expanded.append(f"({w} OR {' OR '.join(syns)})")
continue
expanded.append(w)
return " ".join(expanded)
```
### 1.8 best_match 权重调优
```python
# 修改后权重公式(配合 setweight)
score = (
func.ts_rank(GlobalLiterature.search_tsv, tsq) * 10.0 # ts_rank 放大到 ~0-10
+ func.ln(func.coalesce(GlobalLiterature.cited_by_count, 0) + 1) * 2.0 # 引用 ~0-14
+ case((GlobalLiterature.pub_year >= 2020, 1), else_=0) # 近期 +1
)
```
---
## 阶段 2 — P1 功能 ✅(大部分已完成)
**目标**:字段标签覆盖率达到 ~80%,通配符/精确短语/中文搜索支持。
| # | 任务 | 说明 | 状态 |
|---|------|------|------|
| 2.1 | `[MH:NoExp]`/`[MAJR:NoExp]` 支持 | 解析器 + SQL 增加 `NoExp` 标志,阻止 tree_number 展开 | ✅ 第二轮修复 F1 |
| 2.2 | `[TA]` journal_iso 支持 + 全称回退 | `_field_condition("journal")` 增加 journal_iso ILIKE | ✅ Done |
| 2.3 | `[TW]` 文本词字段标签 | 直接映射到 `search_tsv @@ plainto_tsquery()` | ✅ Done(映射到 "all" |
| 2.4 | `[OT]` (keywords JSON)、`[GR]` (grants JSON)、`[NM]` (chemical_list JSON) | 三字段字段标签 + SQL `jsonb_array_elements` + ILIKE | ✅ Done |
| 2.5 | `*` 通配符截词 | 检测 `word:*` 模式 → `to_tsquery('english', 'word:*')` | ❌ 未实现 |
| 2.6 | `[Title/Abstract]` 长标签 | 解析器添加 `Title/Abstract` → `TIAB` 等价 | ✅ P4 已注册(映射到 all) |
| 2.7 | `[ALL]` 标签识别 | 解析器添加 `ALL` → `all` 字段映射 | ✅ 第三轮修复 P1-1 |
| 2.8 | Field tag 附着规则 + 单值 DP + 日期格式 | 解析器修复 4 个语法场景 | ✅ 第三轮修复 P1-2, P1-3 |
| 2.9 | **Entry Terms 导入** | `desc2025.asc` 解析器 → `GlobalTag.entry_terms` | ✅ 完整 MeSH 导入脚本 |
| 2.10 | `precision_mode` 后端+前端 | 后端 filter + 前端 SearchView 暴露 | ❌ 已移除(第二轮 F6/F7) |
| 2.11 | 多 affiliation 捕获 | `find` → `findall` + `|` 连接 | ✅ Done |
| 2.12 | 中文搜索 | 非英文 query 改用 `simple` 词典 | ✅ Done |
| 2.13 | 补全测试覆盖 | 28/46 零覆盖区域补全 | ✅ 127 项测试 |
| 2.14 | Europe PMC 7 字段补全 | grants 从 JSON 解析,其余标记 | ✅ Done |
| 2.15 | API 碎片清理 | 移除 `/literature/search`、Feed 集成 tsvector | ⏳ 部分完成 |
| 2.16 | `phraseto_tsquery` 精确短语 | 替代 ILIKE 用 GIN 索引 | ✅ P4 已实现 |
---
## 阶段 3 — P2 完整覆盖 ⏳(部分完成)
**目标**:完整字段标签、搜索历史、自动补全、索引全面、facets。
| # | 任务 | 状态 |
|---|------|------|
| 3.1 | 其余字段标签:目前已有 50+ 标签注册,仅 [REF]/[ISBN] 等低频未注册 | ⏳ 低频 |
| 3.2 | 搜索历史(`/search/history` 端点 + UI) | ❌ 未实现 |
| 3.3 | MeSH 自动补全(`/tags/autocomplete` + debounce | ❌ 未实现 |
| 3.4 | 查询构建器 UI(布尔组合、括号分组) | ✅ AdvancedSearchPanel 已实现 |
| 3.5 | `best_match` 排序前端暴露 | ✅ SearchView 已有 |
| 3.6 | `[AU]` JSONB 回退 + `[DOI]` 精确匹配 | ✅ Done |
| 3.7 | `is_pubmed_syntax()` 引号检测 + regex 缓存 | ✅ Done |
| 3.8 | 重复 query 构建消除(提取 `_build_query()` | ⏳ 可优化 |
| 3.9 | CommentsCorrections 全面处理(18 种 RefType | ⏳ 仅撤稿 |
| 3.10 | 缺失索引(GIN 索引:pub_types/grants/chemical_list 等) | ❌ 需 DBA |
| 3.11 | `DISMISS_THRESHOLD` Feed 已读/忽略过滤 | ❌ 未实现 |
| 3.12 | 搜索响应 faceted counts + spell correction + highlight | ❌ 未实现 |
| 3.13 | `GlobalTagTreeNumber` 模型导出 | ✅ Done |
| 3.14 | JSON 列 json → jsonb 迁移 | ✅ 已完成 |
---
## 验证流程
### 回归测试
```bash
# 搜索专项测试(127 项,~30 秒)
cd backend && python -m pytest tests/test_pubmed_query_parser.py tests/test_pubmed_search_integration.py tests/test_comprehensive_verify.py -v --no-cov 2>&1 | tail -30
# 前端构建
cd frontend && npm run build
```
### 端到端搜索质量检查
```bash
# 纯文本搜索(基础能力)
curl -X POST localhost:8000/api/v1/features/search/advanced \
-H "Content-Type: application/json" \
-d '{"query": "lung cancer"}' | python -m json.tool | head -20
# MeSH 搜索 + 树展开
curl -X POST localhost:8000/api/v1/features/search/advanced \
-H "Content-Type: application/json" \
-d '{"query": "lung neoplasms[MH] AND immunotherapy[TI]"}' | python -m json.tool | head -20
# MeSH NoExp(不展开)
curl -X POST localhost:8000/api/v1/features/search/advanced \
-H "Content-Type: application/json" \
-d '{"query": "asthma[MH:noexp]"}' | python -m json.tool | head -20
# 精确短语 + best_match 排序
curl -X POST localhost:8000/api/v1/features/search/advanced \
-H "Content-Type: application/json" \
-d '{"query": "immune checkpoint inhibitor", "sort": "best_match", "boolean": "and"}'
# 布尔混合 NOT + 括号
curl -X POST localhost:8000/api/v1/features/search/advanced \
-H "Content-Type: application/json" \
-d '{"query": "NOT (lung AND cancer)"}'
# 双重否定
curl -X POST localhost:8000/api/v1/features/search/advanced \
-H "Content-Type: application/json" \
-d '{"query": "NOT NOT cancer"}'
# [SB]/[STAT] 搜索
curl -X POST localhost:8000/api/v1/features/search/advanced \
-H "Content-Type: application/json" \
-d '{"query": "medline[SB]"}' | python -m json.tool | head -20
# 日期字段独立语法
curl -X POST localhost:8000/api/v1/features/search/advanced \
-H "Content-Type: application/json" \
-d '{"query": "2025[DP]"}' | python -m json.tool | head -10
# 日期范围
curl -X POST localhost:8000/api/v1/features/search/advanced \
-H "Content-Type: application/json" \
-d '{"query": "cancer", "year_from": 2024, "year_to": 2026, "sort": "date"}'
```
---
## 依赖关系
```
阶段 012 个 Bug 修复)
├→ 阶段 1.1(MeSH 树号导入)← 需先完成 0.1
├→ 阶段 1.2entrez_date 解析)← 独立
├→ 阶段 1.3-1.5(字段标签)← 独立
├→ 阶段 1.6setweight 迁移)← 独立
├→ 阶段 1.7(ATM 引擎)← 需先完成 2.9 Entry Terms
└→ 阶段 1.8best_match 权重)← 需先完成 1.6
```
阶段 0 可并行完成,阶段 1-3 按顺序推进。