0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

お薬手帳 OCR による休薬判定アシストの実装 ~取りこぼしゼロと誤検出最小を両立する薬剤名マッチング~

0
Last updated at Posted at 2026-07-04

この記事で扱う問題

医療従事者向けの 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」 の 骨格 が、 同種 の アシスト ツール を 作る 人 に ひとつ の 参考 に なれば 幸い です。


参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?