ククログ

株式会社クリアコード > ククログ > > Nominatimが広島平和記念資料館を見つけられない理由とその解決方法

Nominatimが広島平和記念資料館を見つけられない理由とその解決方法

FOSS4G Hiroshima 2026で発表予定の阿部です。 2026年9月3日の15時から「Why Nominatim Can't Find Hiroshima Peace Memorial Museum - and How to Fix It」というタイトルで発表予定です。

英語が全くできないのですが公用語が英語だそうで、英語で発表しなければいけません…。 台本がないと発表が厳しそうなので、英語の台本を作ります。 英語の台本を作る前にまずは日本語で発表内容をまとめよう、というのが本記事です。

はじめに

上述の通り最終的に英語の台本を作りたいので、台本風の記事になります。 セリフだけでわかりにくいところはスライドの内容を引用します。 ちょっと読みづらいかもしれないですがご了承ください。

台本に合わせてスライドを微調整したあとで、スライドも公開するので、そちらもご参照ください。

発表用台本

タイトル

こんにちは。「Nominatim が広島平和記念資料館を見つけられない理由と、その解決方法」というタイトルで発表します。 この会場の近くにある施設が、検索しても出てこないのが気になったので解決方法を考えました。

自己紹介

阿部智晃と申します。日本でソフトウェアエンジニアをしていて、オープンソースの開発、とくに全文検索まわりに取り組んでいます。 所属はクリアコードで、全文検索エンジンの Groonga・PGroongaを開発しています。 今日の発表ではPGroongaを使って検索を改善します。

さっそくやってみる

さっそく今日の発表の課題を確認するためにOpenStreetMapのサイトで検索してみます。 「広島平和記念資料館」で検索します。結果は0件です。 そこで建物名ありの「広島平和記念資料館 本館」や「広島平和記念資料館 東館」で検索するとヒットします。 データは存在しているわけです。

広島平和記念資料館

https://www.openstreetmap.org/ で検索

"広島平和記念資料館"で検索

広島平和記念資料館 本館

"広島平和記念資料館 本館"で検索

広島平和記念資料館 東館

"広島平和記念資料館 東館"で検索

結果のまとめ

クエリー 結果
広島平和記念資料館 0 件
広島平和記念資料館 本館 ヒット
広島平和記念資料館 東館 ヒット

整理すると表の通りです。普通、本館や東館といった建物名を含めて検索することはないと思います。 ということで、普通に「広島平和記念資料館」で検索しても見つからないので、どうして見つからないのか?どうやったら見つかるのか?について今日は発表します。

ポイントは「広島平和記念資料館 本館」や「広島平和記念資料館 東館」のデータはあるのに、「広島平和記念資料館」で検索しても見つからないところです。 これはNominatimの検索の仕組みで発生しています。順に見ていきます。

Nominatim

まずNominatimについてですが、OpenStreetMapで検索を担っているソフトウェアです。 検索結果にも"Nominatim"と表示されていることからもわかります。 OSMデータを使うジオコーダーで、多言語対応もされています。 ただし、CJK(中国語・日本語・韓国語)では、期待通りに検索できないことがあります。

検索の仕組み

「トークン」を使って検索します。名称を単語に分割して照合します。 この単語のことをトークンと呼びます。 名称もクエリーも、同じように分割して突き合わせます。

検索(部分一致)

クエリーの単語を全部含む場所がヒットします。 たとえば「広島平和記念資料館」は「広島平和記念資料館 本館」に含まれるので、本来はヒットするはずです。 これは部分一致なので、大量にヒットすることもあります。 そこで性能のために、内部でいろいろな検索最適化をしています。 実はこの最適化がうまくいかずにヒットしない状況が発生します。

改めて、何が起きているのか?

例で使った「広島平和記念資料館」による検索は「広島平和記念資料館 本館 / 東館」の部分集合なのでヒットするはずです。 しかし、実際はヒットしません。これは最適化が裏目に出ているためです。 具体的にどのように裏目に出ているかはCJK以外でも起こりえる問題なので、詳細は巻末に記載しました。 この発表では特にCJKだとうまく検索できないところに注目して説明していきます。

CJKだとうまくいかない理由

CJKでうまくいかない理由を2つ挙げます。 1つは前述の検索最適化が裏目に出るです。CJKだと特に裏目に出やすいです。 もう1つはトークンに分割された漢字は最終的に中国語の読みで扱うことです。 それぞれについて見ていきます。

1つ目の検索最適化が裏目に出る件は長い名前ほど、分割されるトークンが増え起こりやすくなります。 特に、単語の区切りがはっきりしないCJKで起きやすいです。

2つ目のNominatimはトークンに分割した漢字を日本語読みではなく中国語読みに変換して、それを使って検索します。 すると、日本語の漢字では違う場所なのに、同じ読みになってしまうことがあり、検索の精度が落ちます。 その具体例を見てみます。

例: li yuan

いくつか住所で使われる単語を変換しました。 見た目でわかる通りそれぞれ違う場所です。 しかし変換後はすべて同じ'li yuan'になっていることが確認できます。

from icu import Transliterator
t = Transliterator.createInstance('Any-Latin; Latin-ASCII; Lower()')
for w in ['笠原','栗原','栃原','立原','梨原']:
    print(w, '->', repr(t.transliterate(w)))

# Output:
# 笠原 -> 'li yuan'
# 栗原 -> 'li yuan'
# 栃原 -> 'li yuan'
# 立原 -> 'li yuan'
# 梨原 -> 'li yuan'

例: query "笠原"

今回の発表用に作ったデモ環境で"笠原"で検索してみても、関係のない住所がヒットしていることがわかります。

$ docker compose exec -T -e NOMINATIM_PGROONGA=off nominatim nominatim search --query "笠原" | grep '"display_name"'
        "display_name": "栃原, 大台町, 多気郡, 三重県, 519-2423, 日本",
        "display_name": "立原, 福知山市, 京都府, 620-0917, 日本",
        "display_name": "栃原, 下市町, 吉野郡, 奈良県, 638-0041, 日本",
        "display_name": "栗原, 上郡町, 赤穂郡, 兵庫県, 678-1256, 日本",
        "display_name": "栗原, 度会町, 度会郡, 三重県, 516-1238, 日本",
        "display_name": "栃原, 伯耆町, 西伯郡, 鳥取県, 689-4222, 日本",
        "display_name": "栃原, 美咲町, 久米郡, 岡山県, 日本",
        "display_name": "栗原, 大津市, 滋賀県, 520-0516, 日本",
        "display_name": "栗原, 真庭市, 岡山県, 719-3153, 日本",
        "display_name": "梨原, 佐治町高山, 佐治, 鳥取市, 鳥取県, 689-1312, 日本",

検索のデモ

これらの状況を踏まえて、改めてデモ環境で「広島平和記念資料館」で検索してみます。 デモ環境は中国地方と関西地方のデータのみ登録してあります。 わかっていた結果ですが、何もヒットしません。

$ docker compose exec \
  -e NOMINATIM_PGROONGA=off \
  nominatim \
  nominatim search --query "広島平和記念資料館"
2026-08-06 22:43:19: Using project directory: /nominatim/project
[]

解決策: 全文検索をする

ということで、どうやったら見つかるかの解決方法ですが全文検索を導入します。 具体的にはPGroongaというPostgreSQLの拡張機能を追加して全文検索をします。 SQLで書けるのでORM経由でも使えてお手軽です。 Nominatimに導入する場合も、3ファイルを数行変更するだけで済みます。

PGroongaとは

突然登場したPGroongaについて簡単に説明します。 PGroongaはPostgreSQLの拡張機能で全文検索をするためのものです。 Groongaという全文検索エンジンがベースです。 CJK対応のトークナイザを選べて、今回の環境ではBigramを使っています。 形態素解析のMeCabも使えます。

PGroonga: 簡単に使える

また馴染みのあるSQLから利用できて、使い慣れているLIKEでも全文検索ができます。 今回は、より効率のいい&@演算子を使っています。

Nominatim + PGroonga

Nominatimに導入するにあたっては、トークン化される前の名前そのものであるall_namesという列に対して全文検索するように設定しました。 全文検索なので部分一致でもヒットします。 しかも、新しくalt_nameshort_nameの略称などを足さなくても、見つかる場所が増えます。 ただし、別名が正式名と全然違う場合は、データ追加が必要です。ここは後でもう一度触れます。

変更点(コード断片)

コードの変更点も見てみます。 ちょっとdiffが長いですが、ポイントはop('&@')の部分です。 検索条件に全文検索の条件を追加しています。 既存の検索はそのままで、全文検索を足しただけです。

# src/nominatim_api/search/db_searches/place_search.py
- for lookup in self.lookups:
-   sql = sql.where(lookup.sql_condition(t))
+ lookup_conditions = [lookup.sql_condition(t) for lookup in self.lookups]
+ if self.query_text:
+     name_match_condition = t.c.all_names.op('&@')(self.query_text)
+     if lookup_conditions:
+         sql = sql.where(sa.or_(sa.and_(*lookup_conditions), name_match_condition))
+     else:
+         sql = sql.where(name_match_condition)
+ elif lookup_conditions:
+     sql = sql.where(sa.and_(*lookup_conditions))

PGroongaありでデモ

この変更ありでデモ環境で検索してみます。 今度は期待通り見つかりました。

$ docker compose exec \
  nominatim \
  nominatim search --query "広島平和記念資料館"
2026-08-06 22:59:50: Using project directory: /nominatim/project
[
    {
        "place_id": 1069413,
        "licence": "Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright",
        "osm_type": "way",
        "osm_id": 60867022,
        "lat": "34.3915495",
        "lon": "132.4530979",
        "category": "tourism",
        "type": "museum",
        "place_rank": 30,
        "importance": 9.99999999995449e-06,
        "addresstype": "tourism",
        "name": "広島平和記念資料館東館",
        "display_name": "広島平和記念資料館東館, 2, 平和大通り, 中島町, 中区, 広島市, 広島県, 730-0811, 日本",
        "boundingbox": [
            "34.3912687",
            "34.3918269",
            "132.4527648",
            "132.4534301"
        ]
    },
    {
        "place_id": 1078343,
        "licence": "Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright",
        "osm_type": "way",
        "osm_id": 60867023,
        "lat": "34.3918121",
        "lon": "132.4521048",
        "category": "tourism",
        "type": "museum",
        "place_rank": 30,
        "importance": 9.99999999995449e-06,
        "addresstype": "tourism",
        "name": "広島平和記念資料館本館",
        "display_name": "広島平和記念資料館本館, 平和大通り, 中島町, 中区, 広島市, 広島県, 730-0811, 日本",
        "boundingbox": [
            "34.3916233",
            "34.3920004",
            "132.4516421",
            "132.4525610"
        ]
    }
]

性能(スピード)

性能について触れておきます。 PGroonga用のインデックス生成が増えるので、データのインポートの時間は増えます。 具体的には今回のデモ環境の78万行のデータですと、約3分ほど追加でかかります。 この3分というのはインポート時間全体の16%ほどの時間です。 検索自体は、PostgreSQLの中で約 0.2ミリ秒でした。

性能(サイズ)

PGroongaのインデックスサイズは106MBです。 既存のname_vectorのインデックスと同じくらいで、nameaddress_vectorのインデックスよりは小さいです。 Nominatimに載せても、構築コスト・検索速度とも軽めだと思います。

なぜPostgreSQL拡張なのか

今回は全文検索をするにあたりPostgreSQLの拡張機能を選びました。 これはNominatimの強みのひとつであるPostgreSQLのみで動くことを重視したからです。 別の検索エンジンの用意が不要で、これまでと同様に運用ができます。

提案: Nominatimにプラグイン機構を

PGroongaは一例です。他にも便利な拡張はたくさんあります。 それらをお手軽にNominatimに組み込めるような仕組みをアップストリームに提案していきたい、と思っています。

もう一つの軸: OSMデータ整備

PGroongaの導入でヒット率があがりそうなことは確認できました。 しかし、別名が全然違う場合はそのデータが登録されていなければ検索しても見つかりません。

例: OSMデータの穴

登録されていない例の紹介です。 資料館の通称「原爆資料館」はshort_nameに登録されていないので、「原爆資料館」で検索してもヒットしません。 これはPGroongaを導入した場合も同様です。 データの追加が必要です。 技術とデータの両方が揃うことで、よりよい検索体験が実現できます。

施設 通称 short_name 通称で検索
広島平和記念資料館 原爆資料館 空欄 出ない

CJK共通の課題として

今回は日本語だけで検証しました。 中国語や韓国語でも、同じ問題が発生している可能性が高いと思います。 今回の全文検索を使うアプローチは、原理的には3言語すべてに適用でき、問題を解決できるはずです。

まとめ

まとめです。CJK検索には課題がありました。 PGroongaの活用で、その課題を補えそうです。 しかも構成は変えず、簡単に実現できます。

展望

PGroongaは一例で、「拡張を差し込める仕組み」がNominatimにあると、より便利になりそうです。 また、データ整備も進めることでCJKの検索を良くしていきたいです。

巻末: 前提知識1

最適化が裏目に出る、の補足です。 最適化の前に検索方法の整理からです。

Nominatimのトークンには名前と住所のデータがあります。 「広島平和記念資料館 本館」の例は次の通りです。

「広島平和記念資料館 本館」の例:

  • 名前: 広島 / 平和 / 記念 / 資料館 / 本館
  • 住所: 平和大通り / 中島町 / 中区 / 広島市 / 広島県

巻末: 前提知識2

クエリーも同様にトークン化します。

クエリーが「広島平和記念資料館」のときの例:

広島 / 平和 / 記念 / 資料館

巻末: ヒットしそうだけど…

クエリーのトークンがすべてデータに含まれているので、見つかりそうに見えます。

  • データ: 広島 / 平和 / 記念 / 資料館 / 本館
  • クエリー: 広島 / 平和 / 記念 / 資料館

巻末: 実際の検索

Nominatim はクエリーのどこが名前で、どこが住所なのかを、事前には分かりません。 そこで、実際はクエリーを「名前用」と「住所用」に振り分け、「名前での検索」と「住所での検索」の2つの検索をします。 そのためいろいろな振り分け方を試しますが、この例では次のように振り分けられたとします。

例: クエリー「広島 / 平和 / 記念 / 資料館

  • 名前用: 資料館
  • 住所用: 広島 / 平和 / 記念

巻末: 実際の検索(住所)

先ほどの振り分け例で「住所での検索」に注目すると「記念」がデータに含まれていないことがわかります。 これにより住所でマッチせず、検索結果が0件になる、という事象が起こるのです。

  • データの住所:
    • 平和大通り / 中島町 / 中区 / 広島市 / 広島県
  • クエリー(住所用):
    • 広島 / 平和 / 記念

巻末: 検索最適化

ここまでの説明では振り分け例を1つしか示しませんでした。 実際にはこのような候補をたくさん作成し、その中から一番検索しやすい候補で検索するという最適化が行われています。 その際に今回の例のようなヒットしない候補が選ばれると、ヒットしない状況が生まれます。 実は、ヒットする候補も作られています。 ただ、その候補のコストが高いと判断されると選ばれません。 そのため、ヒットしない候補だけが実行されて0件になる、というわけです。

巻末: 注意

巻末の補足説明はわかりやすさを重視しました。 そのため非正確な説明が多々あります。ご了承ください。

本記事のまとめ

台本形式でブログ記事としてはわかりにくかったと思います、すみません。 当日はこのような内容で発表しますのでよろしくお願いいたします。

参考