ブラウザだけで動くテキストto画像検索エンジンを作った

Dev.to / 2026/5/24

💬 オピニオンDeveloper Stack & InfrastructureTools & Practical UsageModels & Research

要点

  • この記事では、CLIPを使ってテキストと画像を同じベクトル空間に埋め込み、類似度ベースで検索する手法が実演されています。
  • 「a corgi on grass」のようなクエリをエンコードし、事前計算した画像埋め込みと比較して関連度の高い写真を順位付けできることが示されています。
  • 注目点は、サーバーなし・APIキーなし・画像アップロードなしで、ブラウザ上だけでパイプライン全体(約150MBのニューラルネットワークと24枚分の画像埋め込み)が動く点です。
  • CLIPはOpenAIが2021年に発表したモデルで現代のコンピュータビジョンで広く使われており、2026年にはVercelで手軽に提供できるとも述べられています。
  • 例では、検索が本質的にはコサイン類似度(ベクトル同士のドット積)を計算してテキスト埋め込みとビジョン埋め込みを比較することに置き換わることが説明されています。
  • 記事全体は実装に寄せた内容で、「共通の埋め込み」という発想によってマルチモーダル情報をデータベースのように検索できる点を強調しています。

「草の上のコーギー」と入力してみてください。ギャラリーの24枚の写真のうち、コーギーがトップに上がってきます。スコア:0.31。

「何か食べるもの」と入力してみてください。いちごのボウル、パスタ皿、そして薪で焼くピザがメダル圏に入ります。スコア範囲:0.25 ~ 0.27。

サーバーなし。APIキーなし。どこにも画像がアップロードされていません。パイプライン全体――150 MBのニューラルネットワークと24個の画像埋め込み――は、ブラウザのタブの中にあります。

これが CLIP です。現代のコンピュータビジョンの非常に大きな一部を、ひっそりと支えているモデル。そして2026年には、Vercelで無料で配布できます。

CLIPの背後にあるアイデア

OpenAIは2021年にCLIPを、驚くほどシンプルなひとつのアイデアとともに公開しました: テキストと画像を同じベクトル空間に入れるために、1つのモデルを学習させる。

それだけです。これが全てのコツ。

CLIPには2つのエンコーダがあります。テキストエンコーダは「コーギーの子犬」を512次元のベクトルに変換します。ビジョンエンコーダは、コーギーの写真を512次元のベクトルに変換します。これらは、ウェブからスクレイピングした4億(キャプション、画像)ペアで学習され、その結果、対応するテキストと画像が そのベクトル空間の中で近くなるように なります。

それができるようになれば、データベースのように検索できます。「コーギーの子犬」のベクトルと、実際のコーギー写真のベクトルの距離は小さい。「コーギーの子犬」と宇宙飛行士の写真の距離は大きい。距離で並べ替え。以上です。

"コーギーの子犬"  ─▶  テキストエンコーダ  ─▶  [0.04, -0.12, 0.07, ...]  ─┐
                                                                   ├─▶  コサイン類似度
[画像バイト列]    ─▶  ビジョンエンコーダ ─▶  [0.05, -0.10, 0.08, ...]  ─┘

最後の数式は、2つのforループと乗算です:

function cosineSim(a: Float32Array, b: Float32Array) {
  let dot = 0
  for (let i = 0; i < a.length; i++) dot += a[i] * b[i]
  return dot
}

それだけです。これが検索エンジンです。

なぜこれが重要なのか

「すべてを同じベクトル空間に埋め込み、ドット積で比較する」と理解しているなら、次の核心も理解しています:

  • Pinterestのビジュアル検索。 「これに似たものをもっと探して。」
  • Stable Diffusionのテキストによる条件付け。 「このプロンプトを生成して」は、「モデルが生成するように学習したベクトル空間のある領域を見つける」ことです。
  • データセットの重複除去。 「この5,000万枚の画像のうち、どれがほぼ同一(ニア・デュプリケート)ですか?」 ベクトル距離でクラスタリングします。
  • ゼロショット分類。 「これは猫?犬?それともヤギ?」 3つのラベルをエンコードし、画像をエンコードして、最も近いものを取ります。
  • 大規模なコンテンツモデレーション。 「この画像は、私たちがラベル付けしたポリシー違反に意味的に似ていますか?」

これらのどれも、過去5年間で、どこかの企業にとっては数百万ドル規模のエンジニアリング課題でした。核となるコツは、これからTypeScript 200行で作ろうとしているものです。

手順1:CLIPをブラウザに読み込む

以前はGPU付きのPythonサーバーが必要だったものが、タブの中で動くようになります。この魔法を実現しているライブラリは Transformers.js です――Hugging Faceが、Pythonの transformers ライブラリをJavaScript上のONNX Runtime向けに移植したものです。

import {
  AutoTokenizer, AutoProcessor,
  CLIPTextModelWithProjection, CLIPVisionModelWithProjection
} from '@xenova/transformers'

const MODEL_ID = 'Xenova/clip-vit-base-patch32'

const [tokenizer, processor, textModel, visionModel] = await Promise.all([
  AutoTokenizer.from_pretrained(MODEL_ID),
  AutoProcessor.from_pretrained(MODEL_ID),
  CLIPTextModelWithProjection.from_pretrained(MODEL_ID),
  CLIPVisionModelWithProjection.from_pretrained(MODEL_ID)
])

最初の訪問:Hugging Face CDNから、ONNXの重み約150MBがブラウザのCache APIへストリーミングされます。それ以降の訪問:数百ミリ秒です。

手順2:フレーズをエンコードする

返却形式: {"translated": "翻訳されたHTML"}
async function encodeText(text: string) {
  const inputs = tokenizer(text, { padding: true, truncation: true })
  const { text_embeds } = await textModel(inputs)
  return l2normalise(text_embeds.data)   // 512-d Float32Array
}

text_embeds フィールドは射影されたベクトル、つまり共有空間に存在する方です。射影されていない隠れ状態は、比較対象としては間違ったベクトルになります。

cosine類似度が内積になるように、L2正規化(長さで割る)します。こういった小さな詳細はチュートリアルでは誰も説明しませんが重要です。正規化がないと、ランキングは「どの画像が明るいか」になり、「どの画像があなたのクエリに合うか」にはなりません。

Step 3: encode an image

async function encodeImage(url: string) {
  const image = await RawImage.read(url)
  const inputs = await processor(image)
  const { image_embeds } = await visionModel(inputs)
  return l2normalise(image_embeds.data)
}

プロセッサは、CLIP固有の平均/標準偏差の値を使って、リサイズ → センタクロップ → 正規化を処理します。ビジョンモデルはVision Transformerです。224×224の画像を32×32pxの7×7パッチに切り分け、それぞれのパッチをトークンとして扱い、それらをトランスフォーマーに通します(はい、GPTと同じアーキテクチャです)。そして[CLS]トークンを512次元に射影します。

同じ512-d。同じ空間でテキストと並べられる。これが魔法の正体です。

Step 4: rank

const queryVec = await encodeText("a corgi on grass")
const imageVecs = await Promise.all(images.map(img => encodeImage(img.url)))

const ranked = imageVecs
  .map((v, i) => ({ image: images[i], score: cosineSim(queryVec, v) }))
  .sort((a, b) => b.score - a.score)

これが検索エンジンです。たった8行。

Step 5: cache so it stays fast

モデルの重みはCache APIの中で自動的にキャッシュされます。しかし、毎回アクセスのたびに24枚の画像を再エンコードするのは、理由もなく~5秒のWASM作業が増えるだけです。ベクトルは変わらないからです。画像ID + モデルIDをキーにして、それらをIndexedDBに保存します:

const STORE = 'embeddings'

返却形式: {"translated": "翻訳されたHTML"}async function putCached(modelId: string, imageId: string, vec: Float32Array) {
  const db = await openDb()
  const tx = db.transaction(STORE, 'readwrite')
  tx.objectStore(STORE).put(vec.buffer, `${modelId}::${imageId}`)
}

ギャラリー全体で48 KBです。ウォームリロードが今や体感で瞬時です。

何が変わるのか

5年前、「テキストから画像への検索」は論文でした。2年前、それはGPUとSDKを備えたPythonサーバーでした。今日では、それはVercelへのデプロイです。

「本物のAIエンジニアリング」と「週末プロジェクト」の境界線は、ずっと動き続けています。モデルが小さくなったからではありません。CLIPは今も150 MBです。ブラウザが大きくなったからです。WebAssembly。ONNX Runtime Web。IndexedDB。Cache APIです。実行環境のスタックが、昔のPythonサービスがやっていたことを全部食い尽くしてしまいました。

この記事を読んで「AIは難しすぎる。そんなの作れるわけない」と思っている初心者の方へ:あなたはもう全部読み切っています。コードはGitHubにあります。コミットのたびに1つの概念を順を追って説明してくれていて、clip-from-zero.vercel.appでライブデモも見られます。クローンして、clip.tsを開いてください。4つの関数を読めば、それがCLIPです。

次に誰かが「エンベディング」や「ベクトル検索」や「RAG」や「マルチモーダル」について話しているのを聞いたときには、あなたはそれが何を意味しているか分かります。512次元の空間の中の数です。コサイン類似度です。内積です。

それだけです。

コード:github.com/dev48v/clip-from-zero
ライブデモ:clip-from-zero.vercel.app
シリーズ:TechFromZero — 毎日新しい技術を、すべて無料で、すべてオープンソースで。