BigQuery の EDIT_DISTANCE と AI.GENERATE を組み合わせて病名の名寄せ処理を効率化する
こんにちは、 Ubie の @yubessy です。普段は Medical Engineering というチームで医療・医学に関するデータや AI を扱う仕事をしています。
今回は BigQuery の EDIT_DISTANCE と AI.GENERATE を組み合わせて病名の名寄せ処理を効率化した話をします。
病名マスターの名寄せの問題
世の中には数えきれないほどたくさんの種類の病気があります。
が、医療情報をデータとして処理するためには、同じ種類の病気に同じ名前をつけてまとめ、違うものには違う名前をつけて区別できるよう、病名の有限集合としてのマスターが必要です。
Ubie では創業初期から独自の病名マスター (社内病名) を使っていました。これは複数のデータソースをもとに社内の医師が整備したもので、一般の生活者向けにわかりやすい表記を採用したり、医学的にほぼ同じ病気を統合するといった工夫がなされています。
こうした工夫はユーザにとって親しみやすいプロダクトを作るのに役立ってきた一方で、業界標準である ICD10対応標準病名マスター (標準病名) との紐付けが不完全であり、最近ではデータ分析をする上で不便を感じることが多くなっていました。
例えば何らかのがんに罹ったことがあるユーザの数を集計したいとします。そのためには「肺がん」「胃がん」「白血病」などの病名ががんに分類される、という情報が必要です。標準病名にはICD10コードという分類コードが付与されており、これを使えばがんに分類される病名の一覧を出すことができます。しかし社内病名と標準病名の紐付けが不完全であったため、まずは両者のレコードを 名寄せ する必要がありました。
名寄せのための文字列処理
一見この名寄せは難しくなさそうですが、社内病名には2万件を超えるレコードが登録されているため、全て人手でやるのは大変です。また、日本語の病名は次のように表記揺れのパターンが非常に多様なため、機械的にやるのも簡単ではありません。
- 「毛嚢炎」と「毛のう炎」
- 「外耳炎」と「外耳道炎」
- 「注意欠如多動症」と「注意欠陥多動障害」
- 「フォン・ウィルブランド病」と「フォン・ヴィルブランド病」と「フォン・ヴィレブランド病」
- ...
少し試したところ、単なる文字列一致では名寄せできない病名が数千件残りました。また LLM に CSV データを与えて処理させることもやってみましたが、リスト全体を与えるとトークン数が上限を超えてうまくいきませんでした。
そこで今回は、まずシンプルな文字列処理アルゴリズムによって紐付け先の標準病名の候補を列挙し、次に LLM を使って候補の中から最も適した標準病名を選択する、という方法をとりました。
それぞれの病名データはもともと分析用に BigQuery に置かれていたため、 BigQuery の関数を組み合わせるだけで一連の処理を完結させるようにしてみました。
EDIT_DISTANCE による紐付け先候補の列挙
紐付け先候補の列挙では、社内病名のうち標準病名と単純一致しなかったものについて、全ての標準病名との レーベンシュタイン距離 (編集距離) を計算し、距離が近い順に数件を抽出しました。
BigQuery には EDIT_DISTANCE という関数があり、これを使うと2つの文字列間の編集距離を計算できます。これと NFKC 正規化を行う NORMALIZE 関数を使い、社内病名と標準病名の全組み合わせの正規化後編集距離を計算しました。
社内病名テーブルを ubie_diseases, 標準病名テーブルを standard_diseases とすると、次のようなクエリで候補を抽出できます。
WITH
combinations AS (
SELECT
u.name AS ubie_name,
s.name AS standard_name,
-- NFKC 正規化を行った上で編集距離を計算
EDIT_DISTANCE(NORMALIZE(u.name, NFKC), NORMALIZE(s.name, NFKC)) AS distance,
FROM
`project.dataset.ubie_diseases` AS u
CROSS JOIN
`project.dataset.standard_diseases` AS s
)
SELECT
ubie_name,
ROW_NUMBER() OVER (PARTITION BY ubie_name ORDER BY distance ASC) AS idx,
standard_name,
distance,
FROM
combinations
-- 距離が近い上位5件に絞り込む
QUALIFY
idx <= 5
クエリの結果は次のようになります。
| ubie_name | idx | standard_name | distance |
|---|---|---|---|
| 毛のう炎 | 1 | 毛嚢炎 | 2 |
| 毛のう炎 | 2 | 毛様体炎 | 2 |
| A群溶連菌感染症 | 1 | B群溶連菌感染症 | 1 |
| A群溶連菌感染症 | 2 | A群β溶血連鎖球菌感染症 | 4 |
| ... | ... | ... | ... |
| フォン・ウィルブランド病 | 1 | フォン・ヴィレブランド病 | 2 |
| フォン・ウィルブランド病 | 2 | フォン・ヒッペル・リンダウ病 | 6 |
AI.GENERATE による紐付け先の選択
次は LLM を使ってこの中から最も適切な標準病名を選択します。
BigQuery から LLM を呼び出すのには AI.GENERATE 関数を使いました。記事執筆時点ではまだ Preview ですが、 structured output が使えたりパラメータを細かく設定できたりと既存の ML.GENERATE_TEXT よりも使いやすいので、 GA になるのが楽しみです。
前段の結果を candidates とすると、次のようなクエリで LLM を呼び出して紐付け先を選択させることができます。
WITH
-- 候補リストをフォーマット (以下例)
-- - 1: 毛嚢炎
-- - 2: 毛様体炎
candidates_aggregated AS (
SELECT
ubie_name,
STRING_AGG(FORMAT('- %d: %s', idx, standard_name), '\n') AS standard_names,
FROM
candidates
GROUP BY
ubie_name
),
results AS (
SELECT
ubie_name,
AI.GENERATE(
-- プロンプトを作成
FORMAT(
"""
次の病名に対し、医学的に同一、または最も表記として適切なものを以下の標準病名リストから1つだけ選び、その番号を出力してください。
病名: %s
標準病名リスト:
%s
""",
ubie_name,
standard_names
),
connection_id => '...',
endpoint => 'gemini-2.5-flash',
model_params => JSON '''
{
"generation_config": {
"temperature": 0.0,
"max_output_tokens": 10,
"thinking_config": {
"thinking_budget": 0
}
}
}
''',
output_schema => 'idx INT64'
).idx,
FROM
candidates_aggregated
)
SELECT
ubie_name,
idx,
standard_name,
FROM
results
LEFT JOIN combinations
USING(ubie_name, idx)
クエリの結果は次のようになります。
| ubie_name | idx | standard_name |
|---|---|---|
| 毛のう炎 | 1 | 毛嚢炎 |
| A群溶連菌感染症 | 2 | A群β溶血連鎖球菌感染症 |
| フォン・ウィルブランド病 | 1 | フォン・ヴィレブランド病 |
あらかじめ編集距離を使って候補を絞り込むことで入力トークン数を削減し、 structured output を使って出力トークン数も抑えているので、数千件の処理でも数分程度で完了しました。
結果を見てみると、例えば「A群溶連菌感染症」に対して編集距離では「B群溶連菌感染症」が最も近い候補となりますが、 LLM の出力では医学的に正しい「A群β溶血連鎖球菌感染症」を選択できていることがわかります。
結果の最終チェックは社内医師に頼る必要がありますが、人力でやるのに比べて作業がはるかに効率的になりました。
おわりに
LLM の登場によって、テキストデータ処理のベストプラクティスは大きく様変わりしました。ただ個人的には、従来では痒い所に手が届かなかった部分を LLM が肩代わりしてくれることで、むしろ編集距離のようなありふれたアルゴリズムがピンポイントで価値を発揮しやすくなったようにも感じます。
実際の現場で遭遇する問題をちょっとした工夫で解くのもまた楽しいものですね。
Discussion