AkinFind は、文章を貼るだけで「意味の近い文」を探せる検索ツールです。多言語の文埋め込みモデルをブラウザ内で動かすので、言葉が違っても意味で引っかかります(「昼ごはん」で「ランチ」が出る)。文章はどこにも送りません。
#🎯 キーワード検索では拾えないもの
Ctrl+F は「文字が一致するか」しか見ません。だから
- 「昼ごはん」で検索しても「ランチ」は出ない
- 「解約したい」で検索しても「退会の方法」は出ない
- 英語のメモと日本語のメモは互いに見つからない
議事録やメモが溜まると、この取りこぼしが効いてきます。意味で引ける検索があれば解決しますが、たいていは文章をどこかのサーバーに送る必要があります。仕事のメモを外に出せない場面は多い。
そこでモデルごとブラウザに持ってくる構成にしました。
#🧭 文埋め込みとコサイン類似度
やっていることは 2 段です。
- 各文をモデルに通して**ベクトル(数百次元の数列)**にする
- ベクトル同士のコサイン類似度で近さを測る
モデルは Xenova/multilingual-e5-small を transformers.js 経由で使っています。多言語対応なので、日本語と英語をまたいだ検索ができます。
const r = await extractor(prefix + t, { pooling: "mean", normalize: true })
out.push(Array.from(r.data as Float32Array))pooling: "mean" は、トークンごとのベクトルを平均して 1 文 1 ベクトルにする指定です。normalize: true で長さ 1 に揃えます。
正規化しておくと、コサイン類似度が単なる内積になります。
export function cosine(a: number[], b: number[]): number {
let s = 0
for (let i = 0; i < n; i++) s += a[i] * b[i]
return s
}除算も平方根もありません。正規化を推論側に押し付けることで、比較側が掛けて足すだけになります。
#🔤 e5 モデルの接頭辞
このモデルには作法があります。検索する側の文には query: 、検索される側には passage: を付ける必要があります。
const prefix = kind === "query" ? "query: " : "passage: "
const r = await extractor(prefix + t, ...)これは e5 系モデルが学習時にそう訓練されているためで、付けないと精度が明確に落ちます。モデル固有の使い方を守るという、地味だけど効く部分でした。ドキュメントを読まずに使うと気づけません。
#📊 類似度を「体感に合う数字」に伸ばす
実装して最初に困ったのがこれです。e5 の類似度は、まったく関係ない文同士でも 0.7 くらい出ます。関係あるものは 0.85〜0.95。つまり実際に使われる幅は 0.7〜1.0 の狭い範囲に密集しています。
これをそのまま「類似度 72%」と出すと、無関係な文が「7 割似ている」ことになって意味が伝わりません。
export function simPct(cos: number): number {
// e5 の類似度は概ね 0.7〜1.0 に密集するので 0.7→0%, 1.0→100% に伸張して体感に合わせる
const stretched = (cos - 0.7) / 0.3
return Math.round(Math.max(0, Math.min(1, stretched)) * 100)
}0.7 を 0%、1.0 を 100% に線形に引き伸ばすだけです。数学的には元の値のほうが正しいのですが、利用者が判断に使う数字としてはこちらが正しいと判断しました。
こういう変換は「ごまかし」になりかねないので、コメントで理由を明記し、変換していること自体を隠さないようにしています。
#🔍 2 つのモード
意味で検索 — 入力した文に近いものを、リストから順に出す。 似ている文を検出 — リスト内の全ペアを比較して、近いもの同士を見つける。重複や言い換えの発見に使えます。
後者は総当たりなので O(N²) ですが、ベクトルは既に手元にあるので掛けて足すだけです。数百件なら一瞬で終わります。重いのは推論(1 回だけ)で、比較は軽い ── この非対称性が使い方を決めました。
#😓 苦労したところ
初回のモデルダウンロード。 数十 MB を落とすので、何も出ないまま待たせると離脱します。進捗のコールバックを受け取って画面に出し、一度読めばキャッシュされることを明記しました。
onProgress({ kind: "running", message: "意味を 計算中…" })ダウンロード中と推論中を別のメッセージにしているのは、どちらで待っているかが分かるだけで体感が変わるからです。
1 件ずつ推論している。 バッチにまとめたほうが速いのですが、途中経過を出せなくなります。件数が多いときに「何件目を処理中」が出ないと固まったように見えるので、ここは速度より進捗を優先しました。
「似ている」の基準を利用者が決められない。 閾値を固定すると、用途によって多すぎたり少なすぎたりします。結果を類似度順に並べて、どこで切るかは見て判断してもらう形にしました。
#🔭 今後の拡張
- ファイル(テキスト・Markdown)の読み込み
- ベクトルを端末内に保存して、次回は推論を省く
- クラスタリング(似た文をグループにまとめる)
- より大きいモデルの選択(精度と速度のトレードオフ)
#💡 このサービスから言えること
モデルをブラウザに持ってくると、「送れないデータ」に対して使えるようになります。 精度はサーバー側の大きいモデルに劣りますが、そもそも使えるかどうかが変わります。議事録や個人のメモは、まさにその領域でした。
そしてモデルが返す数値をそのまま見せるのが正しいとは限らない、というのも学びでした。0.7〜1.0 に密集する類似度は、機械にとっては十分な情報でも、人が判断に使う数字ではありません。どう見せるかまでが実装だと思っています。
[ ./next_action ]
読んだら、 AkinFind を実際に動かす。
この開発ログは AkinFind をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。