# 搜索功能实施计划

> 计划日期：2026-07-24
> 基于 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"}'
```

---

## 依赖关系

```
阶段 0（12 个 Bug 修复）
  │
  ├→ 阶段 1.1（MeSH 树号导入）← 需先完成 0.1
  ├→ 阶段 1.2（entrez_date 解析）← 独立
  ├→ 阶段 1.3-1.5（字段标签）← 独立
  ├→ 阶段 1.6（setweight 迁移）← 独立
  ├→ 阶段 1.7（ATM 引擎）← 需先完成 2.9 Entry Terms
  └→ 阶段 1.8（best_match 权重）← 需先完成 1.6
```

阶段 0 可并行完成，阶段 1-3 按顺序推进。
