はじめに
ブックマークが500件を超えたあたりから、「確かに保存したはずなのに見つからない」が日常になりました。キーワード検索は、自分が保存したときに使った言葉を正確に思い出せないと機能しません。「あの記事、"vector database"って書いてあったっけ、"ベクトルDB"だったっけ」——こういう表記の揺れや言い換えに、キーワード検索は無力です。
そこで、保存したWebページの全文に対してセマンティック検索を実装しました。この記事では、実際に動いているパイプラインの設計判断——特にチャンキングとハイブリッド検索、そして日本語特有の落とし穴について書きます。
全体のアーキテクチャ
パイプラインはシンプルです:
収集 → 本文抽出 → チャンキング → ベクトル化+インデックス → ハイブリッド検索 → 融合ランキング → 返却
ポイントは2つあります。1つ目は「保存時にできるだけの前処理を済ませる」こと。検索時のレイテンシを稼ぐため、チャンキングもベクトル化も保存時に行い、検索時はクエリのベクトル化とランキングだけに集中させます。2つ目は「キーワード検索を捨てない」こと。セマンティック検索という名前ですが、実際はBM25とのハイブリッドです。理由は後述します。
チャンキング戦略
Webページの本文をそのまま1つのベクトルにするのは、情報が薄まってうまくいきません。かといって細かく切りすぎると、文脈が失われて「引用が原文と対応しない」という事故が起きます。
タイトル・本文・ハイライトは分けて扱う
- タイトルは独立したチャンクにし、重みを上げます。タイトルは著者が付けた要約であり、検索クエリとの一致率が最も高いからです。
- 本文は意味段落単位で分割します。固定長(例:500文字)で機械的に切る方法もありますが、段落の途中で切れるとベクトルの質が落ちるため、段落境界を優先し、長すぎる段落だけ固定長で分割します。
- **ハイライト(ユーザーがマークした箇所)**は独立したチャンクにします。ハイライトは「ユーザーが重要だと判断した文」であり、検索意図との相関が非常に高いからです。
オーバーラップとメタデータ
チャンク間には10〜20%程度のオーバーラップを持たせます。境界付近の文脈が両方のチャンクに残ることで、境界で切れたクエリへの耐性が上がります。
また、各チャンクには必ずメタデータを持たせます:ソースURL、タイトル、保存日時。検索結果から原文にジャンプできることは、ブックマーク検索では必須のUXです。引用付き回答(RAG)を作る場合も、このメタデータが引用の対応付けの基礎になります。
ハイブリッド検索:BM25 + ベクトル
なぜベクトルだけではダメなのか
実際に運用して分かったのは、ベクトル検索だけでは拾えないクエリが確実に存在することです:
- 固有名詞:「Qdrantのドキュメント」と検索したとき、ベクトル検索は「ベクトルデータベースのドキュメント全般」を返しがちです。製品名の完全一致はBM25の方が正確です。
- 誤記・略称:「pgvector」を「pgvecter」と打ち間違えた場合、ベクトルは意味的に近いものを返しますが、ユーザーが探しているのは明確にその製品のページです。
融合ランキング
BM25とベクトル検索でそれぞれ上位N件を取得し、RRF(Reciprocal Rank Fusion)で融合します。
def rrf_fusion(bm25_results, vector_results, k=60):
scores = {}
for rank, doc_id in enumerate(bm25_results):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
for rank, doc_id in enumerate(vector_results):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)
RRFの良いところは、スコアのスケールが違う2つの検索器を、重み調整なしで素直に混ぜられることです。後から重みを調整したくなったら、加重和に切り替えれば済みます。
日本語特有の落とし穴
形態素解析なしのBM25は日本語で死ぬ
日本語にはスペースがありません。BM25はトークン単位の一致を見るため、「セマンティック検索」というクエリをそのまま投げても、インデックス側のトークン化と噛み合いません。MeCabやSudachiなどの形態素解析器で分かち書きしてからインデックス化する必要があります。これは英語圏のチュートリアルをなぞるだけでは気づけないポイントでした。
表記揺れ
「検索」「けんさく」「ケンサク」——同じ意味でも表記がバラバラです。対策は2つあります:
- クエリ側で表記バリエーションを展開する:保存時に正規化してしまうのではなく、検索時に関連する表記も一緒に探す
- ベクトル検索をセーフティネットにする:多言語embeddingは表記揺れに比較的強いため、BM25が取りこぼした分をベクトル側で拾う
embeddingモデルの選定
中日英が混ざったクエリを扱う場合、多言語モデル(例:BGE-M3)と日本語特化モデルのどちらを選ぶかが悩みどころです。私の結論は「まず多言語モデルで始めて、日本語クエリの精度が足りなければ特化モデルを試す」です。多言語モデルの日本語性能は年々上がっており、ブックマーク検索の規模では十分なことが多いです。
実装例
なお、このパイプラインは私が開発しているブックマーク管理ツール「LinkWise」のセマンティック検索として実際に動いています。上で書いた設計判断はすべて、この実装での経験に基づいています。
効果と失敗談
導入前後で変わったこと:
- 「言い換えで探す」クエリ(例:「あの、認証周りのベストプラクティスが書いてあった記事」)がヒットするようになった
- 日本語の表記揺れを含むクエリのヒット率が体感で大きく改善した
失敗談もあります。初期の実装ではチャンクを細かく切りすぎて、「検索結果のスニペットと原文の対応が取れない」という事故を起こしました。スニペットだけ見て「これだ」と思って開いたら、前後の文脈がなくて何の話か分からない。チャンクサイズは「検索の当たりやすさ」と「引用の読みやすさ」のトレードオフであり、細かければいいわけではありません。
最小構成で再現するなら
週末で試すなら、この構成で十分動きます:
- ベクトルDB:Qdrant(Dockerで一発起動)またはPostgres + pgvector
- embedding:BGE-M3(多言語、ローカルで動く)
- BM25:形態素解析はSudachi
- 融合:上記のRRFでOK
おわりに
セマンティック検索の実装で一番効いたのは、派手なモデル選びではなく、地味な前処理——チャンキングと日本語の分かち書きでした。ブックマーク検索のような個人用途では、この2つを丁寧にやるだけで体感精度が大きく変わります。