この記事で扱う問題
医療従事者向けの Web アプリ 休薬レンズ (KyuyakuLens) を作っています。 手術・造影 CT の前に「休薬が必要な薬」 を服用中の患者を見落とすと、 出血・血栓・造影剤腎症・乳酸アシドーシスといった重篤な有害事象に直結します。 このアプリは、 スマホでお薬手帳を撮影 (または処方内容を貼付) すると、 Google Cloud Vision で OCR したテキストを 155 剤の休薬対象マスタに照合し、 該当薬が見つかれば警告する、 という判定アシストツールです。
医療現場で使うアシストツールに求められる要件は、 相反する 2 つを両立することです。
- 取りこぼしゼロ (recall 100%): マスタに載っている薬を見逃せば、 それはそのまま医療事故に直結する。 この目的でツールを見ている人間に「あるはずのアラートが出なかった」 は許されない
- 誤検出最小 (precision 高): 関係ない薬に対して逐一「休薬候補です」 と出せば、 現場は「またこのツールか」 とアラート疲れを起こし、 やがて本当のアラートも無視するようになる。 いわゆる alarm fatigue で、 これも安全性の毀損
普通の情報検索では「recall と precision のトレードオフ」 で済ませられますが、 医療安全アシストでは片方を諦めないための工夫が必要でした。 骨格 は 6 step の normalize + kata run 索引 + 3 段 scoring 照合。 実コード込みで書き残します。
実装はブラウザで動く JavaScript の単一ファイル (約 700 行、 依存ゼロ) です。
1. 素朴な部分一致がなぜ両方の要件を満たせないか
最初、 素朴に text.includes(drug.name) から始めましたが、 recall / precision 双方でズタボロに敗北します。 実際に踏んだ失敗パターンを 6 つ、 それぞれどちらの要件を壊すかを明示して並べます。
(a) 用量・剤型・メーカー名がくっつく (取りこぼしと誤検出の両方)
バイアスピリン錠100mg「サワイ」
リクシアナOD錠30mg
マスタは バイアスピリン / リクシアナ の 1 語だけ持っているので、 素朴 includes は片側包含にはなる。 が、 剤型記号を含む長い raw を素朴に扱うと、 メーカー名 サワイ トーワ を含む raw と別の短い薬剤名が誤マッチする経路も生まれる。 「マッチする方向を制御しないと両方壊れる」 パターン。
(b) ひらがな・カタカナ・全角半角の混在 (取りこぼし)
ばいあすぴりん / バイアスピリン / バイアスピリン
表記揺れで単純比較が外れる → 見逃し。
(c) OCR の形似誤読 (取りこぼし)
シアリス → ツアリス (シ↔ツ)
アスピリン → アヌピリン (ス↔ヌ)
擦れた薬手帳・角度の悪い撮影で Vision API が 1〜2 字誤読する。 これを吸収しないと該当患者を丸ごと見落とす。
(d) 濁点・半濁点の 1 字差で別薬剤が存在する (誤検出)
プラビックス vs ブラビックス
シアリス vs ジアリス
「濁点差くらい許せば見逃しが減る」 と安易に思うと、 実在する全然別の薬剤に化ける。 recall のために閾値を緩めると precision が破綻する典型例。
(e) 括弧付き情報 (取りこぼし)
バイアスピリン (アスピリン)
リクシアナ (エドキサバン)
括弧内は一般名・製造メーカーの併記。 剥がしておかないと素朴包含が失敗する。
(f) 配合剤 + mono 剤の共存 (誤検出)
サクビトリル・バルサルタン → mono の バルサルタン も併記マッチしてしまう
デノスマブ・プラリア → デノスマブ・ランマーク (別剤名) に化ける
配合剤の名前は複数の一般名を「・」 で連ねる規則があります。 単純に「バルサルタン」 を含むかで検索すると、 サクビトリル・バルサルタン を服用している患者に対して「mono の バルサルタン (配合剤の弟分)」 も検出される。 患者は片方だけ内服しているので、 これは実質的な誤検出。
2. 骨格: normalize → wildcard 索引 → 3 pass scoring
上 の 6 パターン を 個別 の regex で 潰していく と、 「別剤なのに 1 字差」 型 の ケース (プラビックス vs ブラビックス、 バルサルタン vs サクビトリル・バルサルタン) を 安全 に 扱え なく なる。 骨格 を こう します。
raw text (1 行)
│
▼
[Step 1] normalize
NFKC → ひらがな→カタカナ → 漢字 lookalike (口→ロ 等)
→ 濁点/半濁点 lookalike (ハ° / ハ" / smart quotes / prime / 結合 mark)
→ 長音 strip (ー↔''、 ーー↔ー) → 小書き→大書き
│
▼
[Step 2] segmentByKatakana
非カタカナ を \n (= ワイルドカード) に 置換
「バイアスピリン錠100mg「サワイ」」 → 「バイアスピリン\nサワイ」
│
▼
[Step 3] 3 pass matching
(A) exact indexOf ← 正確な一致 (score = 2 * len)
(B) scored 位置合わせ ← CHAR_ALTS で「似た文字」 に 1 点
(C) edit distance ≤ 1 ← char 挿入/欠落 の 拾い上げ
│
▼
finding { drug, matchVia, matchedSpan, score }
「括弧の中身」「用量」「剤型」 は 全て 「非カタカナ = ワイルドカード」 に 含まれる ので、 個別 regex で 事前 に 剥がす 処理 は 必要 ない (この 抽象化 が 効く 理由 は 節 4 で 詳述)。
以下、 各 step を実コードとともに見ていきます。 各 step の設計判断は「recall を上げる方向」 と「precision を守る方向」 の両方が絡み合っているので、 その都度どちらの要件にどう寄与しているかを明示します。
3. Step 1: normalize (recall の土台、 順序が全て)
一番地味で、 一番効きます。 表記揺れを吸収して recall を確保する土台。 マスタの薬剤名側も raw 側も同じ関数に通して比較する、 それだけです。
function normalizeText(input) {
if (typeof input !== 'string' || input === '') return ''
let s = input.normalize('NFKC')
s = hiraToKata(s) // ひらがな → カタカナ
s = normalizeKanjiLookalike(s) // 口/工/二/力 → ロ/エ/ニ/カ
s = normalizeDakutenLookalikes(s) // ハ° → パ、 ハ" → バ 等
s = normalizeLongVowel(s) // ー / 一 / ― / − 等 を strip
s = s.replace(SMALL_RE, c => SMALL_TO_LARGE[c] || c) // 小書き → 大書き
return s
}
判断ポイントは 6 つ。
(1) NFKC で全角半角・合成文字の揺れを吸収。 バイアスピリン が バイアスピリン になる。 recall 側。
コラム: 濁点・半濁点まわりの静かな取りこぼし 2 種
(その 1) 半角濁点・半濁点が空白で離されているケース
半角の
パはハ(U+FF8A) と゚(U+FF9F) の独立した 2 コードポイントです。 NFKC は互換分解で゚を結合半濁点 (U+309A) に変換し、 canonical composition で直前のハと合成してパ(U+30D1) に統一する — これが仕組みの上での期待動作。ところが
ハ ゚のように間に空白が挟まる入力だと、 NFKC の合成は「隣接した combining mark のみ合成」 のルールで働くため、ハ + 空白 + U+309Aにしかならない。 見た目はパに見えても中身は 2 コードポイントのまま残る。 マスタのパ(1 コードポイント U+30D1) と===比較が silently 失敗し、 静かな取りこぼしになる。(その 2) OCR が濁点・半濁点を形似記号として読み取るケース
印字が擦れた薬手帳では、 濁点 (
゛二つの点) を"(ダブルクォート) や''(アポストロフィ 2 個) 、 半濁点 (゜小さな丸) を°(度記号) や○(白丸) として OCR が読み取ることがあります。 NFKC は救ってくれない (別の文字として認識されるため canonical composition に載らない) 。補正パターンは 2 系統に分けて安全に組みます。 対策コードは (4) を参照。
(2) ひらがな → カタカナ変換。 医療系テキストは大抵カタカナ表記だが、 OCR がひらがな寄りに誤読するケース・paste でひらがなが混じるケースを救う。 code point オフセット + 0x60 の 1 行変換で済む。 recall 側。
(3) 漢字 → カタカナ (形似) の 変換。 OCR が カタカナ を 見た目 が 似た 漢字 と 誤認 する パターン: ロ → 口 (U+53E3 くち)、 エ → 工 (U+5DE5 こう)、 ニ → 二 (U+4E8C に)、 カ → 力 (U+529B ちから) 。 これらを事前に カタカナ に 戻す。 これ を 濁点 / 長音 の 前 に 実施 する のが 重要で、 「口"」 が 「ロ"」 に なって から 濁点 処理 に 渡り、 lookalike と 干渉 しない。 recall 側。
(4) 濁点/半濁点 lookalike の 吸収。 これ が 一番 詰まる 箇所。 「印字が擦れて OCR が濁点マークだけ別の記号に読み替えた」 ケース を 救う。 半濁点 lookalike は [ハヒフヘホ] の 直後 に 限定 (可半濁カナ が この 5 音 だけ なので 誤爆 リスク 極小)、 濁点 lookalike は 可濁カナ + 直後 に 仮名/行末 の 文脈 で 限定 (メーカー suffix の 開き クォート アスピリン"サワイ" を 誤って 濁点 に 化けさせ ない ため)。
const HANDAKU_TARGETS = 'ハヒフヘホ'
const DAKU_TARGETS = 'カキクケコサシスセソタチツテトハヒフヘホウ'
// NFKC 後 の 「半濁点 の ように 見える char」 集合。 NFKC で 「空白+結合 mark」 に 分解される
// 元 char (˚ / ゜) の 結合 mark form も 含める
const HANDAKU_LOOKALIKES = /[°○◦゚̊゜˚]/
// NFKC 後 の 「濁点 の ように 見える char」 集合
// " U+0022 / ' U+0027 ASCII クォート、 ゙ U+3099 / ゛ U+309B 結合・spacing 濁点、
// ′ U+2032 / ″ U+2033 prime、 ‘ ’ “ ” smart quotes
const DAKU_LOOKALIKES = /["'゙゛′″‘’“”]/
function normalizeDakutenLookalikes(s) {
// NFKC 由来 の 「空白 挟み」 を \s* で 吸収、 連続 lookalike (「ハ""」) を + で 吸収
s = s.replace(new RegExp(`([${HANDAKU_TARGETS}])\\s*${HANDAKU_LOOKALIKES.source}+`, 'gu'),
(_, kana) => String.fromCharCode(kana.charCodeAt(0) + 2)) // ハ→パ (+2)
s = s.replace(new RegExp(`([${DAKU_TARGETS}])\\s*${DAKU_LOOKALIKES.source}+(?=[ァ-ヶー]|$)`, 'gu'),
(_, kana) => String.fromCharCode(kana.charCodeAt(0) + 1)) // ハ→バ (+1)
return s
}
recall 側 (OCR 誤読 の 救済) と precision 側 (誤変換 の 抑止) を、 「左に可濁カナ」「右に仮名または行末」 の 2 方向 の 文脈条件 で 挟み込んで 両立。
(5) 長音記号 (ー) の 統一 と 消失。 OCR は 「ー」 を 別 の dash 系 記号 と 誤認 したり、 逆 に 読み落とし たり する。 一 (漢数字 1) / ― / - / − / – / — / ─ / ー / ー を 全 て strip して 「ー が あって も なくて も 同じ」 に 揃える。 「エフピー」 と 「エフピ」 が 同じ に なる。 recall 側。
(6) 小書き → 大書き の 統一。 OCR は 「ッ」 と 「ツ」、 「ャ」 と 「ヤ」 を 混同 する。 全部 大書き に 統一。 「ゼップバウンド」 → 「ゼツプバウンド」、 「マンジャロ」 → 「マンジヤロ」。 recall 側。
順序 が 重要: (3) 漢字 → (4) 濁点 lookalike → (5) 長音 strip → (6) 小書き→大書き の 順。 例えば (6) を 先 に 実施 すると 「口"」 が 「口"」 の まま (口 が 漢字 で 「ロ」 に なって いない ので 濁点 lookalike の 対象外)、 (4) で 「ロ"」 → 「ロ゛」 を 期待 したい の に 空振り する。
4. Step 2: segmentByKatakana (非 kata を ワイルドカード に)
ここが 骨格 の 核心。 マッチング 対象 に なる 「薬剤名 の 識別 情報」 は カタカナ 部分 だけ に 集中 して いる、 と いう 観察 が 基盤。 剤型 (錠 / カプセル / 皮下注)、 用量 (100mg)、 括弧内 (「サワイ」「メーカー名」)、 中黒 (・)、 空白、 全て カタカナ で は ない。
そこで、 normalize 済 text の 非 カタカナ を 全て \n (単一 delimiter) に 置換 する。
const KATA_RE = /[゠-ヺー-ヿ]/ // カタカナ 範囲 (中黒 U+30FB は 除外)
const NON_KATA_RE = /[^゠-ヺー-ヿ]+/gu // + で 連続 非 kata を 単一 \n に
function segmentByKatakana(normalized) {
return normalized.replace(NON_KATA_RE, '\n')
}
これ で:
-
バイアスピリン錠100mg「サワイ」→バイアスピリン\nサワイ -
サクビトリル・バルサルタン→サクビトリル\nバルサルタン -
マンジャロ皮下注2.5mgアテオス→マンジャロ\nアテオス
「非 kata は 何 で あって も 同じ」 = 事実上 ワイルドカード。 STRIP_BRACKETS も STRIP_DOSE も STRIP_FORM も 個別 regex は 一切 要らなく なる (「非 kata」 と 一括 で 扱える)。
5. Step 3: dict 索引 (compound + individual runs + isFullName filter)
各 マスタ 薬剤 の name / brand_names / generic_brands / combination_brands / aliases を 全て normalize + segmentByKatakana に 通して、 2 種 の key を 生成 する:
- compound key: segmented form の 全体 (「サクビトリル\nバルサルタン」)
- individual run: 各 kata run (「サクビトリル」「バルサルタン」)
function buildDict(drugs) {
const kataDict = []
for (const drug of drugs) {
const drugNameRawKataChars = extractSegmentedName(normalizeTextRaw(drug.name)).replace(/\n/g, '').length
const candidates = [drug.name, ...brands, ...aliases]
const drugKataEntries = new Map()
for (const c of candidates) {
const normalized = normalizeText(c)
const compound = extractSegmentedName(normalized) // 「サクビトリル\nバルサルタン」
const runs = extractAllKatakanaRuns(normalized, 2) // ["サクビトリル", "バルサルタン"]
const totalKataChars = compound.replace(/\n/g, '').length
const isFromName = c === drug.name
const keys = []
if (compound.indexOf('\n') >= 0 && totalKataChars >= 2) {
keys.push({ key: compound, isFullName: isFromName && totalKataChars === drugNameRawKataChars })
}
for (const run of runs) {
keys.push({ key: run, isFullName: isFromName && run.length === drugNameRawKataChars })
}
// drug 内 で 同 key は isFullName=true 昇格 を 優先 して 1 件
for (const { key, isFullName } of keys) {
if (key.length < 2) continue
const prev = drugKataEntries.get(key)
if (prev && prev.isFullName) continue
drugKataEntries.set(key, { key, drug, original: c, isDrugName: isFromName, isFullName })
}
}
for (const entry of drugKataEntries.values()) kataDict.push(entry)
}
kataDict.sort((a, b) => b.key.length - a.key.length) // 長い順
return { kataDict }
}
判断 ポイント は 2 つ。
(1) 「compound + individual runs」 の 併用。 compound key (「サクビトリル\nバルサルタン」 13 char) は 長い の で ソート の 先頭 に 来て、 「サクビトリル・バルサルタン」 と いう 完全 な OCR 入力 に 最長 一致 して 先取り する。 これ で mono 剤 の 「バルサルタン」 と 誤マッチ しない (compound が 消費 した 領域 は blank_out され、 mono の individual run キー は そこ を 拾え なく なる)。
一方 で、 短い OCR 入力 (「マンジャロ皮下注」 だけ で アテオス を 含まない 場合) も 個別 run 「マンジヤロ」 で 拾える。 「両方 の 経路 を 用意 して、 長い 方 を 常 に 先 に 走らせる」 の が 味噌。
(2) isFullName filter で mono / compound の 誤爆 抑止。 dict 内 で 同 key を 複数 剤 が 持つ ケース (例 「バルサルタン」 が mono バルサルタン と 配合剤 サクビトリル・バルサルタン の 両方 に 出現) は、 「その 剤 の drug.name 全 kata content を 完全 に カバー する 側」 を 優先 する。 mono バルサルタン に とって は 「バルサルタン」 key = 全 kata content なので isFullName=true、 配合剤 に とって は 個別 run で しかない ので isFullName=false。
function groupDictByKey(dict) {
const map = new Map()
for (const entry of dict) {
if (!map.has(entry.key)) map.set(entry.key, [])
map.get(entry.key).push(entry)
}
// 同 key に isFullName=true が 1 件 でも あれば その 集合 に 絞る
for (const [key, entries] of map) {
const fulls = entries.filter(e => e.isFullName)
if (fulls.length > 0 && fulls.length < entries.length) map.set(key, fulls)
}
...
}
これ で 「バルサルタン」 単独 入力 は mono だけ に hit、 「サクビトリル・バルサルタン」 は compound key で 配合剤 だけ に hit、 と なる。 precision 側。
6. Step 3 の 3 pass: exact → scored → fuzzy
各 段 の 意図 は こう:
- Pass A (exact indexOf): normalize 後 の 完全 一致 (score = 2 * len)。 最速 パス で 最高 優先。
- Pass B (scored 位置合わせ): CHAR_ALTS で 「self=2 点、 alt=1 点、 mismatch=0 点 (却下)」 の 位置 合わせ scoring。 lookalike (ソ↔ン、 濁点対 ハ↔バ↔パ 等) を 拾う。
- Pass C (fuzzy edit distance ≤ 1): OCR の char 挿入/欠落 の 拾い上げ。 長め key に 限定 (fuzzyMinLen=5)。
段 を 分ける 意味 は、 「別 drug の fuzzy が 実剤 の exact を 先取り する」 事故 を 防ぐ ため。 例えば バルデナフィル 入力 で シルデナフィル の fuzzy (シ↔バ 1 char) が 先 に 走って しまう と バルデナフィル 自身 の exact 一致 が 空振り する。 全 key の exact を 走らせて から fuzzy、 と いう 2 段 順序 で 誤マッチ を 抑止 する。
6.1 scored 位置合わせ の 中身
function charMatchScore(ocrChar, dictChar) {
if (ocrChar === dictChar) return 2 // self match
const alts = CHAR_ALTS[ocrChar]
if (alts && alts.indexOf(dictChar) >= 0) return 1 // alt match
return 0 // mismatch → reject
}
function scoredSubstringSearch(haystack, needle) {
let best = null
for (let start = 0; start <= haystack.length - needle.length; start++) {
let score = 0, ok = true
for (let j = 0; j < needle.length; j++) {
const s = charMatchScore(haystack[start + j], needle[j])
if (s === 0) { ok = false; break }
score += s
}
if (!ok) continue
// 前後 が \n or 端 = 境界 チェック
const before = start === 0 || haystack[start - 1] === '\n'
const after = start + needle.length === haystack.length || haystack[start + needle.length] === '\n'
if (!before || !after) continue
if (!best || score > best.score) best = { start, matchLen: needle.length, score }
}
return best
}
「全 char で self か alt が 一致 する」 を 必須条件 に して、 total score が 最大 の 位置 を 返す。 mismatch (score=0) が 1 char でも あれば その 位置 は 却下。
これ が 「OCR 結果 と ぴったり 一致 の 剤 が lookalike で 一致 の 剤 より 高得点 に なり、 誤マッチ を 抑止 する」 の 実体。
6.2 CHAR_ALTS (「似た文字」 テーブル)
字形 形似 pair と 濁点/半濁点 pair の 2 系統 を 収録:
const CHAR_ALTS = {
// (1) 字形 形似 pair
'ソ': ['ン', 'ゾ'], 'ン': ['ソ'],
'シ': ['ツ', 'ジ'], 'ツ': ['シ', 'ヅ'],
'ノ': ['リ'], 'リ': ['ノ'],
'コ': ['ユ', 'ゴ'], 'ユ': ['コ'],
'ク': ['ワ', 'グ'], 'ワ': ['ク'],
'チ': ['テ', 'ヂ'], 'テ': ['チ', 'デ'],
'マ': ['ヌ'], 'ヌ': ['マ'],
'オ': ['ホ'], 'ホ': ['オ', 'ボ', 'ポ'],
// (2) 濁点/半濁点 pair
'ハ': ['バ', 'パ'], 'バ': ['ハ', 'パ'], 'パ': ['ハ', 'バ'],
'ヒ': ['ビ', 'ピ'], 'ビ': ['ヒ', 'ピ'], 'ピ': ['ヒ', 'ビ'],
'フ': ['ブ', 'プ'], 'ブ': ['フ', 'プ'], 'プ': ['フ', 'ブ'],
'ヘ': ['ベ', 'ペ'], 'ベ': ['ヘ', 'ペ'], 'ペ': ['ヘ', 'ベ'],
'ボ': ['ホ', 'ポ'], 'ポ': ['ホ', 'ボ'],
'カ': ['ガ'], 'ガ': ['カ'], 'キ': ['ギ'], 'ギ': ['キ'], 'グ': ['ク'],
'ケ': ['ゲ'], 'ゲ': ['ケ'], 'ゴ': ['コ'], 'サ': ['ザ'], 'ザ': ['サ'],
'ジ': ['シ'], 'ス': ['ズ'], 'ズ': ['ス'], 'セ': ['ゼ'], 'ゼ': ['セ'],
'ゾ': ['ソ'], 'タ': ['ダ'], 'ダ': ['タ'], 'ヂ': ['チ'], 'ヅ': ['ツ'],
'デ': ['テ'], 'ト': ['ド'], 'ド': ['ト'], 'ウ': ['ヴ'], 'ヴ': ['ウ'],
}
これ で 「ホナロン (ボナロン 半濁点消失)」 が アレンドロン酸 (ボナロン は アレンドロン酸 の 商品名) に scoring 経由 で hit する。 exact でも fuzzy でも 拾えて い ない ケース を scoring が 拾う。
「ぴったり 一致 の 剤」 が 「lookalike で 一致 の 剤」 より 高得点 に なる 性質 が 効いて、 実剤 の 名前 が OCR 入力 に 完全一致 する 場合 は そちら が 常 に 優先 される。 「全 char が self or alt」 と いう 必須 条件 で 「短い 候補 が 距離 1 で 誤マッチ する」 型 の 罠 を 構造的 に 防ぐ。
7. kanji route: 純粋 kanji alias 用 の 別経路
「朝鮮人参」「銀杏葉」「高麗人参」 など、 カタカナ が 全く 含まれない alias が マスタ に あります (漢方 系)。 これら は kata route (compound / individual run) で は 索引 化 でき ない (kata run が 0 個)。
対策 と して 「kata run が 皆無 の 候補」 だけ に 対して、 normalize 後 の 全文 を key に する 別 route を 用意 する:
function isKanjiRouteCandidate(normalized) {
const runs = extractAllKatakanaRuns(normalized, 2)
return runs.length === 0
}
function findMatches(text, dict, options = {}) {
...
// ── kanji pass ── normalize 後 の 全体 に substring match、 hit した 領域 は
// \n で blank_out して kata route の 誤爆 を 抑止
let normalized = normalizeText(text)
for (const entry of kanjiDict) {
const key = entry.key
const idx = normalized.indexOf(key)
if (idx < 0) continue
findings.push({ drug: entry.drug, ..., matchVia: 'kanji', score: 2 * key.length + 1 })
normalized = normalized.substring(0, idx) + '\n'.repeat(key.length) + normalized.substring(idx + key.length)
}
// ── kata pass ── blank_out 済 の normalized を segment。 通常 の 3 pass
let working = segmentByKatakana(normalized)
...
}
対 象 は 純粋 kanji の みで、 「低分子ヘパリン」「結合型エストロゲン」「α-トコフェロール」 の よう な 「先頭 は 非 kata だ が kata run が 存在 する」 alias は kata route 側 で 「先頭 に 最も 近い 連続 カタカナ を 抽出」 で 処理 する。 これ は 曖昧 マッチ (低分子ヘパリン → エノキサパリン と ヘパリンナトリウム が 両方 hit する 等) が 残る が、 データ の 制約 と 割り切り、 別 route を 増やさない 方針。
8. 実装 上 の 3 つ の 補足 (静かな 誤検出 の 根絶 と UI 信頼性)
8.1 ASCII 英字略号 は 自動 的 に 除外 される
マスタには EPA (エイコサペンタエン酸)、 OC (経口避妊薬) といった英字略号 を 別名 に 持つ 薬剤 が あります。 raw 側 の 記号 ノイズ EP」 (お薬手帳 の 縦線・鍵括弧 が OCR で 」 に なる) と 距離 1 で マッチ する と 誤検出 に なり かねない。
この 設計 で は そもそも ASCII は 非 kata と して segmentByKatakana で \n に なり、 index の key に なら ない。 明示的 な 除外 コード は 不要。 「アーキテクチャ で 防ぐ」 型 の 解決。
8.2 「マスタ に ない 薬剤」 に よる 誤マッチ は scoring が 自動 で 落とす
オメプラゾール (胃薬)、 マイスリー (睡眠導入剤) は マスタ に 無い 非対象 薬剤 です。 素朴 impl で は raw 側 の プラゾール 部分 文字列 が 対象 内 の プラノバール (経口避妊薬) と 距離 2 で 誤マッチ する 経路 が あり ました。 これ が 「静かな 誤検出」 の 典型。
この 設計 で は プラゾール は 5 char、 プラノバール は 6 char、 position-aligned scoring で 3 char 目 が ゾ vs ノ (CHAR_ALTS に なし → score=0 → 却下) の 段 で 落ちる。 fuzzy edit distance ≤ 1 も 距離 が 大きすぎて 落ちる。 構造 的 に 誤マッチ が 発生 しない ため、 非対象 辞書 の blank_out は 不要。
8.3 span 復元 (highlight UI の 前提)
判定 結果 を UI に 表示 する 時、 「この 行 の この 部分 が バイアスピリン」 と ハイライト します。 現場 の ツール で 「なぜ この 判定 が 出た か」 が 説明 できない = 現場 は 信用 しない。 span を 返す こと で 根拠 可視化 して います。
matchedText は normalize 後 の 形 (「ゼップ」 → 「ゼツプ」、 「ウゴービ」 → 「ウゴビ」)。 raw の 生 文字 と 差 が ある ので、 単純 な raw.indexOf(matchedText) は null を 返す ケース が ある。 対策 は 「normalize(raw) 内 で 位置 を 特定 → raw を 1 char ずつ 進めて normalize 位置 と 対応 させて raw 上 の 開始/終端 を 復元」:
function computeSpan(raw, finding) {
const target = finding.matchedText
// Fast path: raw に そのまま 存在
let idx = raw.indexOf(target)
if (idx >= 0) {
let end = idx + target.length
// 末尾 に normalize で 消失 する char (ー 等) が 続く 場合 は 前方 延長
while (end < raw.length && normalizeText(raw[end]) === '') end += 1
return { start: idx, end }
}
// Slow path: normalize 経由 の back-projection
const normalized = normalizeText(raw)
const nIdx = normalized.indexOf(target)
if (nIdx < 0) return null
const nEnd = nIdx + target.length
let rawPos = 0, normPos = 0, startInRaw = -1, endInRaw = -1
while (rawPos < raw.length) {
if (normPos >= nIdx && startInRaw < 0) startInRaw = rawPos
if (normPos >= nEnd) { endInRaw = rawPos; break }
normPos += normalizeText(raw[rawPos]).length
rawPos += 1
}
...
return { start: startInRaw, end: endInRaw }
}
一般 化 された normalize 逆引き で 全 変換 (小書き→大書き、 長音 strip、 dakuten lookalike、 hira→kata、 NFKC) に 一括 対応 する。 個別 パターン regex は 不要。
9. まとめ: recall と precision、 どちらも譲らない設計
recall / precision の どちら に 寄与 する か で 対策 マップ を 整理 します。
| 対策 | recall 寄与 | precision 寄与 |
|---|---|---|
| NFKC + ひらがな→カタカナ + 漢字 lookalike + 長音 strip + 小書き→大書き | ○ (表記揺れ 6 系統 の 吸収) | — |
| 濁点/半濁点 lookalike の 文脈条件 付き 変換 | ○ (OCR 誤読救済) | ○ (メーカー suffix 誤変換 の 回避) |
| segmentByKatakana (非 kata を \n = ワイルドカード に) | ○ (剤型/用量/括弧 の 一括 吸収) | — |
| 「compound key + individual run」 の 併用 (長い順 索引) | ○ (部分 入力 の 拾い上げ) | ○ (最長 一致 で 配合剤 が mono 剤 に 先取り) |
| isFullName filter (同 key の 剤 内 で 全 kata cover を 優先) | — | ○ (mono / compound の 誤爆 抑止) |
| 3 pass 順 (exact → scored → fuzzy) の 分離 | — | ○ (別 drug の fuzzy が 実剤 の exact を 先取り する 事故 回避) |
| CHAR_ALTS scoring (self=2 alt=1、 全 char 一致 必須) | ○ (形似 + 濁点 pair 救済) | ○ (「ぴったり 一致」 が 高得点 で 誤マッチ 抑止) |
| kanji route (純粋 kanji alias の 全文一致) | ○ (漢方 系 alias) | — |
| span 復元 の normalize 逆引き | — | — (UI 信頼性 の 補助) |
「recall のために normalize を 汎用化 する 時、 必ず 別軸 の 制約 (compound の 最長 一致、 isFullName filter、 CHAR_ALTS の 全 char 一致) を 同時 に 入れる」 が 全体 を 貫く 原則。 「非 kata = wildcard」 と いう 抽象化 で 剤型/用量/括弧/中黒 を 一括 処理 する の が 骨格。
依存パッケージゼロ、 単一ファイル ~700 行の JavaScript で成立します。 バックエンド側 に は 同等 ロジック の PHP 版 を 持ち、 両者は同じ入力に対して同じ finding を返すよう unit test で回帰させています (frontend / backend で挙動が分岐すると、 それ自体が新たな見落とし源になるため)。
医療系日本語 OCR の後段は、 判定を出すかどうか以前に「静かな誤検出を出さないための地味な足元固め」 が主戦場でした。 「アーキテクチャ で 構造的 に 防ぐ」 型 の 設計 = 「normalize → wildcard 索引 → 3 pass scoring」 の 骨格 が、 同種 の アシスト ツール を 作る 人 に ひとつ の 参考 に なれば 幸い です。
参考リンク