Commit 611548a1 by luoqi

perf(召回): 义齿按颌分支补预过滤 —— 单患者路径快 16.7 倍

archDentureBranch 在展开 exam_findings 的 JSON 数组之前没有任何文本级过滤,
而兄弟分支 RESTORATION_IN_PLACE 那条本来就先在整段文本上过滤了(rsrc 子查询)。
这是漏了,不是有意为之。

ARCH_DENTURE_UPPER_RE / LOWER_RE 两条都要求句中出现「义齿|假牙」,所以整段
exam_findings 里一个都没有时,任何条目都不可能命中 —— 预过滤逻辑上恒等,只剪枝。

2026-08-29 测试库实测:
  exam_findings 是数组的 emr 事实 1,519,829 份,含「义齿|假牙」的仅 7,396(0.49%)
  按患者相关子查询(实际形态,300 患者样本):832.7ms → 49.7ms,快 16.7 倍,结果一致

 注释里写死了一条反向提醒:别用「全表扫」形态验证本优化 —— 那个形态两边都是
81 秒(顺序扫描 + detoast 压倒一切),看不出差别,照着那个数会误以为没用而删掉。
我自己 2026-08-29 就先测错了这一次。批量路径接近全表扫形态,收益不明显;
吃到 16.7 倍的是单患者路径(详情页刷新 / recomputeForPatient / reparse 后定向重算)。

tests/arch-denture-false-missing.spec.ts 新增一组用例锁「预过滤词必须始终是
两条 RE 的必要条件」的蕴含关系 —— 一旦不成立,预过滤会静默丢真命中(少召不报错)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 5ed7ec88
...@@ -25,6 +25,7 @@ import { ...@@ -25,6 +25,7 @@ import {
ARCH_DENTURE_UPPER_RE, ARCH_DENTURE_UPPER_RE,
ARCH_DENTURE_LOWER_RE, ARCH_DENTURE_LOWER_RE,
ARCH_DENTURE_INTENT_EXCLUDE_RE, ARCH_DENTURE_INTENT_EXCLUDE_RE,
ARCH_DENTURE_PREFILTER_RE,
UPPER_ARCH_FIRST_DIGITS_RE, UPPER_ARCH_FIRST_DIGITS_RE,
LOWER_ARCH_FIRST_DIGITS_RE, LOWER_ARCH_FIRST_DIGITS_RE,
} from '@pac/types'; } from '@pac/types';
...@@ -387,6 +388,14 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces { ...@@ -387,6 +388,14 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
WHERE adx.patient_id = p.id WHERE adx.patient_id = p.id
AND adx.type = 'emr_record' AND adx.status IN ('active', 'fulfilled') AND adx.type = 'emr_record' AND adx.status IN ('active', 'fulfilled')
AND adx.content->>'exam_findings' ~ '^\\[' AND adx.content->>'exam_findings' ~ '^\\['
-- ⚡ 预过滤(纯剪枝,逻辑恒等):上下颌两条 RE 都要求句中出现「义齿|假牙」,
-- 整段文本里一个都没有时任何条目都不可能命中 → 先筛掉整份病历,不进 LATERAL 展开。
-- 2026-08-29 测试库实测:exam_findings 是数组的 emr 事实 1,519,829 份,含此二词的
-- 仅 7,396 份(0.49%)。按患者相关子查询形态(本分支的实际形态)300 患者样本:
-- 832.7ms → 49.7ms,**快 16.7 倍**,结果一致。
-- ⛔ 别拿「全表扫」去验证 —— 那个形态两边都 81 秒看不出差别,照着那个数会误删本行。
-- ⛔ 改 ARCH_DENTURE_UPPER_RE / LOWER_RE 时必须同步核对本词仍是它们的必要条件。
AND adx.content->>'exam_findings' ~ ${ARCH_DENTURE_PREFILTER_RE}
-- 🔴 只认**信号诊断那一份病历**:同一份里医生同时写下"缺失"与"有义齿",用后者补前者 -- 🔴 只认**信号诊断那一份病历**:同一份里医生同时写下"缺失"与"有义齿",用后者补前者
-- 没争议;跨次就多一层"这中间会不会变了"的推断。 -- 没争议;跨次就多一层"这中间会不会变了"的推断。
AND adx.content->>'emr_external_id' = sig.content->>'source_encounter_external_id' AND adx.content->>'emr_external_id' = sig.content->>'source_encounter_external_id'
......
...@@ -15,6 +15,7 @@ import { ...@@ -15,6 +15,7 @@ import {
ARCH_DENTURE_UPPER_RE, ARCH_DENTURE_UPPER_RE,
ARCH_DENTURE_LOWER_RE, ARCH_DENTURE_LOWER_RE,
ARCH_DENTURE_INTENT_EXCLUDE_RE, ARCH_DENTURE_INTENT_EXCLUDE_RE,
ARCH_DENTURE_PREFILTER_RE,
UPPER_ARCH_FIRST_DIGITS_RE, UPPER_ARCH_FIRST_DIGITS_RE,
LOWER_ARCH_FIRST_DIGITS_RE, LOWER_ARCH_FIRST_DIGITS_RE,
} from '@pac/types'; } from '@pac/types';
...@@ -23,6 +24,7 @@ import { GAP_FLAGS_BY_PRIMARY } from '../src/modules/clinical-gap/potential-trea ...@@ -23,6 +24,7 @@ import { GAP_FLAGS_BY_PRIMARY } from '../src/modules/clinical-gap/potential-trea
const UP = new RegExp(ARCH_DENTURE_UPPER_RE); const UP = new RegExp(ARCH_DENTURE_UPPER_RE);
const LOW = new RegExp(ARCH_DENTURE_LOWER_RE); const LOW = new RegExp(ARCH_DENTURE_LOWER_RE);
const INTENT = new RegExp(ARCH_DENTURE_INTENT_EXCLUDE_RE); const INTENT = new RegExp(ARCH_DENTURE_INTENT_EXCLUDE_RE);
const PRE = new RegExp(ARCH_DENTURE_PREFILTER_RE);
/** 模拟:该句判出哪几颌有义齿(未发生词一票否决) */ /** 模拟:该句判出哪几颌有义齿(未发生词一票否决) */
const arches = (msg: string): string[] => { const arches = (msg: string): string[] => {
...@@ -125,3 +127,42 @@ describe('表自身自洽', () => { ...@@ -125,3 +127,42 @@ describe('表自身自洽', () => {
expect(on).toEqual(['K08']); expect(on).toEqual(['K08']);
}); });
}); });
/**
* ⚡ SQL 侧的预过滤(potential-treatment-gap.sql:archDentureBranch)在展开 JSON 数组之前
* 先用 ARCH_DENTURE_PREFILTER_RE 把整份 exam_findings 筛一道。那是纯剪枝,**前提是
* 该词必须始终是上下颌两条 RE 的必要条件** —— 一旦不是,预过滤就会静默丢掉真命中
* (少召,不报错)。这组用例就是锁这个蕴含关系的。
*
* 实测依据(2026-08-29 测试库):exam_findings 是数组的 emr 事实 1,519,829 份,
* 含「义齿|假牙」的仅 7,396 份(0.49%),预过滤剪掉 99.5% 的无用展开。
*/
describe('⚡ 预过滤词必须是两条 RE 的必要条件(改 RE 时这组会先炸)', () => {
const 会命中的句子 = [
'上下颌吸附性义齿修复,下颌固位可,上颌固位稍差',
'牙缺失,口内活动义齿修复,上颌义齿卡环紧,不易取戴,下颌义齿无法完全就位',
'上颌活动义齿在位',
'下半口假牙尚可',
'全口义齿修复',
'上下全口假牙使用中',
'上颌见活动义齿,基托边缘密合',
];
it.each(会命中的句子)('命中 RE 的句子必然含预过滤词:%s', (msg) => {
expect(UP.test(msg) || LOW.test(msg)).toBe(true); // 前提:这些确实命中
expect(PRE.test(msg)).toBe(true); // 结论:那就一定含预过滤词
});
it('⛔ 反向:不含预过滤词的文本,两条 RE 都不可能命中(否则预过滤会丢真命中)', () => {
for (const msg of [
'上颌见残根,下颌牙列完整',
'全口牙石(+),牙龈红肿',
'上颌种植体冠在位,边缘密合', // 修复体在位但非义齿 → 归按条目那条分支管
'下颌固定桥完好',
]) {
expect(PRE.test(msg)).toBe(false);
expect(UP.test(msg)).toBe(false);
expect(LOW.test(msg)).toBe(false);
}
});
});
...@@ -730,6 +730,35 @@ export const ARCH_DENTURE_UPPER_RE = ...@@ -730,6 +730,35 @@ export const ARCH_DENTURE_UPPER_RE =
export const ARCH_DENTURE_LOWER_RE = export const ARCH_DENTURE_LOWER_RE =
'(下颌|下半口)[^。;;]{0,10}(义齿|假牙)|(上下颌|全口|上下全口)[^。;;]{0,10}(义齿|假牙)'; '(下颌|下半口)[^。;;]{0,10}(义齿|假牙)|(上下颌|全口|上下全口)[^。;;]{0,10}(义齿|假牙)';
/**
* 预过滤词 —— **纯性能剪枝,逻辑上恒等,不改任何判定结果**。
*
* 上面两条 RE 都要求句子里出现「义齿」或「假牙」(两个分支各自的第二个捕获组都是
* `(义齿|假牙)`),所以整段 exam_findings 文本里若一个都没有,任何条目都不可能命中。
* 于是可以在**展开 JSON 数组之前**先用它把整份病历筛掉。
*
* 🔴 为什么必须加(2026-08-29 测试库实测):
* exam_findings 是数组的 emr 事实 1,519,829 份,其中含「义齿|假牙」的只有 7,396 份
* —— **0.49%**。不加预过滤时这条分支要对 152 万份病历做 `jsonb_array_elements`
* 横向展开,再逐条目 unnest 牙位(全口条目一条炸 28~32 行)。
* ⚡ 按患者相关子查询(**实际使用的形态**,先走 patient_id 索引再过滤),300 患者样本:
* 无预过滤 832.7ms → 有预过滤 49.7ms,**快 16.7 倍**,结果一致(均 5 行)。
*
* ⛔ 别用「全表扫」的写法去验证本优化 —— 那个形态下两边都是 81 秒、看不出差别
* (顺序扫描 + detoast 压倒一切,少展开几百万行无关紧要),照着那个数会误以为它没用
* 而把它删掉。2026-08-29 我自己就先测错了这一次。批量路径(runAllForHost)接近全表扫
* 形态,收益不明显;真正吃到 16.7 倍的是**单患者路径**(详情页刷新 /
* recomputeForPatient / reparse 后的定向重算)。
*
* 兄弟分支 [[RESTORATION_IN_PLACE_TERMS_RE]] 那条本来就先在整段文本上过滤了
* (见 potential-treatment-gap.sql 的 rsrc 子查询),这条是漏了,不是有意为之。
*
* ⛔ 改上面两条 RE 时必须同步检查本词:**本词必须始终是那两条 RE 的必要条件**,
* 否则预过滤会开始丢真命中,而且是静默少召。tests/arch-denture-false-missing.spec.ts
* 有一条用例专门锁这个蕴含关系。
*/
export const ARCH_DENTURE_PREFILTER_RE = '义齿|假牙';
/// 未发生 —— 命中即不作数(「建议上颌活动义齿修复」是还没做) /// 未发生 —— 命中即不作数(「建议上颌活动义齿修复」是还没做)
export const ARCH_DENTURE_INTENT_EXCLUDE_RE = '建议|推荐|拟|打算|要求|考虑|择期|待|计划做|未行|未做'; export const ARCH_DENTURE_INTENT_EXCLUDE_RE = '建议|推荐|拟|打算|要求|考虑|择期|待|计划做|未行|未做';
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment