Tech Blog

「字面」でなく「意味」で探す — 専用のベクトルDBを足さずに意味検索を実装する

RAG 意味検索 embedding セマンティック検索

はじめに

映像・音楽を扱う自作の総合プラットフォームに、作品の検索を付けている。ふつうのキーワード検索は入っていたが、そこに「意味で探す」を足した。専用のベクトルデータベースを増やさずに、だ。

検索精度そのものを数値で上げていく話は 測定駆動でRAGの検索精度を上げる に書いた。この記事は、「意味で探す」をどう軽く実装したか、の話だ。

実際の画面はこうだ。「意味で探す(AI)」に切り替え、探したい気分を文章のまま入れて探す——「雨の日にひとりで観たい映画」のように。返ってくるのは、その語を含む作品ではなく、意味の近い作品だ。

CINEMA DAYS の意味検索モード。「雨の日にひとりで観たい映画」という文章で探した結果(ポートフォリオ用・作品はデモデータ)


キーワード検索は「字面」しか探せない

キーワード検索は、入力した言葉を含む作品を返す。だから「切ない別れの物語」で探しても、説明文にその言葉が書かれていない名作は、いくら内容が合っていても出てこない。

【キーワード検索】「切ない別れ」─▶ その語を"含む"作品だけヒット(字面の一致)
                                    → 「切ない」と書いていない作品は漏れる

探したいのは字面ではなく、意味だ。


作品もクエリも「意味のベクトル」にする

そこで embedding(埋め込み) を使う。embedding とは、文章の「意味」を数百次元の数値の並び(ベクトル)に変換したものだ。意味が近い文章どうしは、ベクトルとしても近い場所に来る。

作品の説明文をベクトルにし、検索クエリもベクトルにして、ベクトルの近さ(=意味の近さ)が高い順に返す。これが意味検索だ。ベクトル化には、文章を埋め込みに変えるモデル(bge-m3)を、手元の Ollama でローカルに動かして使う。

【意味検索】「切ない別れ」─▶ ベクトル化 ─▶ 意味が近い作品を返す(字面でなく意味)

「近さ」を測るのには コサイン類似度を使う。これは二つのベクトルの向きがどれだけ揃っているかで近さを測る方法で、揃っているほど「意味が近い」とみなす。


この規模なら、専用のベクトルDBは要らない

意味検索と聞くと、たいてい専用のベクトルデータベースを足す。PostgreSQL なら pgvector(DBにベクトル検索機能を足す拡張)を入れる、といった具合だ。標準的な選択肢だが、依存が一つ増え、運用も増える。

だが、扱う作品は約 1,000 件だ。この規模なら、専用DBは要らない。起動時に、全作品のベクトルを一度だけ計算してメモリに載せておき、検索のたびにアプリの中でコサイン類似度を計算するだけで十分に速い。

起動時:全作品を一度ベクトル化し、メモリに「索引」として持つ(約1,000件・bge-m3)

   クエリ文 ─▶ ベクトル化 ─┐
                          ├─▶ メモリ内でコサイン類似 ─▶ 近い順に返す
   索引(全作品のベクトル)─┘

        └─ 同じ索引を使い回す ─┬─ 検索(クエリ → 作品)
                              ├─ 類似(作品 → 似た作品)
                              └─ おすすめ(履歴の平均ベクトル → 作品)

   ※ 専用のベクトルDB(pgvector 等)は足していない

コードにすると、索引の中身はごく素朴だ。

// 起動時に全作品のベクトルをメモリへ。専用のベクトルDBは使わない。
class EmbeddingIndexService {
    private Map<Integer, float[]> vectors;   // 作品ID → 意味ベクトル

    // クエリのベクトルに近い作品を、近い順に返す
    List<Scored> topSimilar(float[] query, int limit) {
        return vectors.entrySet().stream()
            .map(e -> new Scored(e.getKey(), cosine(query, e.getValue()))) // 向きの近さ=意味の近さ
            .sorted(comparingDouble(Scored::score).reversed())
            .limit(limit)
            .toList();
    }
}

そして嬉しいのは、この一つの索引が使い回せることだ。クエリを入れれば「検索」、ある作品を入れれば「この作品に似た作品」、ユーザーの視聴履歴の平均ベクトルを入れれば「あなたへのおすすめ」——同じ topSimilar で全部まかなえる。


意味が近い、は、出してよい、ではない

一つだけ、意味検索には最初から分かっている穴がある。埋め込みは「意味の近さ」しか知らず、種類の違い(映像と音楽)や「出してよいか」といった業務の都合を知らない。 だから、近いというだけで別カテゴリの作品が紛れ込む。

これは意味検索を足した時点でセットで来る問題で、取得した結果を出口でふるいにかける必要がある。その話は ベクトル検索に別カテゴリが紛れ込む罠 に、取得したものを渡す前に関連度で選り分ける話は RAGに「関係あるか」を見分ける小さな判定器を置く に書いた。

軽く始められること自体が、この設計の勘所だった。専用のDBを構える前に、手元のメモリと素朴なコサイン類似で「意味で探す」は十分に立ち上がる。規模が育ってから、必要になった時に専用DBへ移せばいい。最初から重い道具を据えない——それが、この機能で選んだ道だった。


関連する記事

気軽にメッセージください

仕事の依頼、案件紹介、ご感想・ご質問なんでもお待ちしております。 高い志をもった同士の皆様と繋がることを切に願っております。 これからも人生を掛けたチャレンジを続けていきます。 何卒よろしくお願い致します。