対象: 前の記事で意味検索のしくみを動かしてみて、「で、これを実際の画面にすると、どう使うの?」が気になった方
所要時間: 40〜60分(前の記事の準備ができている場合) / 使うもの: ブラウザ、ターミナル、前の記事で作ったSupabaseのプロジェクトとローカルのモデル
確認日: 2026年10月(画面や料金は変わることがあります)
前の記事(手順編): https://qiita.com/tomozilla/items/aa859c8cf4d50d32c9ef
ビジネス編(やさしい解説・note): https://note.com/tomozilla/n/nd84bf4efca6c
サンプルコード一式(GitHub): https://github.com/tomozilla/semantic-search-ja-demo
記事の中のコードは日本語コメントです。リポジトリの本体は英語コメントで、日本語コメント版(この記事と同じもの)はja/フォルダーにあります。
前の記事では、SQL EditorとEdge Functionで、「行を追加すると、自動で意味の数字が作られる」しくみを動かしました。でも、終わってみて、ふと思いました。
「これ、営業さんや採用担当の人は、どんな画面で使うんだろう?」
SQLを書かない人にとっての「意味検索」は、検索ボックス1つです。そこで今回は、そのしくみを使った小さなWebアプリをつくってみました。この記事は、アプリの全体像と、動かして気づいたことの記録です。
0. 先に伝えておきたいこと(正直ベース)
- 前の記事の準備(テーブル、キュー、cron、
embed関数、ローカルのモデルとトンネル)ができている前提です。モデルは私のMacで動かしているので、動かしている間だけアプリが使えます。 - サンプルは、私が作った架空の議事録・面接メモ8件です。
- 画面はデモ用の最小構成です(ログインなし。後ろの「実際のWebアプリにするなら」を読んでください)。
-
費用:新しくかかるのは、Edge Functionの呼び出し回数だけです(Freeプランは月50万回まで。ドキュメントより)。画面を開く・検索する・メモを追加するたびに、
memo-apiが呼ばれます。私は、コードから数えただけで、実際の回数は測っていません。モデルとトンネルは、前の記事と同じく0円(自分のMac)です。 - 未検証:複数人での同時利用、スマホでの表示、他のブラウザ(私はMacのChromeだけで確認しました)、件数が多いときの速度。
1. つくったもの:「メモ検索」画面
画面は1枚です。左が「探す」、右が「メモを追加する」と「登録済みのメモ」です。
使う人の気持ちで、3つの場面を想像してみました。
- 新人のオンボーディングを担当している人:「同僚をアプリに追加するには」で探すと、「新入社員のアカウントは、管理画面の『ユーザー追加』から」という議事録が出てほしい
- カスタマーサクセスの人:「お客さんが離れていく理由」で探すと、「解約が増えている原因」の議事録が出てほしい
- 採用担当の人:「チームを率いた経験がある人」で探すと、「5人のチームをまとめた」という面接メモが出てほしい
どれも、メモの中に、探す言葉そのものは入っていません。
2. 全体像
画面(HTML)は、memo-apiというEdge Functionにだけ話しかけます。memo-api が、データベースを読み書きして、必要なら私のMacのモデルに「この言葉を数字にして」とお願いします。メモを追加したときの「数字づくり」は、前の記事のしくみ(キュー → cron → embed関数)が、アプリが何もしなくてもやってくれます。
memo-api が受け付ける「動作」は、5つです。
| 動作(action) | 何をする? |
|---|---|
list |
登録済みのメモの一覧と、「検索できる状態か」を返す |
search |
言葉を数字にして、近い順に5件返す(意味検索) |
keyword |
言葉が文字として入っているものを、5件まで返す(ふつうの検索) |
add |
メモを1件追加する(数字は、あとで自動で作られる) |
status |
追加したメモの数字ができたか、を返す |
3. つくったもの(ファイル)
app/
index.html 画面(HTML+CSS+JavaScriptが1ファイル。ビルド不要)
config.example.js 設定のひな形(config.js にコピーして使う)
supabase/functions/memo-api/index.ts APIの関数
API(Edge Function):「入場券」を確認する
前の記事のデモでは、Verify JWT(認証トークンの確認)をオフにして、誰でも関数を呼べる状態にしました。今回のアプリでは、オフのままにしつつ、関数の中で「アプリのpublishableキーが付いているか」を確認することにしました。
const PUBLISHABLE_KEYS: string[] = Object.values(JSON.parse(Deno.env.get("SUPABASE_PUBLISHABLE_KEYS") ?? "{}"));
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: cors });
const key = req.headers.get("apikey");
if (!key || !PUBLISHABLE_KEYS.includes(key)) return json({ error: "unauthorized" }, 401);
// …以下、actionごとに処理
SUPABASE_PUBLISHABLE_KEYS は、Edge Functionに最初から入っている環境変数です(ドキュメントより)。キーなしで呼ぶと、ちゃんと401が返りました。
ただし、これは簡単な入場券です。publishableキーは「公開前提のキー」で、画面のHTMLを開けば誰でも見られます。本物のログイン認証の代わりにはなりません。
メモを追加する動作では、入力の長さと種類を確認してから保存します。
case "add": {
const kind = String(body.kind ?? "");
const title = String(body.title ?? "").trim();
const content = String(body.content ?? "").trim();
if (!["議事録", "面接"].includes(kind)) return json({ error: "kind must be 議事録 or 面接" }, 400);
if (!title || title.length > 100) return json({ error: "title must be 1-100 chars" }, 400);
if (!content || content.length > 2000) return json({ error: "content must be 1-2000 chars" }, 400);
const [row] = await sql`insert into public.meeting_notes (kind, title, content) values (${kind}, ${title}, ${content}) returning id`;
return json({ id: row.id }); // あとは、トリガー → キュー → cron → embed が自動で数字を作る
}
画面:fetchで呼ぶだけ
画面側は、fetch で memo-api を呼ぶだけです。ライブラリは使っていません。
async function api(body) {
const res = await fetch(API, { method: 'POST',
headers: { 'Content-Type': 'application/json', apikey: cfg.SUPABASE_PUBLISHABLE_KEY },
body: JSON.stringify(body) })
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
return data
}
4. 動かす
- 前の記事の準備ができていることを確認します(ローカルのモデル、門番、トンネルを起動して、
EMBED_URLを更新)。 -
memo-apiをデプロイします。Verify JWTはオフにします(キーの確認は関数の中で行います)。 -
app/config.example.jsをconfig.jsにコピーして、プロジェクトのURLとpublishableキーを入れます。 - 画面を配信します。
cd app python3 -m http.server 8765 --bind 127.0.0.1 # → http://127.0.0.1:8765/index.html
つまずきメモ(トンネルの住所を更新できなかった話):トンネルは起動のたびに住所が変わるので、Edge Functionの秘密情報
EMBED_URLも更新が必要です。最初、Secretsの「追加」フォームに同じ名前で入れて保存したのですが、画面上は更新されませんでした(エラーも出ませんでした)。一覧の右にある「︙」から Edit secret を選んで値を入れ直したら、更新されました。
5. 使ってみる
意味検索
例の言葉(チップ)を押すか、自分で言葉を入れて検索します。
「同僚」も「追加」も入っていない議事録が、1位に出ました。右の棒は類似度(大きいほど近い)で、点数も出しています。
画面の下には、意味検索の注意書きを出しました。意味検索は「近い順に並べる」だけで、「該当なし」とは言わないからです(前の記事で気づいたことです)。
「見比べる」
いちばん気に入っているのが、意味検索とキーワード検索を並べて見る切り替えです。同じ言葉で、2つの方法を同時に試します。
「お客さんが離れていく理由」では、キーワード検索は0件です。意味検索は、1位に「解約が増えている原因」の議事録を出しました(0.529)。ただし2位は、少し外れた面接メモ(大企業の意思決定が遅いのが不満だった、という話)でした。この画面があると、「意味検索のほうが常に正しい」わけではないことも、見て分かります。
メモを追加する
右の「メモを追加する」に、種類・タイトル・内容を入れて、追加するを押します。
押すとすぐに、①保存、②意味の数字づくり、③検索できます、の3つの進み具合が出ます。一覧の新しいメモには「準備中」の印が付き、数字ができると「検索できます」に変わります。私の環境では、約4秒で終わりました(cronが10秒ごとなので、運が良かった回です。前の記事では約11秒でした)。
追加したメモは、違う言い方でもすぐ探せました。
「値段の変更をお客さんに知らせる方法」と探すと、追加したメモ(「新しい料金プランをお客様へ案内する」)が1位(0.639)でした。
6. つまずいたこと
① 古い検索結果が、新しい結果を上書きした
「見比べる」に切り替えた直後に検索したら、画面の結果が1つ前の言葉のものになっていました。切り替えと検索ボタンで、ほぼ同時に2つのリクエストが飛んで、遅れて届いた古い結果が、新しい結果を上書きしていたのです。
直し方は、検索のたびに番号をつけて、最新の番号以外の結果は捨てることでした。
let seq = 0
async function search() {
const mine = ++seq
// …await で待ったあとに
if (mine !== seq) return // その間に、新しい検索が始まっていたら、この結果は捨てる
検索ボックスのあるアプリでは、よくある落とし穴だと思います。
② 「議事録」の印が、2行に折り返した
右の一覧で、種類の印(バッジ)が、狭い幅で2行に折れてしまいました。white-space: nowrap と flex-shrink: 0 を付けて直しました。
③ 見比べの2列が窮屈だった
2列にすると、タイトルが細かく折れて読みにくかったので、類似度をタイトルの下に回し込むようにして、見比べは上位3件に絞りました。
7. 実際のWebアプリにするなら
動かしてみて、「実際に使ってもらうなら、ここが要るな」と思ったことです。どれも、私は試していません。
- ログイン:今は誰でも使えます。面接メモは個人情報なので、見られる人を限定する必要があります(行ごとのアクセス制限は、別の記事で試しました)。
- ずっと動く場所:モデルが私のMacの上なので、Macが止まったら使えません。常時動く場所と、その費用が要ります。
- 「該当なし」の扱い:点数に線を引く方法はうまくいきませんでした(前の記事)。「候補です」と見せて、人が確かめる画面のままにするのが、今のところ良さそうです。
- 見る側の記録:どんな言葉で探されているかを残すと、「足りないメモ」が見えてきそうです(未検証)。
- 費用の見張り:呼び出し回数と、詰まった仕事のやり直しを見張る必要があります(前の記事の見積もりのとおりです)。
まとめ
- 意味検索は、アプリにすると検索ボックス1つになります。使う人は、しくみを知らなくてかまいません
- 画面は、Edge Functionを1つ呼ぶだけの小さなHTMLで作れました(ビルド不要)
- **意味検索とキーワード検索の「見比べ」**を付けると、意味検索の強みも弱みも、画面で見て分かります
- 追加したメモは、アプリが何もしなくても、数秒〜10秒ほどで検索できるようになりました(私の環境)
- 検索ボックスのアプリは、古い結果が新しい結果を上書きする落とし穴に気をつけてください
コードは、上のGitHubにあります。次は、SSL接続を強制するスイッチを押したときに何が起きるかを、ステータスページにして「見える化」してみます。
スクリーンショットは、記事用に作った無料プランのデモ環境で撮影したものです。サンプルのデータは、すべて架空の内容です。





