Files
backend/docs/12-搜索功能实施计划.md
T
34047007@qq.com 62ca8fa6b8
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
chore: batch commit remaining changes
Includes search engine improvements, Alembic migrations,
new services (pubmed_daily_update, query_expansion),
frontend updates, and documentation sync.
2026-07-27 08:35:12 +08:00

288 lines
12 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.
# 搜索功能实施计划
> 计划日期:2026-07-24
> **硬性目标:搜索功能必须与 PubMed 完全一致。不允许"暂缓/可以忽略/不急"的降级。**
> 基于 9 Agent 审计 + 真实数据库 1,662 篇字段覆盖率验证
---
## 阶段 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` | 2-3h |
| 1.2 | **解析 PubmedData/History → `entrez_date`** | `pubmed_api.py` + `models/literature.py` + 迁移 | 2h |
| 1.3 | `[AD]`(机构)字段标签映射 | `search_engine.py` + `pubmed_query_parser.py` | 30min |
| 1.4 | `[LA]`(语言)字段标签映射 | `search_engine.py` + `pubmed_query_parser.py` | 30min |
| 1.5 | `[EDAT]`(入库日期)字段标签 | `search_engine.py` + `pubmed_query_parser.py` | 30min |
| 1.6 | **tsvector `setweight()` 迁移** | 迁移脚本 | 1h |
| 1.7 | **ATM 引擎 v1**:整合 SynonymExpander | `services/query_expansion.py`(新建) | 3-4h |
| 1.8 | `best_match` 权重调优 + setweight 配合 | `search_engine.py:556-558` | 1h |
### 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 展开 |
| 2.2 | `[TA]` journal_iso 支持 + 全称回退 | `_field_condition("journal")` 增加 journal_iso ILIKE |
| 2.3 | `[TW]` 文本词字段标签 | 直接映射到 `search_tsv @@ plainto_tsquery()` |
| 2.4 | `[OT]` (keywords JSON)、`[GR]` (grants JSON)、`[NM]` (chemical_list JSON) | 三字段字段标签 + SQL `jsonb_array_elements` + ILIKE |
| 2.5 | `*` 通配符截词 | 检测 `word:*` 模式 → `to_tsquery('english', 'word:*')` |
| 2.6 | `[Title/Abstract]` 长标签 | 解析器添加 `Title/Abstract` → `TIAB` 等价 |
| 2.7 | `[ALL]` 标签识别 | 解析器添加 `ALL` → `all` 字段映射 |
| 2.8 | Field tag 附着规则 + 单值 DP + 日期格式 | 解析器修复 4 个语法场景 |
| 2.9 | **Entry Terms 导入** | `desc2025.asc` 解析器 → `GlobalTag.entry_terms` |
| 2.10 | `precision_mode` 后端+前端 | 后端 filter + 前端 SearchView 暴露 |
| 2.11 | 多 affiliation 捕获 | `find` → `findall` + `|` 连接 |
| 2.12 | 中文搜索 | 非英文 query 改用 `simple` 词典 |
| 2.13 | 补全测试覆盖 | 28/46 零覆盖区域补全 |
| 2.14 | Europe PMC 7 字段补全 | grants 从 JSON 解析,其余标记 |
| 2.15 | API 碎片清理 | 移除 `/literature/search`、Feed 集成 tsvector |
| 2.16 | `phraseto_tsquery` 精确短语 | 替代 ILIKE 用 GIN 索引 |
---
## 阶段 3 — P2 完整覆盖
**目标**:完整字段标签、搜索历史、自动补全、索引全面、facets。
| # | 任务 | 优先级 |
|---|------|--------|
| 3.1 | 其余字段标签:[VI], [IP], [PG], [PMC], [SO], [IS], [GS], [RN], [SI], [SH], [LR], [IR] | P2 |
| 3.2 | 搜索历史(`/search/history` 端点 + UI | P2 |
| 3.3 | MeSH 自动补全(`/tags/autocomplete` + debounce | P2 |
| 3.4 | 查询构建器 UI(布尔组合、括号分组) | P2 |
| 3.5 | `best_match` 排序前端暴露 | P2 |
| 3.6 | `[AU]` JSONB 回退 + `[DOI]` 精确匹配 | P2 |
| 3.7 | `is_pubmed_syntax()` 引号检测 + regex 缓存 | P2 |
| 3.8 | 重复 query 构建消除(提取 `_build_query()` | P2 |
| 3.9 | CommentsCorrections 全面处理(18 种 RefType | P2 |
| 3.10 | 缺失索引(doi B-tree, journal_iso B-tree, pub_types GIN, study_design GIN, retracted B-tree | P2 |
| 3.11 | `DISMISS_THRESHOLD` Feed 已读/忽略过滤 | P2 |
| 3.12 | 搜索响应 faceted counts + spell correction + highlight | P2 |
| 3.13 | `GlobalTagTreeNumber` 模型导出 | P2 |
| 3.14 | JSON 列 json → jsonb 迁移 | P2 |
---
## 验证流程
### 每阶段验证
```bash
# 1. 回归测试(排除 broken wechat test
cd backend && python -m pytest tests/ -v --no-cov -k "not wechat" 2>&1 | tail -30
# 2. [MH] 查询(阶段 0 修复后应有结果)
curl -X POST localhost:8000/api/v1/features/search/advanced \
-H "Content-Type: application/json" \
-d '{"query": "lung neoplasms[MH]"}' | python -m json.tool | head -10
# 3. OR 布尔
curl -X POST localhost:8000/api/v1/features/search/advanced \
-H "Content-Type: application/json" \
-d '{"query": "lung[TI] OR breast[TI]", "boolean": "or"}' | python -m json.tool | head -10
# 4. 历史日期 + date 排序
curl -X POST localhost:8000/api/v1/features/search/advanced \
-H "Content-Type: application/json" \
-d '{"query": "cancer", "year_from": 1990, "year_to": 2000, "sort": "date"}'
# 5. 数据回填验证
curl -X POST localhost:8000/api/v1/admin/pipeline/run \
-H "Authorization: Bearer $(admin_token)"
# 6. 前端构建
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"}'
# MeSH 搜索(阶段 1 修复后)
curl -X POST localhost:8000/api/v1/features/search/advanced \
-H "Content-Type: application/json" \
-d '{"query": "lung neoplasms[MH] AND immunotherapy[TI]"}'
# 精确短语 + 排序
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"}'
```
---
## 依赖关系
```
阶段 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 按顺序推进。