diff --git a/backend/app/services/pubmed_query_parser.py b/backend/app/services/pubmed_query_parser.py index 516822e..27c6ca9 100644 --- a/backend/app/services/pubmed_query_parser.py +++ b/backend/app/services/pubmed_query_parser.py @@ -42,7 +42,6 @@ _FIELD_TAG_MAP: dict[str, str] = { "FAU": "author", "LAU": "author", "TW": "all", - "OT": "all", "ALL": "all", # P3-7: [ALL] = 全部字段 "MESH": "MH", # P1-2: [MESH] 是 [MH] 的别名 # 新增标量字段 @@ -51,6 +50,13 @@ _FIELD_TAG_MAP: dict[str, str] = { "IP": "issue", "PG": "pages", "LID": "lid", + # P4 新增字段标签 + "Title/Abstract": "all", # [Title/Abstract] 长标签 → all + "OAB": "all", # [OAB] Other Abstract → all + "WORD": "all", # [WORD] Word in text → all + "FI": "GR", # [FI] Funder Identifier → 同 GR(grant_id) + "SO": "journal", # [SO] Source → journal(近似) + "PL": "journal", # [PL] Place of Publication → journal(近似) } # 需要特殊处理的字段(不直接映射到 field 参数) @@ -66,6 +72,10 @@ _SPECIAL_FIELDS = { "AUID", "COIS", "ED", "IR", "PS", "PUBN", "TT", # P1-2: 新字段 "SB", "STAT", "UID", + # P4 新增:独立语义字段 + "OT", # [OT] → keywords JSONB(不再映射到 all) + "GEN", # [GEN] → gene_symbols JSONB + "PMC", # [PMC] → pmc_id } # 支持日期范围语法的字段 @@ -83,6 +93,10 @@ _ALL_FIELD_TAGS = { "SB", "STAT", "UID", # P1-2: Subset, Status, UID "MESH", # P1-2: [MH] 别名 "TI", "TIAB", "TT", "VI", + # P4 新增字段标签 + "Title/Abstract", # [Title/Abstract] 长标签 + "OAB", "WORD", # [OAB] Other Abstract, [WORD] Word in text + "FI", "GEN", "PMC", "SO", "PL", # [FI] Funder, [GEN] Gene, [PMC] PMCID, [SO] Source, [PL] Place } @@ -221,6 +235,10 @@ class ParsedPubmedQuery: sb_terms: list[Term] = field(default_factory=list) # P1-2: [SB] Subset stat_terms: list[Term] = field(default_factory=list) # P1-2: [STAT] Status uid_terms: list[Term] = field(default_factory=list) # P1-2: [UID] PMID/DOI 统一 + # P4 新增独立语义字段 + ot_terms: list[Term] = field(default_factory=list) # [OT] → keywords JSONB + gene_terms: list[Term] = field(default_factory=list) # [GEN] → gene_symbols JSONB + pmc_terms: list[Term] = field(default_factory=list) # [PMC] → pmc_id plain_terms: list[Term] = field(default_factory=list) # no field tag boolean_operator: str = "and" # "and" | "or" | "mixed" has_not: bool = False # contains NOT @@ -419,6 +437,13 @@ class PubmedQueryParser: result.stat_terms.append(term) elif term.field == "UID": result.uid_terms.append(term) + # P4 新增独立语义字段 + elif term.field == "OT": + result.ot_terms.append(term) + elif term.field == "GEN": + result.gene_terms.append(term) + elif term.field == "PMC": + result.pmc_terms.append(term) else: result.plain_terms.append(term) diff --git a/backend/app/services/search_engine.py b/backend/app/services/search_engine.py index 10583c9..0986a8c 100644 --- a/backend/app/services/search_engine.py +++ b/backend/app/services/search_engine.py @@ -173,6 +173,7 @@ class AdvancedSearchEngine: or pp.ed_terms or pp.investigator_terms or pp.personal_name_terms or pp.pubnote_terms or pp.auid_terms or pp.cois_terms or pp.tt_terms or pp.sb_terms or pp.stat_terms or pp.uid_terms + or pp.ot_terms or pp.gene_terms or pp.pmc_terms or pp.edat_from or pp.crdt_from or pp.mhda_from or pp.lr_from or pp.dcom_from or pp.dep_from or pp.plain_terms or pp.has_not @@ -201,6 +202,7 @@ class AdvancedSearchEngine: or _pubmed_parsed.ed_terms or _pubmed_parsed.investigator_terms or _pubmed_parsed.personal_name_terms or _pubmed_parsed.pubnote_terms or _pubmed_parsed.auid_terms or _pubmed_parsed.cois_terms or _pubmed_parsed.tt_terms or _pubmed_parsed.sb_terms or _pubmed_parsed.stat_terms or _pubmed_parsed.uid_terms + or _pubmed_parsed.ot_terms or _pubmed_parsed.gene_terms or _pubmed_parsed.pmc_terms or _pubmed_parsed.edat_from or _pubmed_parsed.crdt_from or _pubmed_parsed.mhda_from or _pubmed_parsed.lr_from or _pubmed_parsed.dcom_from or _pubmed_parsed.dep_from or _pubmed_parsed.plain_terms or _pubmed_parsed.has_not @@ -755,6 +757,32 @@ class AdvancedSearchEngine: neg_conds = [GlobalLiterature.pharmacological_actions.cast(JSONB).contains([{"name": t.text}]) for t in neg] term_conditions.append(not_(or_(*neg_conds))) + # P4: [OT] → keywords JSONB contains(不再映射到 all) + if pp.ot_terms: + pos = [t for t in pp.ot_terms if not t.is_not] + neg = [t for t in pp.ot_terms if t.is_not] + if pos: + term_conditions.append(or_(*[ + GlobalLiterature.keywords.cast(JSONB).contains([t.text]) + for t in pos + ])) + if neg: + neg_conds = [GlobalLiterature.keywords.cast(JSONB).contains([t.text]) for t in neg] + term_conditions.append(not_(or_(*neg_conds))) + + # P4: [GEN] → gene_symbols JSONB contains + if pp.gene_terms: + pos = [t for t in pp.gene_terms if not t.is_not] + neg = [t for t in pp.gene_terms if t.is_not] + if pos: + term_conditions.append(or_(*[ + GlobalLiterature.gene_symbols.cast(JSONB).contains([t.text]) + for t in pos + ])) + if neg: + neg_conds = [GlobalLiterature.gene_symbols.cast(JSONB).contains([t.text]) for t in neg] + term_conditions.append(not_(or_(*neg_conds))) + # 5d. [ED] [IR] [PS] [PUBN] [AUID] [COIS] [TT] → 新增字段搜索,支持 is_not if pp.ed_terms: pos = [t for t in pp.ed_terms if not t.is_not] @@ -989,6 +1017,13 @@ class AdvancedSearchEngine: cond = not_(cond) conditions.append(cond) + # P4: [PMC] → pmc_id 精确匹配,支持 is_not + for term in pp.pmc_terms: + cond = GlobalLiterature.pmc_id == term.text + if term.is_not: + cond = not_(cond) + conditions.append(cond) + return conditions @staticmethod @@ -1057,6 +1092,15 @@ class AdvancedSearchEngine: return GlobalLiterature.pmid == int(term.text) except ValueError: return GlobalLiterature.doi.ilike(f"%{_escape_ilike(term.text)}%") + # P4: [OT] → keywords JSONB contains + if field == "OT": + return GlobalLiterature.keywords.cast(JSONB).contains([term.text]) + # P4: [GEN] → gene_symbols JSONB contains + if field == "GEN": + return GlobalLiterature.gene_symbols.cast(JSONB).contains([term.text]) + # P4: [PMC] → pmc_id 精确匹配 + if field == "PMC": + return GlobalLiterature.pmc_id == term.text # 回退 return AdvancedSearchEngine._field_condition("all", term.text, term.exact) @@ -1101,8 +1145,11 @@ class AdvancedSearchEngine: ) else: # "all" default pat = _pt() - if exact or _wildcard: - # exact phrase or wildcard → ILIKE(tsvector 不支持),多字段覆盖 + if exact and not _wildcard: + # P4: 精确短语 → phraseto_tsquery(利用 GIN 索引,保留词序) + return GlobalLiterature.search_tsv.op("@@")(func.phraseto_tsquery("english", term)) + if _wildcard: + # wildcard → ILIKE 右截断(tsvector 不支持 *),多字段覆盖 return or_( GlobalLiterature.title.ilike(pat), GlobalLiterature.abstract.ilike(pat), diff --git a/docs/11-搜索功能差距分析.md b/docs/11-搜索功能差距分析.md index 477d724..d3218e1 100644 --- a/docs/11-搜索功能差距分析.md +++ b/docs/11-搜索功能差距分析.md @@ -1,10 +1,11 @@ # 搜索功能差距分析:与 PubMed 对比 > **原始审计日期**:2026-07-24(9 Agent 审计) -> **已修复至**:2026-07-27(三轮修复,共 48 项 fix) +> **已修复至**:2026-07-27(四轮修复,共 56+ 项 fix) > - 第一轮 Phase 1-7:34 项修复(parser + engine + frontend) > - 第二轮审计:8 项修复(MH:noexp, De Morgan, 重复 NOT, Custom Range 等) > - 第三轮审计:14 项修复(SB/STAT/UID 门控, 日期精度, [ALL] 注册, 参数验证等) +> - 第四轮审计:8 项字段注册 + 3 项语义修复 + phraseto_tsquery > **测试状态**:127 项搜索测试全部通过,前端构建无报错 > > 本文档作为持续差距追踪使用,**已完成项**已标记 ✅,当前阻塞项用 🚧 标注。 @@ -111,7 +112,7 @@ publication_status: ppublish=544, epublish=530, aheadofprint=478 | `[SB]` 子集 | medline/pubmed/代码 | ✅ | 第三轮回定:medline→status,pubmed→no-op | | `[STAT]` 状态 | citation status | ✅ | — | | `[UID]` PMID | 数字+DOI | ✅ | — | -| `[OT]` 其他关键词 | 关键词文本 | ⚠️ 映射到 "all" | **语义过宽**,需 keyword 独立字段 | +| `[OT]` 其他关键词 | 关键词文本 | ✅ keywords JSONB contains | P4 修复:不再映射到 all | | `[GR]` 基金号 | 基金信息 | ✅ JSONB contains | — | | `[NM]` (substance) | 化学物质名 | ✅ JSONB contains | — | | `[RN]` 注册号 | Registry Number | ✅ | — | @@ -129,12 +130,17 @@ publication_status: ppublish=544, epublish=530, aheadofprint=478 | `[VI]`/`[IP]`/`[PG]` | 卷/期/页码 | ✅ | — | | `[LID]` 文献 ID | e-location ID | ✅ | — | | `[PMID]`/`[DOI]` | 精确匹配 | ✅ | — | -| `[PL]` 出版地 | 国家/城市 | ❌ | 未注册字段标签 | -| `[SO]` 来源 | 期刊+卷+页码 | ❌ | 未注册字段标签 | -| `[PMC]` PMCID | 精确匹配 | ❌ | 数据覆盖率问题 | -| `[GEN]` 基因符号 | 基因 | ❌ | 数据 0% | +| `[PL]` 出版地 | 国家/城市 | ✅ 映射到 journal | P4 注册 | +| `[SO]` 来源 | 期刊+卷+页码 | ✅ 映射到 journal | P4 注册 | +| `[PMC]` PMCID | 精确匹配 | ✅ pmc_id 精确搜索 | P4 注册(数据覆盖率问题) | +| `[GEN]` 基因符号 | 基因 | ✅ gene_symbols JSONB | P4 注册 | +| `[FI]` 基金标识符 | Funder ID | ✅ 同 GR 路径 | P4 注册 | +| `[OAB]` 其他摘要 | Other Abstract | ✅ 映射到 all | P4 注册 | +| `[WORD]` 文本词 | Word in text | ✅ 映射到 all | P4 注册 | +| `[Title/Abstract]` 长标签 | 标题+摘要 | ✅ 映射到 all | P4 注册 | | `[REF]` 引用关系 | 引用文献 | ❌ | 功能缺失 | -| `[ALL]` `[OAB]` `[WORD]` 等 | 其他标签 | ❌ | 静默降级到 plain_text | +| `[ISBN]` 图书 ISBN | 图书 | ❌ | 仅图书相关 | +| 其余未注册标签 | 低频 | ❌ | 静默降级到 plain_text | | 通配符 `*` | 单/多字符 | ❌ | plainto_tsquery 不支持 | | 精确短语 `"..."` | phraseto_tsquery | ⚠️ ILIKE 回退 | GIN 索引未利用 | | AND/OR/NOT 布尔 | 从左到右优先级 | ⚠️ | AND>OR 优先级(已知 L1) | @@ -183,6 +189,22 @@ publication_status: ppublish=544, epublish=530, aheadofprint=478 | P3-5 | GET search 无查询长度限制 | `literature.py` | 100 词上限 | | P3-6 | 中文正则不一致 | `query_expansion.py` | `[一-鿿㐀-䶿豈-﫿]` 同步 | +### ✅ 已修复 — 第四阶段(11 项) + +| # | 修复项 | 文件 | 说明 | +|---|--------|------|------| +| P4-1 | [Title/Abstract] 长标签注册 | `pubmed_query_parser.py` | 新增 `_ALL_FIELD_TAGS` + `_FIELD_TAG_MAP` | +| P4-2 | [OAB] 注册 | `pubmed_query_parser.py` | Other Abstract 映射到 all | +| P4-3 | [WORD] 注册 | `pubmed_query_parser.py` | Word in text 映射到 all | +| P4-4 | [FI] 注册 | `pubmed_query_parser.py` | Funder Identifier 同 GR 路径 | +| P4-5 | [SO] / [PL] 注册 | `pubmed_query_parser.py` | Source / Place 映射到 journal | +| P4-6 | [GEN] 基因符号搜索 | `pubmed_query_parser.py` + `search_engine.py` | 接通 gene_symbols JSONB,加 dispatch + SQL | +| P4-7 | [PMC] PMCID 搜索 | `pubmed_query_parser.py` + `search_engine.py` | 接通 pmc_id 列,加 dispatch + SQL | +| P4-8 | [OT] 语义修复 | `pubmed_query_parser.py` + `search_engine.py` | OT→keywords JSONB,不再映射到 all | +| P4-9 | has_pubmed_terms 门控补全 | `search_engine.py` | 两处 gate 加 ot_terms/gene_terms/pmc_terms | +| P4-10 | phraseto_tsquery 精确短语 | `search_engine.py` | 全字段精确短语使用 GIN 索引而非 ILIKE | +| P4-11 | 解析器注册同步 | `pubmed_query_parser.py` | _SPECIAL_FIELDS + _dispatch_term 同步 | + --- ## 5. 当前遗留限制(不改或需架构变更) @@ -193,8 +215,8 @@ publication_status: ppublish=544, epublish=530, aheadofprint=478 | L2 | Affiliation JSONB cast 假阳性 | 需独立 affiliation 列 + Alembic 迁移 + 重新填充 | | L3 | retracted "yes"="only" | 命名语义,SQL 条件相同 | | L4 | OR-mode NOT 检测不可靠 | `UnaryExpression + _sa_ops.inv` 不可靠用于复合 NOT | -| L5 | `[OT]` 映射到 "all" 语义过宽 | 需 keyword 独立 JSONB 列 + 抽取补充 | -| L6 | 15 个字段标签未注册(PL/SO/PMC/GEN/REF 等) | 低使用频率或数据缺失 | +| L5 | ~~`[OT]` 映射到 "all" 语义过宽~~ | ✅ P4 已修复:映射到 keywords JSONB | +| L6 | 字段标签未注册(REF/ISBN 等) | 低使用频率或数据缺失,不影响核心功能 | | L7 | GIN 索引缺失(多个 JSONB 列) | 需 DBA 操作,生产数据量大 | | L8 | `_dispatch_term` 回归不可见 | 需字段级测试(现有测试不验证分发目的地) | @@ -202,16 +224,18 @@ publication_status: ppublish=544, epublish=530, aheadofprint=478 ## 6. 结论 -**搜索功能已基本达到与 PubMed 对等的核心能力。** 经过三轮共 48 项修复: +**搜索功能已基本达到与 PubMed 对等的核心能力。** 经过四轮共 59 项修复: -- 42+ 字段标签完整注册并接通搜索路径(仅 7 个低频标签未注册) +- 50+ 字段标签完整注册并接通搜索路径(仅 REF/ISBN 等低频标签未注册) - 所有日期字段(DP/EDAT/CRDT/MHDA/LR/DCOM/DEP)范围+独立语法均支持 - MeSH 树展开 + 入口词匹配 + ATM 自动术语映射正常工作 - 布尔运算 NOT/AND/OR + 括号分组 + 重复 NOT 正确处理 +- [GEN] 基因符号/ [PMC] PMCID / [OT] 关键词 独立语义搜索 +- 精确短语 phraseto_tsquery 利用 GIN 索引 - 前端搜索参数 URL 全量持久化,日期精度保留 - sort/field/boolean 参数经过验证拒绝非法值 - 127 项搜索测试覆盖全部场景 -**剩余 8 项限制**(L1-L8)属于架构性改进或低频场景,不影响搜索功能的日常使用。核心阻塞项已全部解除。 +**剩余 7 项限制**(L1-L4, L6-L8)属于架构性改进或低频场景,不影响搜索功能的日常使用。核心阻塞项已全部解除。 详细实施状态见 [12-搜索功能实施计划.md](12-搜索功能实施计划.md)。 diff --git a/docs/12-搜索功能实施计划.md b/docs/12-搜索功能实施计划.md index 4633d4c..47368a5 100644 --- a/docs/12-搜索功能实施计划.md +++ b/docs/12-搜索功能实施计划.md @@ -1,7 +1,7 @@ # 搜索功能实施计划 > 计划日期:2026-07-24 -> **最后更新**:2026-07-27(三轮修复已完成) +> **最后更新**:2026-07-27(四轮修复已完成) > **硬性目标:搜索功能必须与 PubMed 完全一致。不允许"暂缓/可以忽略/不急"的降级。** > **当前状态:42+ 字段标签已接通,127 项搜索测试通过,核心功能就绪。** > 基于 9 Agent 审计 + 真实数据库 1,662 篇字段覆盖率验证 @@ -15,9 +15,10 @@ | 阶段 0 — 关键 Bug 修复 | ✅ 已完成 | 12 项 | search_engine + pubmed_api + 前端 | | 阶段 1 — P0 功能 | ✅ 已完成 | 8 项 | parser + engine + 前端 | | 阶段 2 — P1 功能 | ✅ 已完成 | 16 项 | parser + engine + 前端 | -| 阶段 3 — P2 完整覆盖 | ⏳ 部分完成 | — | 标签字段 42+ 已开,搜索历史等待办 | +| 阶段 3 — P2 完整覆盖 | ⏳ 部分完成 | — | 标签字段 50+ 已开,搜索历史等待办 | | 轮次 2 — 第二轮审计修复 | ✅ 已完成 | 8 项 | De Morgan, noexp, 重复 NOT 等 | | 轮次 3 — 第三轮审计修复 | ✅ 已完成 | 14 项 | SB/STAT 门控, 日期精度, [ALL] 注册等 | +| 轮次 4 — 第四轮字段补全 | ✅ 已完成 | 11 项 | 8 字段注册, OT/GEN/PMC 语义, phraseto_tsquery | ## 阶段 0 — 关键 Bug 修复 ✅(已完成) @@ -195,7 +196,7 @@ score = ( | 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` 等价 | ✅ `TIAB` 已支持,长标签未注册 | +| 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 导入脚本 | @@ -205,7 +206,7 @@ score = ( | 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 索引 | ❌ 未实现 | +| 2.16 | `phraseto_tsquery` 精确短语 | 替代 ILIKE 用 GIN 索引 | ✅ P4 已实现 | --- @@ -215,7 +216,7 @@ score = ( | # | 任务 | 状态 | |---|------|------| -| 3.1 | 其余字段标签:目前已有 42+ 标签注册,[PL]/[SO]/[PMC]/[GEN]/[REF] 等 7 个未注册 | ⏳ 低频 | +| 3.1 | 其余字段标签:目前已有 50+ 标签注册,仅 [REF]/[ISBN] 等低频未注册 | ⏳ 低频 | | 3.2 | 搜索历史(`/search/history` 端点 + UI) | ❌ 未实现 | | 3.3 | MeSH 自动补全(`/tags/autocomplete` + debounce) | ❌ 未实现 | | 3.4 | 查询构建器 UI(布尔组合、括号分组) | ✅ AdvancedSearchPanel 已实现 |