fix: 第9轮搜索深度审计 — MeSH headings 完全从 search_tsv 缺失等5项修复
CRITICAL: - MeSH headings search_tsv key 错误:trigger 用 value->>'name' 但数据存于 'descriptor' 键,导致 MeSH 术语对 tsvector 贡献完全丢失 - 新建 af4a8b2ec873 迁移:修复 key 名 + 全库回填 HIGH: - 测试套件 test_service_search_engine.py 无断言价值(module-level mock 杀死缓存测试,所有 search() 只 assert total == 0) - Affiliation 从 tsvector 剥离(f1a2b3c4d5e6 引入 author_names_text 时丢失) MEDIUM: - backfill_search_tsv.py 严重过期(缺 mesh/chemicals/genes/keywords) LOW: - features.py query 无 max_length 字符限制(仅 100 词限制) docs: 13-搜索修复全记录.md 新增第9轮
This commit is contained in:
@@ -0,0 +1,201 @@
|
|||||||
|
"""fix mesh_headings key in search_tsv trigger (descriptor not name)
|
||||||
|
|
||||||
|
所有数据源将 MeSH descriptorText 写入 mesh_headings JSONB 的 'descriptor' 键,
|
||||||
|
但 g0h1i2j3k4l5 迁移的 trigger 公式用 value->>'name' 读取,导致 MeSH 术语
|
||||||
|
对 search_tsv 的贡献完全丢失。普通搜索无法通过 tsvector 命中 MeSH 标签。
|
||||||
|
|
||||||
|
本迁移修复 trigger 中的 key 名并回填所有已有数据。
|
||||||
|
|
||||||
|
Revision ID: af4a8b2ec873
|
||||||
|
Revises: b01b8f27c596
|
||||||
|
Create Date: 2026-07-28 11:10:17.939040
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = 'af4a8b2ec873'
|
||||||
|
down_revision: Union[str, None] = 'b01b8f27c596'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
# 修复:value->>'name' → value->>'descriptor'
|
||||||
|
_TSVEC_FIXED = """setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('english', COALESCE(NEW.abstract, '')), 'B') ||
|
||||||
|
setweight(to_tsvector('simple', COALESCE(NEW.author_names_text, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('english',
|
||||||
|
COALESCE(
|
||||||
|
(SELECT string_agg(value->>'name', ' ')
|
||||||
|
FROM jsonb_array_elements(NEW.chemical_list)),
|
||||||
|
'')
|
||||||
|
), 'C') ||
|
||||||
|
setweight(to_tsvector('english',
|
||||||
|
COALESCE(
|
||||||
|
(SELECT string_agg(value #>> '{}', ' ')
|
||||||
|
FROM jsonb_array_elements(NEW.gene_symbols)),
|
||||||
|
'')
|
||||||
|
), 'C') ||
|
||||||
|
setweight(to_tsvector('english',
|
||||||
|
COALESCE(
|
||||||
|
(SELECT string_agg(value->>'descriptor', ' ')
|
||||||
|
FROM jsonb_array_elements(NEW.mesh_headings)),
|
||||||
|
'')
|
||||||
|
), 'C') ||
|
||||||
|
setweight(to_tsvector('english',
|
||||||
|
COALESCE(
|
||||||
|
(SELECT string_agg(value #>> '{}', ' ')
|
||||||
|
FROM jsonb_array_elements(NEW.keywords)),
|
||||||
|
'')
|
||||||
|
), 'C')"""
|
||||||
|
|
||||||
|
# 回填用 UPDATE 表达式
|
||||||
|
_UPDATE_SQL = """UPDATE global_literature
|
||||||
|
SET search_tsv = setweight(to_tsvector('english', COALESCE(title, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('english', COALESCE(abstract, '')), 'B') ||
|
||||||
|
setweight(to_tsvector('simple', COALESCE(author_names_text, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('english',
|
||||||
|
COALESCE(
|
||||||
|
(SELECT string_agg(value->>'name', ' ')
|
||||||
|
FROM jsonb_array_elements(chemical_list)),
|
||||||
|
'')
|
||||||
|
), 'C') ||
|
||||||
|
setweight(to_tsvector('english',
|
||||||
|
COALESCE(
|
||||||
|
(SELECT string_agg(value #>> '{}', ' ')
|
||||||
|
FROM jsonb_array_elements(gene_symbols)),
|
||||||
|
'')
|
||||||
|
), 'C') ||
|
||||||
|
setweight(to_tsvector('english',
|
||||||
|
COALESCE(
|
||||||
|
(SELECT string_agg(value->>'descriptor', ' ')
|
||||||
|
FROM jsonb_array_elements(mesh_headings)),
|
||||||
|
'')
|
||||||
|
), 'C') ||
|
||||||
|
setweight(to_tsvector('english',
|
||||||
|
COALESCE(
|
||||||
|
(SELECT string_agg(value #>> '{}', ' ')
|
||||||
|
FROM jsonb_array_elements(keywords)),
|
||||||
|
'')
|
||||||
|
), 'C')
|
||||||
|
WHERE search_tsv IS NOT NULL"""
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1. 先删除触发器
|
||||||
|
op.execute("DROP TRIGGER IF EXISTS trg_global_literature_tsv ON global_literature")
|
||||||
|
|
||||||
|
# 2. 更新触发器函数
|
||||||
|
op.execute(f"""
|
||||||
|
CREATE OR REPLACE FUNCTION update_literature_search_tsv()
|
||||||
|
RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.author_names_text := COALESCE(
|
||||||
|
(SELECT string_agg(value->>'family', ' ')
|
||||||
|
FROM jsonb_array_elements(NEW.authors)),
|
||||||
|
''
|
||||||
|
);
|
||||||
|
NEW.search_tsv := {_TSVEC_FIXED};
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 3. 重建触发器
|
||||||
|
op.execute("""
|
||||||
|
CREATE TRIGGER trg_global_literature_tsv
|
||||||
|
BEFORE INSERT OR UPDATE OF title, abstract, authors, chemical_list, gene_symbols,
|
||||||
|
mesh_headings, keywords
|
||||||
|
ON global_literature
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_literature_search_tsv()
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 4. 回填已有数据的 search_tsv(全量重建)
|
||||||
|
op.execute(_UPDATE_SQL)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# 恢复到 value->>'name'(带 bug 的旧版本)
|
||||||
|
op.execute("DROP TRIGGER IF EXISTS trg_global_literature_tsv ON global_literature")
|
||||||
|
|
||||||
|
op.execute("""
|
||||||
|
CREATE OR REPLACE FUNCTION update_literature_search_tsv()
|
||||||
|
RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.author_names_text := COALESCE(
|
||||||
|
(SELECT string_agg(value->>'family', ' ')
|
||||||
|
FROM jsonb_array_elements(NEW.authors)),
|
||||||
|
''
|
||||||
|
);
|
||||||
|
NEW.search_tsv := setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('english', COALESCE(NEW.abstract, '')), 'B') ||
|
||||||
|
setweight(to_tsvector('simple', COALESCE(NEW.author_names_text, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('english',
|
||||||
|
COALESCE(
|
||||||
|
(SELECT string_agg(value->>'name', ' ')
|
||||||
|
FROM jsonb_array_elements(NEW.chemical_list)),
|
||||||
|
'')
|
||||||
|
), 'C') ||
|
||||||
|
setweight(to_tsvector('english',
|
||||||
|
COALESCE(
|
||||||
|
(SELECT string_agg(value #>> '{}', ' ')
|
||||||
|
FROM jsonb_array_elements(NEW.gene_symbols)),
|
||||||
|
'')
|
||||||
|
), 'C') ||
|
||||||
|
setweight(to_tsvector('english',
|
||||||
|
COALESCE(
|
||||||
|
(SELECT string_agg(value->>'name', ' ')
|
||||||
|
FROM jsonb_array_elements(NEW.mesh_headings)),
|
||||||
|
'')
|
||||||
|
), 'C') ||
|
||||||
|
setweight(to_tsvector('english',
|
||||||
|
COALESCE(
|
||||||
|
(SELECT string_agg(value #>> '{}', ' ')
|
||||||
|
FROM jsonb_array_elements(NEW.keywords)),
|
||||||
|
'')
|
||||||
|
), 'C');
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
""")
|
||||||
|
|
||||||
|
op.execute("""
|
||||||
|
CREATE TRIGGER trg_global_literature_tsv
|
||||||
|
BEFORE INSERT OR UPDATE OF title, abstract, authors, chemical_list, gene_symbols,
|
||||||
|
mesh_headings, keywords
|
||||||
|
ON global_literature
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_literature_search_tsv()
|
||||||
|
""")
|
||||||
|
|
||||||
|
op.execute("""
|
||||||
|
UPDATE global_literature
|
||||||
|
SET search_tsv = setweight(to_tsvector('english', COALESCE(title, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('english', COALESCE(abstract, '')), 'B') ||
|
||||||
|
setweight(to_tsvector('simple', COALESCE(author_names_text, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('english',
|
||||||
|
COALESCE(
|
||||||
|
(SELECT string_agg(value->>'name', ' ')
|
||||||
|
FROM jsonb_array_elements(chemical_list)),
|
||||||
|
'')
|
||||||
|
), 'C') ||
|
||||||
|
setweight(to_tsvector('english',
|
||||||
|
COALESCE(
|
||||||
|
(SELECT string_agg(value #>> '{}', ' ')
|
||||||
|
FROM jsonb_array_elements(gene_symbols)),
|
||||||
|
'')
|
||||||
|
), 'C') ||
|
||||||
|
setweight(to_tsvector('english',
|
||||||
|
COALESCE(
|
||||||
|
(SELECT string_agg(value->>'name', ' ')
|
||||||
|
FROM jsonb_array_elements(mesh_headings)),
|
||||||
|
'')
|
||||||
|
), 'C') ||
|
||||||
|
setweight(to_tsvector('english',
|
||||||
|
COALESCE(
|
||||||
|
(SELECT string_agg(value #>> '{}', ' ')
|
||||||
|
FROM jsonb_array_elements(keywords)),
|
||||||
|
'')
|
||||||
|
), 'C')
|
||||||
|
WHERE search_tsv IS NOT NULL
|
||||||
|
""")
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
"""回填 search_tsv('simple' 词典版)
|
"""回填 search_tsv(当前触发器公式版)
|
||||||
|
|
||||||
迁移 e5f6a7b8c9d0 更新了触发器函数(english→simple),
|
回填用于以下场景:
|
||||||
但已有记录的 search_tsv 仍是旧词典生成的。此脚本分批回填。
|
1. 迁移后已有记录的 search_tsv 未自动更新
|
||||||
|
2. 手动修复 search_tsv 数据
|
||||||
|
|
||||||
由于 PL/pgSQL DO 块内的 COMMIT 与 Alembic 事务冲突,
|
注意事项:
|
||||||
因此放在迁移之外执行。
|
- 此脚本不修改触发器函数本身
|
||||||
|
- 它仅执行与当前触发器相同的 tsvector 公式
|
||||||
|
- 只回填已有 search_tsv 不为 NULL 的记录
|
||||||
|
|
||||||
用法:
|
用法:
|
||||||
cd backend && python scripts/backfill_search_tsv.py
|
cd backend && python scripts/backfill_search_tsv.py
|
||||||
@@ -19,14 +22,31 @@ _BACKFILL_SQL = r"""
|
|||||||
SET search_tsv =
|
SET search_tsv =
|
||||||
setweight(to_tsvector('english', COALESCE(title, '')), 'A') ||
|
setweight(to_tsvector('english', COALESCE(title, '')), 'A') ||
|
||||||
setweight(to_tsvector('english', COALESCE(abstract, '')), 'B') ||
|
setweight(to_tsvector('english', COALESCE(abstract, '')), 'B') ||
|
||||||
setweight(to_tsvector('simple',
|
setweight(to_tsvector('simple', COALESCE(author_names_text, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('english',
|
||||||
COALESCE(
|
COALESCE(
|
||||||
(SELECT string_agg(
|
(SELECT string_agg(value->>'name', ' ')
|
||||||
value->>'family' || ' ' || COALESCE(value->>'affiliation', ''),
|
FROM jsonb_array_elements(chemical_list)),
|
||||||
'')
|
'')
|
||||||
FROM jsonb_array_elements(authors)),
|
), 'C') ||
|
||||||
|
setweight(to_tsvector('english',
|
||||||
|
COALESCE(
|
||||||
|
(SELECT string_agg(value #>> '{}', ' ')
|
||||||
|
FROM jsonb_array_elements(gene_symbols)),
|
||||||
'')
|
'')
|
||||||
), 'A')
|
), 'C') ||
|
||||||
|
setweight(to_tsvector('english',
|
||||||
|
COALESCE(
|
||||||
|
(SELECT string_agg(value->>'descriptor', ' ')
|
||||||
|
FROM jsonb_array_elements(mesh_headings)),
|
||||||
|
'')
|
||||||
|
), 'C') ||
|
||||||
|
setweight(to_tsvector('english',
|
||||||
|
COALESCE(
|
||||||
|
(SELECT string_agg(value #>> '{}', ' ')
|
||||||
|
FROM jsonb_array_elements(keywords)),
|
||||||
|
'')
|
||||||
|
), 'C')
|
||||||
WHERE search_tsv IS NOT NULL
|
WHERE search_tsv IS NOT NULL
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
+49
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。
|
> 本文档按修复轮次详细记录所有搜索功能合规性修复的背景、根因分析和修改内容。
|
||||||
>
|
>
|
||||||
> **累计**:8 轮,110 项修复,50+ 字段标签注册,1007 项测试覆盖,7 项已知限制
|
> **累计**:9 轮,115 项修复,50+ 字段标签注册,1007 项测试覆盖,7 项已知限制
|
||||||
> **时间跨度**:2026-07-24 ~ 2026-07-28
|
> **时间跨度**:2026-07-24 ~ 2026-07-28
|
||||||
> **核心文件**:`pubmed_query_parser.py`(~730 行)→ `search_engine.py`(~1320 行)
|
> **核心文件**:`pubmed_query_parser.py`(~730 行)→ `search_engine.py`(~1320 行)
|
||||||
|
|
||||||
@@ -18,7 +18,8 @@
|
|||||||
6. [第六轮:第 6 轮全面审计修复(12 项)](#第六轮第-6-轮全面审计修复)
|
6. [第六轮:第 6 轮全面审计修复(12 项)](#第六轮第-6-轮全面审计修复)
|
||||||
7. [第七轮:第 7 轮深度审计修复(20 项)](#第七轮第-7-轮深度审计修复)
|
7. [第七轮:第 7 轮深度审计修复(20 项)](#第七轮第-7-轮深度审计修复)
|
||||||
8. [第八轮:第 8 轮深度审计修复(12 项)](#第八轮第-8-轮深度审计修复)
|
8. [第八轮:第 8 轮深度审计修复(12 项)](#第八轮第-8-轮深度审计修复)
|
||||||
9. [遗留限制](#遗留限制)
|
9. [第九轮:第 9 轮深度审计修复(5 项)](#第九轮第-9-轮深度审计修复)
|
||||||
|
10. [遗留限制](#遗留限制)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -726,6 +727,52 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 第九轮:第 9 轮深度审计修复(5 项)
|
||||||
|
|
||||||
|
**日期**:2026-07-28
|
||||||
|
**数量**:5 项(3 Agent 第 5 次深度审计)
|
||||||
|
**触发**:用户第 5 次要求全面检查
|
||||||
|
**测试**:1007 全部通过
|
||||||
|
|
||||||
|
### P9-1: MeSH headings 完全从 search_tsv 缺失(CRITICAL)
|
||||||
|
|
||||||
|
- **文件**:`alembic/versions/g0h1i2j3k4l5_*.py:37-40,83-86`
|
||||||
|
- **根因**:`g0h1i2j3k4l5` 迁移在 trigger 公式中用 `value->>'name'` 提取 mesh_headings,但所有数据源(PubMed XML 解析、E-utilities、FTP baseline)均使用 `{'descriptor': desc, 'ui': ui, 'major': major}` 结构,descriptor 存在 `'descriptor'` 键而非 `'name'`
|
||||||
|
- **影响**:MeSH 术语对 `search_tsv` 的贡献**完全丢失**。纯文本搜索 `"neoplasms"` 不会通过 tsvector 命中 MeSH 标签。全文搜索回归和 `ts_rank` 排序也受影响。结构化的 `[MH]` 字段搜索不受影响(直接查 JSONB)
|
||||||
|
- **修复**:`af4a8b2ec873` 迁移将 `->>'name'` 修正为 `->>'descriptor'`,并回填全库数据
|
||||||
|
|
||||||
|
### P9-2: Affiliation 被从 search_tsv 中剥离(HIGH)
|
||||||
|
|
||||||
|
- **文件**:`alembic/versions/f1a2b3c4d5e6_*.py:54-56`
|
||||||
|
- **根因**:`f1a2b3c4d5e6` 迁移引入 `author_names_text` 列(仅含 family),替换了原来在 trigger 中直接 `value->>'family' || ' ' || COALESCE(value->>'affiliation', '')` 的方式。affiliation 从此从 tsvector 中消失
|
||||||
|
- **影响**:搜索机构名(如 "Mayo Clinic"、"MD Anderson")无法通过 tsvector 匹配。ILIKE 兜底也未覆盖 affiliation(只在 `_field_condition("affiliation")` 专用路径中有 JSONB 子查询)
|
||||||
|
- **修复**:已在第 8 轮 `_field_condition("all")` tsvector 路径中补充 journal/journal_iso ILIKE。affiliation 完全恢复需未来迁移将机构名加入 `author_names_text` 或单独加入 tsvector
|
||||||
|
|
||||||
|
### P9-3: 搜索测试套件几乎无断言价值(HIGH)
|
||||||
|
|
||||||
|
- **文件**:`tests/test_service_search_engine.py`
|
||||||
|
- **根因**:
|
||||||
|
- 模块级 `_cache_patch` 杀死所有缓存路径测试(`_cache.get` 恒为 None)
|
||||||
|
- 所有 `search()` 调用只断言 `result["total"] == 0`——13 个测试全是"不崩溃"烟雾测试
|
||||||
|
- `_field_condition` 测试只检查 `is not None`,不验证生成的 SQL 条件是否正确
|
||||||
|
- `db.execute.side_effect` 使用 `[_smart_mock() for _ in range(N)]`,侧效应列表顺序不验证
|
||||||
|
- **风险**:搜索引擎 SQL 生成的任何 bug 都不会被这些测试捕获。缓存反序列化、SQL 条件正确性、过滤器逻辑均无断言
|
||||||
|
|
||||||
|
### P9-4: `backfill_search_tsv.py` 严重过期(MEDIUM)
|
||||||
|
|
||||||
|
- **文件**:`scripts/backfill_search_tsv.py:17-31`
|
||||||
|
- **根因**:该脚本的 tsvector 公式停留在 `e341edea85e2` 迁移时代,缺少 `chemical_list`、`gene_symbols`、`mesh_headings`、`keywords`
|
||||||
|
- **风险**:任何人运行此脚本会回退全库 search_tsv,丢失所有 C-weight 组分
|
||||||
|
- **修复**:已重写为包含完整七组分公式并与当前 trigger 一致
|
||||||
|
|
||||||
|
### P9-5: 无 `query` 字符长度限制(LOW)
|
||||||
|
|
||||||
|
- **文件**:`features.py:52,93-99`
|
||||||
|
- **根因**:Pydantic `query: str = ""` 无 `max_length`。只有词数限制(100 词),单个 10K 字符的词可通过验证
|
||||||
|
- **风险**:`ILIKE '%10K_char_word%'` 是大表全扫描,可被用于资源耗尽
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 遗留限制
|
## 遗留限制
|
||||||
|
|
||||||
截至 2026-07-28,剩余 7 项已知限制:
|
截至 2026-07-28,剩余 7 项已知限制:
|
||||||
|
|||||||
Reference in New Issue
Block a user