programming 2026.01.12

編集距離(レーベンシュタイン距離)の実装と活用 - 検索機能の精度向上

約13分で読めます

ECサイトの商品検索やユーザー入力の候補表示で威力を発揮する編集距離アルゴリズム。動的計画法による実装と実際の活用事例を詳しく解説します。

検索機能でこんな悩みありませんか?

「スマホから『iPhone』と検索したいけど『iPhne』と入力してしまった」「商品名を間違えて入力したお客様に適切な検索結果を表示したい」「ユーザーが途中まで入力した時点で候補を表示したい」

ECサイトやWebアプリケーションを運営していると、こうした入力ミスによる検索の取りこぼしに悩まされることが多いのではないでしょうか。実際に弊社でも、あるクライアント様のECサイトで「商品が見つからない」という問い合わせが月に50件以上ありました。詳しく調査してみると、その多くが入力ミスや表記揺れによるものだったのです。

こうした課題を解決する強力な手法が**編集距離(レーベンシュタイン距離)**を活用した類似度判定です。この記事では、文字列の類似度を数値化し、あいまい検索機能を実現する方法を実践的に解説します。

なぜ編集距離が検索精度向上の鍵となるのか

編集距離とは、ある文字列を別の文字列に変換するために必要な最小の編集操作回数を表す指標です。編集操作には以下の3つがあります:

  • 挿入:文字を追加する(「cat」→「cart」)
  • 削除:文字を削除する(「cart」→「cat」)
  • 置換:文字を変更する(「cat」→「bat」)

先ほどのクライアント様の事例では、編集距離を導入することで検索のヒット率が65%から91%まで向上し、「商品が見つからない」という問い合わせが月5件以下まで減少しました。これは売上機会の損失を防ぐだけでなく、カスタマーサポートの負荷軽減にもつながる大きな成果でした。

動的計画法による編集距離の実装

基本アルゴリズムの理解

編集距離を効率的に計算するには**動的計画法(Dynamic Programming)**を使用します。この手法では、文字列を1文字ずつ比較しながら、最適解を段階的に構築していきます。

以下は、PHPによる基本的な実装例です:

class LevenshteinDistance
{
    /**
     * 編集距離を計算する
     * @param string $str1 比較元文字列
     * @param string $str2 比較先文字列
     * @return int 編集距離
     */
    public static function calculate($str1, $str2)
    {
        $len1 = mb_strlen($str1, 'UTF-8');
        $len2 = mb_strlen($str2, 'UTF-8');
        
        // DPテーブルを初期化
        $dp = array();
        for ($i = 0; $i <= $len1; $i++) {
            $dp[$i] = array_fill(0, $len2 + 1, 0);
        }
        
        // 初期値を設定
        for ($i = 0; $i <= $len1; $i++) {
            $dp[$i][0] = $i;
        }
        for ($j = 0; $j <= $len2; $j++) {
            $dp[0][$j] = $j;
        }
        
        // DPテーブルを埋める
        for ($i = 1; $i <= $len1; $i++) {
            for ($j = 1; $j <= $len2; $j++) {
                $char1 = mb_substr($str1, $i - 1, 1, 'UTF-8');
                $char2 = mb_substr($str2, $j - 1, 1, 'UTF-8');
                
                if ($char1 === $char2) {
                    // 文字が同じ場合、斜め上の値をそのまま使用
                    $dp[$i][$j] = $dp[$i - 1][$j - 1];
                } else {
                    // 文字が異なる場合、3つの操作のうち最小値を選択
                    $dp[$i][$j] = min(
                        $dp[$i - 1][$j] + 1,     // 削除
                        $dp[$i][$j - 1] + 1,     // 挿入
                        $dp[$i - 1][$j - 1] + 1  // 置換
                    );
                }
            }
        }
        
        return $dp[$len1][$len2];
    }
    
    /**
     * 類似度を0-1の範囲で計算する
     * @param string $str1
     * @param string $str2
     * @return float 類似度(1.0が完全一致)
     */
    public static function similarity($str1, $str2)
    {
        $maxLen = max(mb_strlen($str1, 'UTF-8'), mb_strlen($str2, 'UTF-8'));
        if ($maxLen === 0) {
            return 1.0;
        }
        
        $distance = self::calculate($str1, $str2);
        return 1.0 - ($distance / $maxLen);
    }
}

実際の検索機能への組み込み

次に、この編集距離を実際の検索機能に組み込んでみましょう。Laravelを使った商品検索の例です:

class ProductSearchService
{
    /**
     * あいまい検索を実行する
     * @param string $query 検索クエリ
     * @param float $threshold 類似度の閾値(0.0-1.0)
     * @return Collection
     */
    public function fuzzySearch($query, $threshold = 0.6)
    {
        // まず完全一致・部分一致で検索
        $exactResults = Product::where('name', 'LIKE', "%{$query}%")
            ->orWhere('description', 'LIKE', "%{$query}%")
            ->get();
        
        if ($exactResults->count() > 0) {
            return $exactResults;
        }
        
        // 完全一致がない場合、編集距離による検索を実行
        $allProducts = Product::all();
        $fuzzyResults = collect();
        
        foreach ($allProducts as $product) {
            $similarity = LevenshteinDistance::similarity($query, $product->name);
            
            if ($similarity >= $threshold) {
                $product->similarity_score = $similarity;
                $fuzzyResults->push($product);
            }
        }
        
        // 類似度順でソート
        return $fuzzyResults->sortByDesc('similarity_score');
    }
    
    /**
     * 入力候補を生成する
     * @param string $input 部分入力
     * @param int $limit 候補数の上限
     * @return array
     */
    public function getSuggestions($input, $limit = 5)
    {
        if (mb_strlen($input, 'UTF-8') < 2) {
            return [];
        }
        
        $suggestions = Product::all()
            ->map(function ($product) use ($input) {
                return [
                    'name' => $product->name,
                    'similarity' => LevenshteinDistance::similarity($input, $product->name)
                ];
            })
            ->filter(function ($item) {
                return $item['similarity'] >= 0.4;
            })
            ->sortByDesc('similarity')
            ->take($limit)
            ->pluck('name')
            ->toArray();
            
        return $suggestions;
    }
}

JavaScript版の実装

フロントエンド側でリアルタイム候補表示を行う場合のJavaScript実装も紹介します:

class FuzzySearchClient {
    static levenshteinDistance(str1, str2) {
        const len1 = str1.length;
        const len2 = str2.length;
        
        // DPテーブルを初期化
        const dp = Array(len1 + 1).fill(null).map(() => Array(len2 + 1).fill(0));
        
        // 初期値を設定
        for (let i = 0; i <= len1; i++) dp[i][0] = i;
        for (let j = 0; j <= len2; j++) dp[0][j] = j;
        
        // DPテーブルを埋める
        for (let i = 1; i <= len1; i++) {
            for (let j = 1; j <= len2; j++) {
                if (str1[i - 1] === str2[j - 1]) {
                    dp[i][j] = dp[i - 1][j - 1];
                } else {
                    dp[i][j] = Math.min(
                        dp[i - 1][j] + 1,      // 削除
                        dp[i][j - 1] + 1,      // 挿入
                        dp[i - 1][j - 1] + 1   // 置換
                    );
                }
            }
        }
        
        return dp[len1][len2];
    }
    
    static similarity(str1, str2) {
        const maxLen = Math.max(str1.length, str2.length);
        if (maxLen === 0) return 1.0;
        
        const distance = this.levenshteinDistance(str1, str2);
        return 1.0 - (distance / maxLen);
    }
    
    static async getSuggestions(input) {
        if (input.length < 2) return [];
        
        const response = await fetch('/api/products');
        const products = await response.json();
        
        return products
            .map(product => ({
                ...product,
                similarity: this.similarity(input.toLowerCase(), product.name.toLowerCase())
            }))
            .filter(item => item.similarity >= 0.4)
            .sort((a, b) => b.similarity - a.similarity)
            .slice(0, 5);
    }
}

// 使用例
const searchInput = document.getElementById('search-input');
const suggestionsContainer = document.getElementById('suggestions');

searchInput.addEventListener('input', async (e) => {
    const suggestions = await FuzzySearchClient.getSuggestions(e.target.value);
    
    suggestionsContainer.innerHTML = suggestions
        .map(item => `<div class="suggestion-item">${item.name}</div>`)
        .join('');
});

よくある失敗パターンと対処法

編集距離を実装する際によく遭遇する問題と、その解決策を実案件での経験を基に紹介します。

パフォーマンスの問題

失敗例:全商品(10,000件)に対して毎回編集距離を計算していたら、レスポンスが5秒以上かかってしまった。

対処法

  1. 事前計算とキャッシュ:よく検索される単語の組み合わせを事前に計算してRedisに保存
  2. インデックス活用:N-gramインデックスと組み合わせて候補を絞り込んでから編集距離を計算
  3. 非同期処理:リアルタイム候補表示ではWebWorkerを活用
// キャッシュを活用した改良版
class OptimizedSearchService
{
    public function fuzzySearch($query, $threshold = 0.6)
    {
        $cacheKey = "fuzzy_search:{$query}:{$threshold}";
        
        // キャッシュから結果を取得
        $cached = Redis::get($cacheKey);
        if ($cached) {
            return json_decode($cached, true);
        }
        
        // N-gramで候補を絞り込み
        $candidates = $this->getCandidatesByNgram($query);
        
        $results = [];
        foreach ($candidates as $candidate) {
            $similarity = LevenshteinDistance::similarity($query, $candidate->name);
            if ($similarity >= $threshold) {
                $results[] = $candidate;
            }
        }
        
        // 結果をキャッシュ(5分間)
        Redis::setex($cacheKey, 300, json_encode($results));
        
        return $results;
    }
}

日本語処理での問題

失敗例:ひらがな・カタカナ・漢字の表記揺れに対応できず、「コンピュータ」と「コンピューター」が別物として判定された。

対処法:正規化処理を事前に実行

class JapaneseNormalizer
{
    public static function normalize($text)
    {
        // カタカナをひらがなに変換
        $text = mb_convert_kana($text, 'Hc', 'UTF-8');
        
        // 長音符の統一(ー → u)
        $text = preg_replace('/ー/', 'う', $text);
        
        // 小文字の統一
        $text = mb_strtolower($text, 'UTF-8');
        
        // 空白の除去
        $text = preg_replace('/\s+/', '', $text);
        
        return $text;
    }
}

// 使用例
$similarity = LevenshteinDistance::similarity(
    JapaneseNormalizer::normalize($query),
    JapaneseNormalizer::normalize($productName)
);

閾値設定の問題

失敗例:類似度の閾値を0.8に設定したら、ほとんど結果が返されなくなった。逆に0.3にしたら関係ない結果ばかり表示された。

対処法:文字列長に応じた動的な閾値設定

class AdaptiveThreshold
{
    public static function getThreshold($queryLength)
    {
        if ($queryLength <= 3) return 0.5;
        if ($queryLength <= 6) return 0.65;
        if ($queryLength <= 10) return 0.75;
        return 0.8;
    }
}

メモリ使用量の問題

失敗例:長い文字列同士の比較でメモリ不足エラーが発生した。

対処法:最大文字数制限と空間効率化

class MemoryEfficientLevenshtein
{
    public static function calculate($str1, $str2, $maxLength = 100)
    {
        // 文字数制限
        if (mb_strlen($str1, 'UTF-8') > $maxLength) {
            $str1 = mb_substr($str1, 0, $maxLength, 'UTF-8');
        }
        if (mb_strlen($str2, 'UTF-8') > $maxLength) {
            $str2 = mb_substr($str2, 0, $maxLength, 'UTF-8');
        }
        
        // 空間効率化:前の行の情報のみ保持
        $len1 = mb_strlen($str1, 'UTF-8');
        $len2 = mb_strlen($str2, 'UTF-8');
        
        $prevRow = range(0, $len2);
        $currRow = array_fill(0, $len2 + 1, 0);
        
        for ($i = 1; $i <= $len1; $i++) {
            $currRow[0] = $i;
            
            for ($j = 1; $j <= $len2; $j++) {
                $char1 = mb_substr($str1, $i - 1, 1, 'UTF-8');
                $char2 = mb_substr($str2, $j - 1, 1, 'UTF-8');
                
                if ($char1 === $char2) {
                    $currRow[$j] = $prevRow[$j - 1];
                } else {
                    $currRow[$j] = min(
                        $prevRow[$j] + 1,
                        $currRow[$j - 1] + 1,
                        $prevRow[$j - 1] + 1
                    );
                }
            }
            
            // 行を交換
            $temp = $prevRow;
            $prevRow = $currRow;
            $currRow = $temp;
        }
        
        return $prevRow[$len2];
    }
}

無料相談受付中

お気軽にご相談ください(初回無料)

詳しく見る

まとめと次のステップ

編集距離を活用することで、検索機能の精度を大幅に向上させることができます。実際の効果として:

  • 検索ヒット率が65%から91%に向上
  • ユーザーの離脱率が30%減少
  • カスタマーサポートの問い合わせが80%減少

といった成果を多くのクライアント様で実現してきました。

重要なのは、単にアルゴリズムを実装するだけでなく、実際のユーザー体験を考慮した調整を行うことです。文字列の正規化、適切な閾値設定、パフォーマンス最適化など、細部への配慮が成功の鍵となります。

導入前の確認チェックリスト

編集距離アルゴリズムの導入は、単なる技術的な改善以上の価値をもたらします。ユーザーが本当に求めている情報に確実に辿り着けるようになることで、サイト全体の価値向上につながるのです。

検索機能の改善や、より複雑な類似度判定の実装についてご相談がありましたら、お気軽にお問い合わせください。20年以上の実績を持つ弊社が、あなたのWebサイトの検索体験を次のレベルに押し上げるお手伝いをいたします。

この記事をシェア

この記事の内容でお困りですか?

無料でご相談いただけます

Webサイトの改善、システム開発、AI導入など、 お気軽にご相談ください。初回相談は無料です。

無料相談してみる
AIに無料相談