Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

なぜ私たちは住所正規化エンジンをRustで"再発明"したのか? - FFIによる多言語高速化と開発者体験の裏側

Sansan Engineering Unit マスターデータグループ(データ戦略部門)の松本です。 私たちのチームは、「Activating Business Data」というミッションを掲げ、企業の活動の礎となる重要なデータ、いわゆる「マスターデータ」とその利活用という課題に、技術を駆使して向き合っている組織です。

さて、ビジネスデータを扱う上で「住所」は欠かせない情報です。 それは単に「モノを届ける場所」を示すだけではありません。

  • お客様を深く知るための「解像度」になる: 顧客のオフィスの位置を正確に知ることは、効果的なマーケティングや営業戦略を立てる上で不可欠です。
  • データ統合の「鍵」になる: 複数のサービスやデータベースに散らばったお客様の情報を「同一人物である」と正しく繋ぎ合わせる(名寄せする)際、住所は氏名と並んで最も重要なキー情報となります。

このように、正確な住所データはビジネスの根幹を支える資産です。逆に言えば、不正確な住所データは「届かないDM」「重複した顧客リスト」「誤った経営判断」といった形で、静かにビジネスを蝕んでいくリスクそのものなのです。

しかし、これほど重要な住所データを扱う上で、私たちには無視できない2つの技術的負債がありました。

  • 仕様の乖離と、それが生む顧客課題: 社内には複数の、異なるプログラミング言語で実装されたシステムが存在し、それぞれが個別の住所正規化ロジックを持っていました。結果として仕様の乖離が発生し、名寄せが十分に機能せず、お客様にご不便をおかけする事態が発生していました。
  • パフォーマンスという名の壁: 質の高い顧客体験を提供するべく、一部の名寄せは同期処理で実現しています。しかし、そのロジックの一部は実行に100ms以上かかっており、パフォーマンスがサービスの応答速度を著しく下げるボトルネックとなっていました。

今回は、これらの技術的負債を解消し、ビジネスの礎をより強固なものにした、私たちの挑戦についてご紹介します。

技術選定の舞台裏:なぜRust + FFIだったのか?

これらの課題を根本から解決するため、私はコアとなるロジックを全面的に作り直すという決断をしました。そして、その武器として選んだのが「Rust + FFI」です。

なぜ、数ある選択肢の中からこの組み合わせを選んだのか。その技術選定の過程をご紹介します。

なぜ、言語は「Rust」だったのか?

まず、コアエンジンに使用するプログラミング言語の選定です。 今回の目的である「圧倒的なパフォーマンス」を実現するためには、ネイティブバイナリとしてエンジンを提供することが必須でした。そして、バイナリを配布する以上、そのコードはメモリ安全でなければなりません。メモリ関連のバグは、単なるクラッシュに留まらず、深刻なセキュリティリスクに直結するからです。

これにより、私たちは「GC(ガベージコレクション)のようなランタイムを持たずに最高のパフォーマンスを出しつつ、C/C++のような手動メモリ管理の危険性をいかに回避するか」という課題に直面しました。

  • 伝統的なC/C++はパフォーマンスの要件を満たしますが、メモリ安全性の保証は開発者の規律に完全に依存しており、リスクが高い選択肢でした。
  • 一方、Go言語はGCによってメモリ安全性を確保していますが、そのGCが引き起こす僅かな実行停止(レイテンシの揺らぎ)や、ランタイムを含むバイナリサイズの大きさは、今回の要件において見過ごせないデメリットでした。

このトレードオフを解決したのがRustでした。 Rustは、GCを持たずにC/C++に匹敵するパフォーマンスを実現しながら、コンパイラがコンパイル時にメモリ安全性を保証してくれます。まさに、私たちが直面した課題に対する完璧な答えでした。

加えて、cargoという優れたパッケージマネージャーとビルドシステムが、開発体験を大きく向上させてくれる点も強力な後押しとなりました。

なぜ、連携方法は「FFI」だったのか?

次に、Rustで書いたロジックを、既存のRubyやNode.jsのサービスからどう呼び出すか、という連携方法の選定です。

gRPCやWeb APIでマイクロサービスとして切り出すのが、現代的なアプローチかもしれません。しかし、今回の目的は「ネットワークのレイテンシすらも排除した、極限のパフォーマンス」の実現です。インプロセスで直接関数を呼び出す方法に比べ、ネットワーク越しの通信はどうしてもオーバーヘッドが大きくなります。

そこで採用したのがFFI(Foreign Function Interface)です。 これは、Rustでコンパイルしたネイティブな関数を、共有ライブラリとして他の言語から直接呼び出す仕組みです。これにより、ネットワークレイテンシをゼロにし、まさに「隣の関数を呼び出す」かのような圧倒的な速度を実現できます。 また、エンジンをライブラリとして配布することで、ロジックのバージョン管理を利用する側のパッケージマネージャー(gemやnpmなど)に委ねられるという、運用上の利点もありました。

そして最後に、少しだけ正直な気持ちを付け加えると、エンジニアとして、モダンで安全なシステムプログラミング言語であるRustに深く触れ、そのポテンシャルを最大限に引き出してみたい、という純粋な技術的好奇心と「楽しみ」が、この挑戦を後押ししてくれたことも事実です。

3つの技術的挑戦と工夫

さて、ここからは具体的な開発のハイライトをご紹介します。

1. 日本の複雑な住所仕様との戦い

最初の挑戦は、このプロジェクトの根幹とも言える「日本の住所」というドメインの複雑性でした。SNSなどでも時折話題になりますが、日本の住所表記は世界的に見ても非常に複雑です。「一丁目」と「1-」のような表記は日常茶飯事ですし、「ケ」と「ヶ」の使い分け、さらには市町村合併に伴う新旧地名の混在も考慮しなければなりません。

住所データを正確に扱うため、私は単なる文字列比較ではなく、複数のステップからなる照合ロジックを設計しました。大まかな流れは、「①意味的な正規化」で表記揺れを吸収し、「②特殊なレーベンシュタイン距離」で辞書データとの類似度計算を行い、「③省略への対応」で不完全なデータも救う、というものです。

その具体的な工夫をいくつかご紹介します。

工夫①:文字ではなく「意味」をトークン化して比較

私たちのロジックの最大の特徴は、住所を文字の羅列としてではなく、構成要素である 意味的な単位(トークン)に分割して比較する点です。

例えば、「東京都千代田区一ツ橋一丁目二五番」と「東京都千代田区一ツ橋1-25」は、見た目は大きく異なります。しかし、私たちの正規化エンジンは、これを内部的に 東,京,都,千,代,田,区,1,ツ,橋,1,-,25 のような共通のトークン列に変換します。

これにより、漢数字・アラビア数字・全角・半角といった表面的な違いを完全に吸収し、一般的な曖昧検索ライブラリでは困難なレベルでの本質的な比較を可能にしました。

工夫②:都道府県の省略にも対応する、柔軟な部分一致アルゴリズム

次に、ユーザー入力の不完全さに対応する必要がありました。例えば、「京都市中京区」と入力された場合に、データベース上の正式名称である「京都府京都市中京区」と正しく一致させなければなりません。

この課題を解決するため、私は一般的なレーベンシュタイン距離を応用した、特殊なアルゴリズム(局所編集距離)を採用しました。*1 これにより、文字列全体が一致せずとも、類似度が最も高い部分文字列を検出できます。前方一致や完全一致に頼る多くのシステムと一線を画すこの柔軟性が、データ連携の成功率を劇的に向上させます。

工夫③:「日本の住所」専用チューニング

さらに、汎用的なライブラリでは対応しきれない、日本特有の“クセ”にも対応しています。一例としては次のようなものです。

  • 表記揺れの吸収: 「ヶ」「ケ」「が」「ガ」などを内部的に同一視します。
  • 旧地名表現の除去: 「大字(おおあざ)」「字(あざ)」といった、住所検索ではノイズになりがちな表現を除去します。

こうした泥臭いチューニングを重ねることで、利用者がデータクレンジングに費やす前処理コストを大幅に削減しました。

工夫④:誤マッチを防ぐ、厳格な多段階検証プロセス

しかし、柔軟なマッチングは常に誤マッチのリスクと隣り合わせです。「京都市1-1」と「京都市1-2」のような決定的に違う住所を一致させてはなりません。

そこで、単に類似度が高いというだけで一致とは判定せず、複数の品質ゲートを設けました。例えば、「番地などの数字情報が決定的に違う場合は、他の部分がどれだけ似ていても不一致とする」といった厳格なビジネスルールを適用しています。

この堅牢性により、自動化処理の基盤として安心して利用できる高い信頼性を担保しました。

2. 開発者体験(DX)の最大化:”bundle install”だけで使える配布戦略

高速なエンジンを開発しても、利用者が簡単に使えなければ意味がありません。そこで次に注力したのが、インストール作業を可能な限りシンプルにする「開発者体験(DX)」の向上です。

例えばRubyのmysql2 gemのように、インストール時にソースコードをコンパイルするライブラリは、利用者の環境によってビルドエラーが起きやすいという課題があります。私たちのライブラリで、同様の問題を避ける必要がありました。

そこで私たちは、利用者のマシンでコンパイルを行うのではなく、あらかじめビルド済みのバイナリを各環境向けに配布する方式を採用しました。

この仕組みは、2つのステップで実現しています。

ステップ1:CI/CDによる事前ビルド

まず、GitHub Actionsを使い、社内で利用されているOSとCPUアーキテクチャの組み合わせごとに、バイナリを事前にビルドします。具体的には下記のようなGitHub Actionsの設定ファイルを作成し、crossというツールを利用して各OS向けのバイナリを作成するようにしました。

jobs:
  build:
    name: Release binary
    strategy:
      matrix:
        include:
          - os: ubuntu-latest
            target: x86_64-unknown-linux-gnu
            artifact_name: libyour-library.so
            asset_name: libyour-library-x86_64-linux-gnu.so
          - os: ubuntu-latest
            target: aarch64-unknown-linux-gnu
            artifact_name: libyour-library.so
            asset_name: libyour-library-aarch64-linux-gnu.so
          - os: macos-latest
            target: x86_64-apple-darwin
            artifact_name: libyour-library.dylib
            asset_name: libyour-library-x86_64-apple-darwin.dylib
          - os: macos-latest
            target: aarch64-apple-darwin
            artifact_name: libyour-library.dylib
            asset_name: libyour-library-aarch64-apple-darwin.dylib
          - os: windows-latest
            target: x86_64-pc-windows-msvc
            artifact_name: your-library.dll
            asset_name: your-library-x86_64-windows-msvc.dll
    runs-on: ${{ matrix.os }}
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      - name: Install toolchain
        run: |
          rustup toolchain install stable --profile minimal
          rustup default stable
          cargo install cross
          rustup target add ${{ matrix.target }}

      - name: Cross build with all features
        run: cross build --release --target ${{ matrix.target }} --all-features --verbose

このアプローチにより、各環境に最適化されたバイナリを効率的に用意できます。

ステップ2:インストール時の自動ダウンロード

次に、ビルド済みのバイナリを利用者の手元に届ける仕組みです。配布にはGitHub Packagesなどを利用します。今回はNode.js (npm) での実装を例に紹介しますが、Ruby (gem) でも同様のアプローチで実現しています。

package.jsonpostinstallスクリプトを定義することで、インストール後の自動処理を実行できます。

▼ package.json

{
  "name": "@your-org/your-library",
  "version": "1.2.3",
  "scripts": {
    "postinstall": "node postinstall.mjs"
  }
}

npm installが実行されると、このpostinstallに指定されたpostinstall.mjsが起動します。このスクリプトの役割は、利用者の環境を判別し、適切なバイナリをダウンロードすることです。

スクリプトの主要なロジックは以下のようになります。

▼ postinstall.mjs (主要部分の抜粋)

import os from "os";
import path from "path";
import fs from "fs";

// 1. 実行環境のOSとCPUアーキテクチャから、ダウンロードすべきファイル名を特定する
function getBinaryName() {
  const platform = os.platform(); // 例: 'darwin', 'linux', 'win32'
  const arch = os.arch();       // 例: 'x64', 'arm64'

  if (platform === "darwin" && arch === "arm64") {
    return "libyour-library-aarch64-apple-darwin.dylib";
  }
  if (platform === "linux" && arch === "x64") {
    return "libyour-library-x86_64-linux-gnu.so";
  }
  if (platform === "win32" && arch === "x64") {
    return "your-library-x86_64-pc-windows-msvc.dll";
  }
  // ... 他のプラットフォーム向けの分岐が続く
  throw new Error(`Unsupported platform: ${platform}/${arch}`);
}

// 2. 適切なバイナリをダウンロードして配置する
async function main() {
  const binaryName = getBinaryName();
  const packageVersion = process.env.npm_package_version;
  
  // GitHub ReleasesからバイナリをダウンロードするためのURLを組み立てる
  const url = `https://api.github.com/repos/your-org/your-repo/releases/tags/v${packageVersion}/assets/${binaryName}`;

  // (ここに、fetch APIを使って実際にファイルをダウンロードし、
  //  適切な場所に保存する処理が入ります)
  console.log(`Downloading pre-built binary from ${url}`);
}

main();

このように、os.platform()os.arch()といったNode.jsのAPIを使い、実行環境のOSとCPUアーキテクチャを判定します。そして、ステップ1でビルドしてGitHub Releasesにアップロードしておいたバイナリの中から、自身の環境に合致するものを特定し、ダウンロードして配置します。

この仕組みにより、利用者は自身の環境を意識することなく、以下のような普段通りのコマンドを実行するだけでライブラリを導入できます。

# ターミナルでコマンドを叩くだけ
$ npm install @your-org/your-library
# もしくは
$ bundle install

結果として、環境構築に起因するビルドエラーや、それに関する問い合わせをなくすことができました。これは、開発者全員の生産性向上につながる重要な改善です。

3. パフォーマンスの証明:定量的成果

「高速で高精度」と謳うからには、その性能を客観的な数値で証明しなければなりません。また、一度きりの測定ではなく、継続的に品質を担保し続ける仕組みも不可欠です。

CIに組み込まれた、継続的な品質測定

私たちは、Rustの強力なツールチェインを活用し、複数の観点から品質を測定しました。

  • 処理速度: cargo bench を使い、主要な関数のパフォーマンスをベンチマークとして計測します。
  • 照合精度: cargo test の仕組みの中で、約1万件の実データに近いテストケースを用いて、意図通りの精度が出ているかを常に検証します。このテストデータと期待値は、Gitでは扱いにくい大容量データに対応するバージョン管理ツール「DVC(Data Version Control)」で管理しています。これにより、誰でも同じデータセットでテストを再現でき、精度改善前後の結果を正確に比較できる環境を構築しました。
  • メモリ安全性: FFI境界でのメモリリークを防ぐため、Ruby(gem)やNode.js(npm)からライブラリを繰り返し呼び出すテストを作成し、メモリ使用量に異常がないことを確認しました。

特に、速度と精度のテストはCI(継続的インテグレーション)に組み込んでいます。これにより、コードを修正するたびに性能が劣化(デグレ)していないかを自動でチェックでき、自信を持って改善を繰り返せるサイクルを確立しました。

プログラムのメモリ使用量測定結果

結果:従来比50倍の高速化を達成

ベンチマークの結果、従来Pythonで実装されていたロジックと比較して、平均で約50倍の高速化を達成しました。同期処理のボトルネックとなっていた120msオーダーの処理時間は、平均2msにまで短縮されています。

実用例:数百万件のデータクレンジングが10分で完了

このパフォーマンスは、新たなデータ活用の道も拓きました。

一例として、このRust製エンジンをBigQueryの外部関数(UDF)として呼び出せるようにしたところ、これまでバッチ処理に長時間を要していた数百万件の住所データクレンジングが、わずか10分で完了するようになりました。

これにより、より新鮮なデータを、より短いサイクルでビジネスに活用できるようになっています。

今後の展望

私たちの挑戦は、まだ終わりません。この住所正規化エンジンを、さらに強力なものへと進化させるための計画がすでに動き出しています。

  • さらなるパフォーマンス改善(マイクロ秒の世界へ) 現在でもミリ秒単位の高速な応答を実現していますが、私たちはその先、マイクロ秒単位の応答速度を目指しています。
  • さらなる機能拡張 速度だけでなく、機能面でも進化を続けます。住所文字列から緯度経度を算出する「ジオコーディング機能」に加え、より多様な住所表記への対応を進めます。例えば、半角・全角が混在するカタカナ表記や、海外システムで利用される英語表記の住所など、現在対応しきれていないフォーマットを吸収することで、さらに幅広いデータソースの統合を目指します。

私たちは現状に満足することなく、常に改善のサイクルを回し続けています。

まとめ:あなたの挑戦を、お待ちしています

このプロジェクトを通じて、私たちは技術的な知見以上に、価値ある学びを得ることができました。

1つは、「1つの”車輪の再発明”が、組織全体の車輪を加速させる」ということ。内製した共通基盤が、他チームの開発を加速させ、データ分析の精度を向上させ、組織全体にポジティブな影響が広がっていく様を目の当たりにしました。

実際にこのエンジンを共通基盤として整備したところ、すぐに「このロジックで住所分割もできないか?」「私たちのサービスでも利用したい」といった声が、様々な部署から寄せられるようになりました。

特に、実際に利用したデータ分析の担当者からは、こんな嬉しいフィードバックが届いています。

「当初は予定していなかった追加のクレンジングロジックを試したのですが、このライブラリが抜群に速くて安定していたおかげで、サクッと実装できました。本当に助かりました。」

これは、私たちのエンジンが単に既存の処理を高速化しただけでなく、分析担当者が新しいアイデアを気軽に試せる環境を生み出し、開発のサイクルそのものを加速させている証拠です。

こうした広がりを受け、現在ではデータ戦略部門の垣根を越え、プロダクト開発のサイクル全体でこのライブラリを活用していくという方針が正式に定まり、全社的な基盤としての地位を確立しつつあります。

そしてもう1つは、「一人の裁量と、本質を追求する対話が、最高の価値を生む」ということ。エンジニアが自ら課題を発見し、オーナーシップを持って解決策を探求し、CTO笹川のような立場の人間と対等に技術議論を交わしながら、顧客にとって最適な設計をやり遂げる。この文化こそが、私たちの強みなのだと確信しています。

私たちのチームでは、このような技術的挑戦を共に楽しみ、事業の成長を牽引してくれる仲間を心から募集しています。

少しでも興味を持っていただけたら、ぜひ一度カジュアルにお話ししませんか? 堅苦しい面接ではなく、まずは私たちのチームのこと、技術のことを話しましょう。

Sansan技術本部ではカジュアル面談を実施しています

Sansan技術本部では中途の方向けにカジュアル面談を実施しています。Sansan技術本部での働き方、仕事の魅力について、現役エンジニアの視点からお話しします。「実際に働く人の話を直接聞きたい」「どんな人が働いているのかを事前に知っておきたい」とお考えの方は、ぜひエントリーをご検討ください。

データ戦略部門の募集ポジションはこちら

open.talentio.com

open.talentio.com

*1:Sellers, Peter H. "The theory and computation of evolutionary distances: pattern recognition." Journal of algorithms 1.4 (1980): 359-373.

© Sansan, Inc.