開発
ブログに検索機能を追加しました
meilisearchをベースにした、キーワード検索とセマンティック検索に対応した簡易検索モジュール
ブログ検索機能を検討し、試行錯誤の末にmeilisearchを採用しました。PGroongaでは多言語サポートが不十分だったため断念し、ベクトル検索とキーワード検索を組み合わせた手法へ移行。検索結果の質を高めるために、Supabase Edge Functionを活用し、自動インデックス作成を実現しました。
- #ブログ
- #検索機能
- #PGroonga
- #ベクトル検索
- #meilisearch
- #フロントエンド
- #セマンティック検索
- #OpenAI
122
新しいブログが公開されたときには検索機能がありませんでしたが、検索機能はずっと計画されていました。今週は2日かけて、予想よりも早く完成しました。
PGroonga
最初はPostgresqlプラグインのPGroongaを使って検索を実現しようと考えていました。PGroongaは非ラテン文字の言語のインデックスと検索が可能です。この方法は多くの実例があり、ドキュメントも充実していて実現しやすいです。
私のブログには三つのカテゴリーがあり、一度の検索でこれら三つを同時に検索したいと考えていました。
そこで、Materialized Viewを作成し、記事、写真、アイデアの三つのカテゴリーの検索対象フィールドを同じビューにまとめ、データテーブルが更新された時にビューをリフレッシュするトリガーを追加しました。
最後にこの混合ビューにインデックスを作成すれば、検索が可能になります。
フロントエンドの実装も難しくありませんでした。
実際のテストでは、中国語と日本語の検索が可能でしたが、完全一致しかできません。
つまり、たとえば「中华人民共和国」という内容がある場合、「中国」を検索しても結果が出ず、「中华」や「人民」などを検索しなければなりません。これは明らかに使い勝手が悪い検索です。
PGroongaは複数の単語を検索できますが、検索語を分割する必要があります。
しかし、より大きな問題が発生しました:どうやって単語を分割するか?
確かに多くのライブラリが分割を実現できますが、中国語と日本語で別々のライブラリを使う必要があるでしょうか?さらに言語を判断するプログラムを追加する必要があります。これは非常に面倒です。
機能はすでに完成していましたが、私はこのアプローチを断固として放棄しました。
ベクトル検索
ベクトル検索はテキストをベクトルに変換することで、このプロセスはエンベディングと呼ばれます。その後、多次元空間での距離を比較して、二つのテキストのセマンティックな類似度を判断します。
例えば、あるテキストに対して「生活:0.1、技術:0.5、旅行:0.1」のようにラベルとスコアを付け、別のテキストのスコアが「生活:0.1、技術:0.7、旅行:0.2」であれば、これら二つのテキストは内容的に近い可能性が高く、どちらも技術関連の内容です。
実際のベクトル数は数千にも及び、これは単なる簡略化された理解です。
ベクトル検索の優位性は特定のキーワードに限定されず、セマンティックな検索が可能な点です。例えば、レシピデータベースで「大盘鸡」を検索すると、新疆料理や鶏肉を使った他のレシピも見たい場合があります。これらはベクトル検索で実現可能です。
エンベディングのプロセスはAI(当サイトではOpenAIのtext-embedding-ada-002モデルを使用)を利用する必要があり、コストがかかりますが、同じベクトルデータをコンテンツの推薦にも利用できるため、これは将来的に追加する機能です。
Supabase Edge Functionを利用すると、ベクトルデータの自動生成と保存が簡単に実現できます。
ベクトル検索は内容の意味を検索することができますが、キーワード検索よりも正確ではない場合もあります。先述の例では、もしかしたら私は「大盘鸡」の情報だけが欲しいのかもしれません。しかし、ベクトル検索では「大盘鸡」以外のレシピもたくさん出てきます。
最良の方法はキーワードを主体とし、セマンティック検索を補助として利用することです。この時meilisearchが再び私の視野に入りました。
meilisearch
実際のところ、最初からmeilisearchも検討していましたが、できるだけ簡単にしたいという考えで一時保留していました。試行錯誤の結果、meilisearchが現在の最良の解決策であることが分かりました:
1. 多言語の分かち書きとインデックスを自動で行い、データの追加に専念できる;
2. OpenAIのAPIを使用してベクトルデータを生成し、セマンティック検索を実現できる;
3. 類似コンテンツの検索ができ、コンテンツ推薦に利用できる。
さらに、OpenAIのキーを設定することで、エンベディングのプロセスを手動で処理する必要がなく、自動で行ってくれます。
現在のアプローチ
Edge Functionを作成し、該当するテーブルでINSERT、UPDATE、DELETEが行われたときにトリガーされます。前二つの操作は新しいデータをmeilisearchサーバーに送信し、後者は対応するデータを削除します。
typescript
import { serve } from "https://deno.land/std@0.224.0/http/server.ts"; const MEILI_URL = Deno.env.get('MEILI_URL'); const MEILI_KEY = Deno.env.get('MEILI_KEY'); interface Record { id: string; lang?: string; slug?: string; title?: string; subtitle?: string; abstract?: string; content_text?: string; topic?: string; is_draft?: boolean; } interface Payload { type: 'INSERT' | 'UPDATE' | 'DELETE'; table: string; schema: string; record?: Record; old_record?: Record; } async function handleMeilisearch(payload: Payload) { const { type, table, record, old_record } = payload; let url = `${MEILI_URL}/indexes/${table}/documents`; let method = 'POST'; let body; if (type === 'DELETE' || (type === 'UPDATE' && !old_record.is_draft && record.is_draft)) { url = `${url}/${old_record.id}`; method = 'DELETE'; } else if (type === 'INSERT' || type === 'UPDATE') { if (record.is_draft) { console.log(`跳过索引操作:${table} 是草稿状态`); return { skipped: true, reason: 'Draft' }; } const fields = { article: ['id', 'lang', 'slug', 'title', 'subtitle', 'abstract', 'content_text', 'topic'], photo: ['id', 'slug', 'lang', 'title', 'abstract', 'content_text', 'topic'], thought: ['id', 'slug', 'content_text', 'topic'] }; body = JSON.stringify([ fields[table as keyof typeof fields].reduce((obj, field) => { if (record[field as keyof Record] !== undefined) { obj[field] = record[field as keyof Record]; } return obj; }, {} as Record) ]); method = type === 'UPDATE' ? 'PUT' : 'POST'; } const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${MEILI_KEY}` }, body }); if (!response.ok) { throw new Error(`Meilisearch操作失败: ${response.statusText}`); } return response.json(); } serve(async (req) => { try { const payload: Payload = await req.json(); const result = await handleMeilisearch(payload); return new Response(JSON.stringify(result), { headers: { 'Content-Type': 'application/json' } }); } catch (error) { console.error('处理请求时发生错误:', error); return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } });
データがすべてmeilisearchに転送され、documentとして保存され、インデックスが完了すれば、検索が可能です。
検索方法を設定し、ハイライトするフィールドを設定し、ベクトル検索の重み付けを設定するなどを自分で設定できます。以下のコードをbodyに含めてmeilisearchの`/multi-search` endpointに送信することで、詳細な検索方法もドキュメントを参照して決定できます:
typescript
queries: [ { indexUid: "article", q: query, limit: 10, attributesToCrop: ["abstract", "content_text"], cropLength: 24, cropMarker: "...", attributesToHighlight: ["title", "abstract", "content_text", "topic"], highlightPreTag: "<span class=\"text-violet-600\">", highlightPostTag: "</span>", showRankingScore: true, hybrid: { embedder: "default", semanticRatio: 0.4 } }, { indexUid: "photo", q: query, limit: 15, attributesToCrop: ["abstract", "content_text"], cropLength: 24, cropMarker: "...", attributesToHighlight: ["title", "abstract", "content_text", "topic"], highlightPreTag: "<span class=\"text-violet-600\">", highlightPostTag: "</span>", showRankingScore: true, hybrid: { embedder: "default", semanticRatio: 0.5 } }, { indexUid: "thought", q: query, limit: 5, attributesToCrop: ["content_text"], cropLength: 24, cropMarker: "...", attributesToHighlight: ["content_text", "topic"], highlightPreTag: "<span class=\"text-violet-600\">", highlightPostTag: "</span>", showRankingScore: true, hybrid: { embedder: "default", semanticRatio: 0.5 } } ]
最後にフロントエンドで結果をフィルタリングし、rankingScoreに基づいてソートすると、このようなキーワードとファジー検索をサポートし、複数のカテゴリーのコンテンツを検索できる簡単な検索エンジンが完成です。
具体的な効果はナビゲーションバーの検索アイコンをクリックして体験できます。