22 KiB
PubMed 搜索合规修复全记录
本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。
累计:5 轮,63 项修复,50+ 字段标签注册,214 项测试覆盖 时间跨度:2026-07-24 ~ 2026-07-27 核心文件:
pubmed_query_parser.py(~620 行)→search_engine.py(~1250 行)
目录
- 第一轮:Phase 0-7 基础修复(34 项)
- 第二轮:第二轮审计修复(8 项)
- 第三轮:第三轮审计修复(14 项)
- 第四轮:字段补全与语义优化(11 项)
- 第五轮:第 5 轮全面审计修复(4 项)
- 遗留限制
第一轮:Phase 0-7 基础修复
提交:723c4fc / 62ca8fa / 5f69277
日期:2026-07-24 ~ 2026-07-25
数量:34 项
触发:9 Agent 并行深度审计
背景
首次系统审计发现搜索功能存在大量阻塞性 Bug:MeSH 搜索因 INNER JOIN 返回 0 结果、布尔运算符 OR 被当作 AND 处理、PM C_ID 格式混乱、数据导入缺失 journal_iso 和 keywords 等关键字段。整体搜索几不可用。
Phase 0 — 关键 Bug 修复(12 项)
| # | 修复项 | 文件 | 问题描述 | 根因分析 | 修改内容 |
|---|---|---|---|---|---|
| 0.1 | _expand_mesh_tag_ids INNER JOIN |
search_engine.py:518 |
所有 [MH]/[MAJR] 搜索返回 0 个结果。tree_numbers 表无 C04 数据,INNER JOIN 过滤掉所有标签 |
select(GlobalTag.id).join(GlobalTagTreeNumber) 在有 tree_number 的标签时才返回行 |
改为 select(GlobalTag.id).where(...),使 MeSH 搜索在无 tree_number 展开时仍能工作 |
| 0.2 | recent_subq 条件化 |
search_engine.py:314-319 |
有搜索词时仍被 pub_date > 90d 子查询过滤,漏掉旧文章 |
未检查 query.strip() 就附加 recent 子句 |
有搜索词或历史查询时跳过 recent 子查询 |
| 0.3 | OR 布尔运算符 | search_engine.py:394-414 |
同字段多个词始终 AND,lung[TI] OR breast[TI] 只返回同时命中 lung 和 breast 的文献 |
boolean 参数只在文本搜索路径生效,未传入 _field_conditions |
同 field 按 boolean_operator 用 or_() 组合 |
| 0.4 | 多 [MH] 词 AND 组合 | search_engine.py:516-519 |
lung neoplasms[MH] AND immunotherapy[MH] 返回 0 结果 |
所有 mesh 词合并到一个 subq IN,必须所有标签同时关联同一篇文献 | 逐 term 独立 subq,按 boolean 组合(AND 用 intersect) |
| 0.5 | PMC XPath 上下文 | pubmed_api.py:440,447 |
pmc_id/is_oa 解析错误,只有 9/1662 篇有值 | article.findall('.//PMCID') 在 article 而非 article_elem 上调用 |
article.findall → article_elem.findall |
| 0.6 | 构造函数遗漏 | pubmed_api.py:914-944 |
pubmed_revised/citation_status/date_completed/article_date/suppl_mesh_list 未写入模型 |
_parse_pubmed_xml() 返回值不包含这些字段 |
补全 5 字段传递 |
| 0.7 | ArticleTitle itertext | pubmed_api.py:409 |
含 <i>/<sub> 等内联标记的标题截断 |
.text 只返回第一个文本节点 |
"".join(itertext()) |
| 0.8 | PMC_ID 格式统一 | pubmed_api.py:264 |
"PMC1234567" vs "1234567" 混用 | lstrip("PMC") → 会误删 PMC 开头真实数字 |
art.get("pmcid", "").lstrip("PMC") |
| 0.9 | 搜索端点异常处理 | features.py:68 |
搜索异常暴露 500 内幕 | 无 try/except | 加异常处理返回 400 |
| 0.10 | field:all 硬编码 | SearchView.vue:83 |
前端固定发送 field: "all" |
模板字符串手误 | 替换为动态 field.value |
| 0.11 | journal_iso 导入修复 | pubmed_api.py |
0% 覆盖率 | Journal ISOAbbreviation 未解析 | 增补 ISOAbbreviation 提取 |
| 0.12 | keywords 导入修复 | pubmed_api.py |
0% 覆盖率 | KeywordList 未解析 | 增补 KeywordList 解析 |
Phase 1 — P0 新功能(8 项)
| # | 任务 | 文件 | 说明 |
|---|---|---|---|
| 1.1 | MeSH 树号导入 | scripts/import_mesh_tags.py |
导入 NLM mtrees2025.bin,填充 GlobalTagTreeNumber |
| 1.2 | entrez_date 解析 |
pubmed_api.py + migration |
解析 PubmedData/History/PubMedPubDate[@PubStatus="entrez"] → [EDAT] 可搜 |
| 1.3 | [AD] 标签 |
search_engine.py + pubmed_query_parser.py |
机构字段映射到 authors JSONB cast |
| 1.4 | [LA] 标签 |
同上 | 语言字段 = GlobalLiterature.language |
| 1.5 | [EDAT]/[CRDT]/[MHDA]/[LR]/[DCOM]/[DEP] |
同上 | 6 个日期字段的范围+独立语法 |
| 1.6 | setweight 迁移 | 迁移脚本 | ⏳ 待办:tsvector 重建带 A/B 权重 |
| 1.7 | ATM 引擎 v1 | query_expansion.py |
SynonymExpander + MeSH 翻译 + 期刊翻译 |
| 1.8 | best_match 权重调优 | search_engine.py |
⏳ 待 setweight 后调优 |
Phase 2 — 字段标签覆盖(14 项)
| # | 任务 | 状态 | 说明 |
|---|---|---|---|
[OT] |
keywords 搜索 | ✅ | 映射到 all(第四轮修复为独立 keywords JSONB) |
[GR] |
grants 搜索 | ✅ | jsonb_array_elements + ILIKE |
[NM] |
化学物质名 | ✅ | chemical_list JSONB contains |
[RN] |
Registry Number | ✅ | chemical_list.registry_number |
[SH] |
Subheading | ✅ | mesh_headings.qualifiers |
[SI] |
DataBank | ✅ | databank_list.accession_numbers |
[PA] |
Pharmacological Action | ✅ | pharmacological_actions JSONB |
[TW] |
文本词 | ✅ | 映射到 all |
[TA]/[JT] |
期刊全称+缩写 | ✅ | journal ILIKE + journal_iso ILIKE |
[CN]/[FAU]/[LAU] |
作者变体 | ✅ | 均映射到 author 路径 |
| Entry Terms | MeSH 入口词 | ✅ | desc2025.asc → GlobalTag.entry_terms |
| 中文搜索 | CJK 支持 | ✅ | simple 词典而非 english |
| 多 affiliation | 捕获所有 | ✅ | find → findall + | 连接 |
Phase 3 — E-utilities 补充抽取(含 pubmed_api.py 修复)
- 反向兼容阶段,确保 pubmed_api.py 正确抽取所有字段
- PMC/trial_reg/retraction 等补充数据通路修复
Phase 4 — 搜索质量
- 历史搜索查询 + 日期筛选整合
best_match排序引入cited_by_count+ 年度梯度- 缺省排序降级保护
Phase 5 — 解析器健壮性
- 括号嵌套超过 10 层保护
- 空查询短路
- token 超限截断
- 未知字段标签静默降级
- Unicode normalization(全角数字、零宽字符)
Phase 6 — 前端搜索 UX
- SearchView URL 状态全量持久化(24 个参数)
- HomeView → SearchView 参数正确传递
- LiteratureCard
search事件冒泡 - 响应式布局适配(移动端搜索栏隐藏)
Phase 7 — API 验证
- 请求参数 Pydantic validator
field只接受预定义值- 查询长度限制
第二轮:第二轮审计修复
提交:e688241
日期:2026-07-26
数量:8 项 + 6 项新测试
触发:审计发现 Phase 1-7 修复中的遗留 Bug
背景
8 项在首次审计中遗漏的中高级 Bug,全部在后端搜索核心路径。
修复清单
F1: [MH:noexp] 顶层 _noexp 标志丢失
- 文件:
search_engine.py_pubmed_conditions()L640-650 - 问题:
mesh_terms处理时只提取.text,丢弃了_noexp标志,导致asthma[MH:noexp]在顶层使用时仍然树展开,和[MH]无异 - 根因:循环处理 vs 括号组内调用
_single_term_condition()两条路径——后者正确传递noexp,前者未分组 - 修复:将
mesh_terms按_noexp拆为两组,分别调用_expand_mesh_tag_ids:noexp_names = [t.text for t in pp.mesh_terms if t._noexp] exp_names = [t.text for t in pp.mesh_terms if not t._noexp]
F2: 组内 NOT 违反 De Morgan 律
- 文件:
search_engine.pyL862-877 - 问题:
NOT (A AND B)→ 被解析为not_(A) AND not_(B)=NOT (A OR B),语义完全翻转 - 根因:全 NOT 组内每个 term 独立
not_(),然后 AND 组合 - 修复:组的
all_not=True时,对所有 term 不做独立 NOT,改为not_(combined)包裹组合条件
F3: _parse_not_expr 不支持重复 NOT
- 文件:
pubmed_query_parser.pyL428-433 - 问题:
NOT NOT cancer→ 第二个 "NOT" 被降级为搜索字面词,变成NOT "NOT" AND cancer - 根因:语法定义
not_expr → NOT not_expr | primary,但实现直接跳到_parse_primary(result, negated=True),丢失递归 - 修复:改为递归调用
_parse_not_expr并 toggleis_not
F4: SearchView Custom Range 不发送日期
- 文件:
SearchView.vueL296-309 - 问题:选择"自定义年份范围"→ 搜索,请求体不含任何日期参数,返回全库结果
- 根因:
datePreset === 'custom'分支未处理。仅!datePreset.value和datePreset.value !== 'custom'两个条件都失败 - 修复:增加
datePreset === 'custom'分支发送year_from/year_to
F5: HomeView.restoreFromUrl 恢复不全
- 文件:
HomeView.vueL348-364 - 问题:URL 含
?q=cancer&retracted=only时,retracted 参数丢失 - 根因:只恢复了
tag/date_from/date_to/q,缺失sort/field/retracted/negative_result - 修复:补全 4 个缺失参数的读取
F6+F7: 死代码清理 — precision_mode + is_oa
- 文件:
AdvancedSearchPanel.vue/types/index.ts - 问题:UI 中 precision_mode 切换按钮存在但无任何效果,混淆用户
- 修复:全链路移除 precision_mode 控件和类型字段
F8: _expand_mesh_tag_ids N+1 查询 → 批量
- 文件:
search_engine.pyL1119-1143 - 问题:N 个 MeSH 词 → 2N 次
db.execute+ 2 次树查询 = 8 轮数据库往返 - 根因:
for m in mesh_names:循环内每次执行 2 次独立查询 - 修复:OR 合并所有词的 entry_terms 和 name_en ILIKE 到 2 次批量查询
第三轮:第三轮审计修复
提交:a37cc50
日期:2026-07-27
数量:14 项(P0×5, P1×4, P3×5)
触发:6 Agent 并行深度代码审计
背景
第三轮审计聚焦于隐藏较深的逻辑缺陷:PubMed 子集/状态/UID 字段的网关检查遗漏、字段到领域的错误映射、括号组的单 NOT 边缘情况、前端日期精度丢失等。
P0 — 搜索结果错误(5 项)
P0-1: sb/stat/uid/dep 门控遗漏
- 文件:
search_engine.py两个has_pubmed_terms检查点(L162-179, L189-205) - 问题:
medline[SB]等解析后产出了pp.sb_terms,但has_pubmed_terms未检查它,导致走纯文本路径,[SB]条件被丢弃 - 根因:
has_pubmed_termsgate 随字段新增未同步更新 - 修复:在条件判断中添加
pp.sb_terms,pp.stat_terms,pp.uid_terms,pp.dep_from
P0-2: [SB] 映射到错误领域
- 文件:
search_engine.pyL830-839 - 问题:
medline[SB]被映射到nlm_subsets(期刊级),但 PubMed 的medline[SB]是指记录级别citation_status=medline - 根因:所有
[SB]值笼统走nlm_subsets overlap路径 - 修复:
MEDLINE→citation_status == "medline"PUBMED→ no-op(所有记录都是 PubMed)- 单字母代码(AIM/S/D 等)→
nlm_subsets overlap(期刊级) - 其他文本 →
citation_status == val.lower()
P0-3: 括号组单 NOT 词丢失否定
- 文件:
search_engine.pyL882 - 问题:
NOT (cancer)— 组内只有 1 个词,len(group_conds) > 1条件失败,NOT 被丢失 - 根因:全 NOT 组的包裹检查是
> 1(针对多词 NOT 组优化),但 1 个词的组也被 NOT 包裹才正确 - 修复:
> 1→>= 1
P0-4: HomeView watch 丢弃参数
- 文件:
HomeView.vue - 问题:
sort,field,retracted,negative_result在watch(searchKey)的 URL 同步中被丢弃 - 修复:把这些参数加入
searchKeycomputed 依赖和 URL 替换逻辑
P0-5: 日期精度丢失
- 文件:
SearchView.vue - 问题:URL 中的
date_from=2025-03-15恢复后变成2025-03-14或丢失 - 根因:使用
date_from/date_toref 存储(受 DatePicker 组件双向绑定影响),而非原始 URL 值 - 修复:增加
urlDateFrom/urlDateToref 直接存储原始日期字符串
P1 — 功能缺失(4 项)
P1-1: [ALL] 未注册
- 文件:
pubmed_query_parser.py - 问题:
[ALL]tag 未在_FIELD_TAG_MAP和_ALL_FIELD_TAGS中注册,导致被is_pubmed_syntax()识别但 tokeniser 不识别 →UNKNOWN_FIELD→ 静默降级 - 修复:在
_FIELD_TAG_MAP添加"ALL": "all",在_ALL_FIELD_TAGS添加"ALL"
P1-2: 独立日期字段降级
- 文件:
pubmed_query_parser.py_dispatch_term - 问题:
2024-01-01[DP]作为日期范围使用时正确,但作为独立字段值时(term.field 非 None 且非范围语法)被降级到plain_terms - 根因:
_dispatch_term缺少term.field为日期字段名称时的独立处理分支 - 修复:
_dispatch_term增加 DP/EDAT/CRDT/MHDA/LR/DCOM/DEP 裸字段处理→设置from=to=日期
P1-3: 浮点日期范围不交换
- 文件:
pubmed_query_parser.py_parse_range - 问题:
2026:2024[DP]只对纯 digit 做了交换,2024-12-01:2024-01-01[DP]这种完整日期不交换 - 修复:elif 增加非 digit ISO 字符串比较 + 交换
P1-5: MeSH entry_terms 大小写不敏感
- 文件:
scripts/import_mesh_full.py - 问题:Entry terms 导入时未统一 lowercase,
@>匹配区分大小写,导致cancer无法匹配Cancer - 修复:
.lower()统一存储
P3 — 健壮性(5 项)
| # | 修复项 | 文件 | 问题 | 修复 |
|---|---|---|---|---|
| P3-2 | MeSH 展开无保护 | search_engine.py |
_expand_mesh_tag_ids 和 expand_atm 的 DB 查询未包裹异常 |
try/except 包裹 DB 查询块 |
| P3-4 | 参数无验证 | features.py |
sort/field/boolean 参数接受任意值 |
field_validator |
| P3-5 | GET 搜索无限制 | literature.py |
超长查询可耗尽资源 | 100 词上限 |
| P3-6 | 中文正则不一致 | query_expansion.py |
中文检测正则与 search_engine 不一致 | [一-鿿㐀-䶿豈-] 同步 |
| P1-8 | page_size 未恢复 | SearchView.vue |
URL 翻页参数丢失 | restoreFromQuery + syncSearchToUrl |
第四轮:字段补全与语义优化
提交:807972d
日期:2026-07-27
数量:11 项
触发:系统跟踪遗留限制的逐个解决
背景
前三轮修复后仍有多项已知限制:8 个 PubMed 字段标签未注册、[OT] 语义过宽(映射到 all)、精确短语用 ILIKE 而非 GIN 索引。第四轮系统性地清理了这些残留项。
字段注册(8 项)
| # | 字段标签 | 映射目标 | 说明 |
|---|---|---|---|
| P4-1 | [Title/Abstract] |
all |
PubMed 长标签,等同 [TIAB] |
| P4-2 | [OAB] |
all |
Other Abstract |
| P4-3 | [WORD] |
all |
Word in text |
| P4-4 | [FI] |
GR 同路径 |
Funder Identifier,搜索 grant_id |
| P4-5 | [SO]/[PL] |
journal |
Source / Place of Publication(近似映射) |
| P4-6 | [GEN] |
gene_symbols JSONB |
基因符号精确搜索(数据覆盖率依赖实际导入) |
| P4-7 | [PMC] |
pmc_id 列 |
PMCID 精确匹配(数据覆盖率依赖实际导入) |
涉及修改:
_ALL_FIELD_TAGSset:新增 8 个标签名_FIELD_TAG_MAP:注册映射关系_SPECIAL_FIELDS:加OT、GEN、PMCParsedPubmedQuery:加ot_terms/gene_terms/pmc_termslist_dispatch_term:加 3 个 elif 分支search_engine.py:两个has_pubmed_termsgate 加新字段
语义修复(3 项)
P4-8: [OT] → keywords JSONB(不再映射到 all)
- 问题:
[OT]映射到 "all" 意味着在标题/摘要/PMID/DOI 全字段搜索,语义过宽。PubMed 的[OT]只搜索 Other Keywords(keywordsJSONB 列) - 修复:
_dispatch_term将OT路由到ot_terms(而非_FIELD_TAG_MAP→all→tiab_terms)_pubmed_conditions增加ot_terms处理块:GlobalLiterature.keywords.cast(JSONB).contains([t.text])_single_term_condition增加 OT 分支
P4-10: phraseto_tsquery 精确短语
- 问题:精确短语
"immune checkpoint"用 ILIKE%immune checkpoint%实现,无法利用 GIN 索引,全表扫描 - 修复:
_field_condition的"all"字段精确短语路径从 ILIKE 改为search_tsv @@ phraseto_tsquery('english', term) - 限制:通配符
*时仍需 ILIKE(tsvector 不支持截词)
引擎改进(1 项)
P4-9: [PMC] 搜索 SQL
_pubmed_conditions增加第 9 块:pmc_id == term.text精确匹配_single_term_condition增加 PMC 分支
第五轮:第 5 轮全面审计修复
提交:275a9f6
日期:2026-07-27
数量:4 项
触发:5 Agent 并行深度审计 + 第 2 轮规划核对
背景
第 4 轮后启动第 5 轮全面审计,5 个 agent 并行扫描 parser、engine、前端、文档。首先与第 2 轮审计计划(F1-F8)逐项核对,确认以下 8 项已在之前轮次完成:
| 计划 ID | 项目 | 完成轮次 | 状态 |
|---|---|---|---|
| F1 | [MH:noexp] 顶层支持 |
第 3/4 轮 | 已修复 |
| F2 | 组内 NOT De Morgan 律 | 第 2 轮 | 已验证正确 |
| F3 | _parse_not_expr NOT NOT 递归 |
第 4 轮 | 已修复 |
| F4 | SearchView Custom Range 日期 | 第 3 轮 | 已工作正常 |
| F5 | HomeView restoreFromUrl | 第 3 轮 | 已工作正常 |
| F6 | precision_mode 死代码 | 此前轮次 | 已移除 |
| F7 | is_oa 死字段 | 此前轮次 | 已注释 unused |
| F8 | _expand_mesh_tag_ids N+1 |
第 3/4 轮 | 已批量优化 |
实际在第 5 轮修复的 4 项均为 parser 边缘案例:
P5-1: 尾部 NOT 导致 IndexError 降级
- 文件:
pubmed_query_parser.py_parse_not_exprL498 - 问题:
cancer NOT— 解析器_is_primary_start包含NOT,隐式 AND 逻辑尝试将 NOT 作为新词开始,但 NOT 之后无 token →peek()越界抛IndexError→ 整个查询降级为纯文本拆分,NOT作为字面搜索词(影响极小但产生异常) - 根因:
_parse_not_expr不检查是否已到 EOF - 修复:消费 NOT token 后立即检查
peek().type == TokenType.EOF,直接返回[]静默忽略
P5-2: 单数字日期格式不被识别
- 文件:
pubmed_query_parser.pyparse_pubmed_queryL679 - 问题:
2024-1-1[DP]— tokeniser 的 DATE 模式只匹配\d{4}-\d{2}-\d{2},单数字月/日被解析为WORD('2024-1-1')→_dispatch_term设置date_from='2024-1-1'→date.fromisoformat()抛ValueError→ 日期条件被静默丢弃 - 根因:tokeniser 前缺少单数字日期归一化
- 修复:在
parse_pubmed_query的 NFKC 归一化后增加YYYY-M-D → YYYY-MM-DD正则替换
P5-3: is_pubmed_syntax 不处理全角字符
- 文件:
pubmed_query_parser.pyis_pubmed_syntaxL652 - 问题:全角括号
[TI]不被\[...\]识别 → 检出失败 → 走纯文本路径(解析器内parse_pubmed_query做 NFKC 但已不会进入) - 根因:
is_pubmed_syntax未做 NFKC 归一化、与parse_pubmed_query行为不一致 - 修复:函数开头增加
query = unicodedata.normalize('NFKC', query)
P5-4: extract_pubmed_query_for_prisma 不能处理 [Title/Article]
- 文件:
pubmed_query_parser.pyextract_pubmed_query_for_prismaL706 - 问题:归一化 regex
\[([\w:]+)\]不含/,[Title/Article]不被匹配、保持原文 - 根因:regex 字符类不含
/ - 修复:
[\w:]→[\w/:]
各轮变更摘要
| 维度 | 第一轮 | 第二轮 | 第三轮 | 第四轮 | 第五轮 |
|---|---|---|---|---|---|
| 修复数 | 34 | 8 | 14 | 11 | 4 |
| 后端文件变更 | 全部 | engine + parser | engine + parser + api | engine + parser | parser |
| 前端文件变更 | SearchView + HomeView + Card | SearchView + HomeView + Panel | SearchView + HomeView | 0 | 0 |
| 测试变更 | 新增 | +6 项 | 已有覆盖 | 0 | 0 |
| 新功能 | [MH]/[EDAT]/[AD]/[LA] 等 |
— | — | [GEN]/[PMC]/[Title/Abstract] |
— |
| 性质 | 从零搭建 | 审计修复 | 深度审计修复 | 字段补全 |
遗留限制
截至 2026-07-27,剩余 7 项已知限制:
| ID | 问题 | 原因 | 影响 |
|---|---|---|---|
| L1 | 混合 AND/OR 优先级(A OR B AND C) |
需 AST 重构,当前扁平列表无法保留嵌套结构 | 低(混合布尔极罕见) |
| L2 | Affiliation JSONB cast 假阳性 | 需独立 affiliation 列 + Alembic 迁移 + 重新填充 | 低 |
| L3 | retracted "yes"="only" | 命名语义,SQL 条件相同 | 无影响 |
| L4 | OR-mode NOT 检测不可靠 | UnaryExpression + _sa_ops.inv 不可靠用于复合 NOT |
低 |
| L6 | 字段标签 REF/ISBN 未注册 | 低使用频率或数据缺失 | 低 |
| L7 | GIN 索引缺失(基因/chemicals 等) | 需 DBA 操作,生产数据量大 | 中(大表性能) |
| L8 | _dispatch_term 回归不可见 |
需字段级测试 | 低 |
附录:测试覆盖统计
| 测试文件 | 用例数 | 范围 |
|---|---|---|
test_pubmed_query_parser.py |
~40 | tokeniser、解析器、语法正确性 |
test_pubmed_search_integration.py |
~60 | 字段映射、API 集成、前端格式 |
test_comprehensive_verify.py |
~27 | 字段完整、NOT 语义、括号、日期 |
| 合计 | 214 | 全部通过 |