対象: 議事録や面接メモがたまってきて、「あの話、どこだっけ?」を言葉が違っても探せるようにしたい方
所要時間: 60〜90分 / 使うもの: Supabaseのダッシュボード(Freeプランで可)、Mac、ターミナル
確認日: 2026年10月(画面や料金は変わることがあります)
続きの記事(このしくみを、実際のWebアプリにしてみた): https://qiita.com/tomozilla/items/8ae8d0f6ad355829838b
ビジネス編(やさしい解説・note): https://note.com/tomozilla/n/nd84bf4efca6c
議事録を溜めていくと、こんなことが起きます。
「新しく来た人にアカウントを作る話、どの議事録だっけ?」と思って「同僚」で検索しても、出てこない。議事録には「新入社員のアカウントは、管理画面の『ユーザー追加』から」と書いてあって、「同僚」という言葉はどこにもないからです。
言葉は違っても、意味が近いものを探したい。 それをやってみた記録です。
この記事では、行を追加すると自動で「意味の数字」が作られて、日本語で意味検索できるところまで、私が実際にやった手順をそのまま書きます。うまくいかなかったことも、そのまま書きます。
0. 先に伝えておきたいこと(正直ベース)
最初の作戦は動きませんでした
当初は、「Supabaseの中だけで完結させたい」と思って、日本語に強い軽いモデル(multilingual-e5-small)をEdge Functionの中で動かそうとしました。結果は次のとおりです。
| 試したこと | 結果 |
|---|---|
| 変換ライブラリをそのままデプロイ | デプロイが「internal error」で失敗(理由は画面に出ませんでした) |
| 実行時にライブラリを読み込む | 「Module not found」(実行時には、あらかじめ同梱したもの以外は読めないようです) |
| ブラウザ向けのビルドを使う | 「Unsupported device」で、計算の土台が見つからない |
| 計算の土台を自分で用意して動かす | 約20秒後に WORKER_RESOURCE_LIMIT(資源の上限)で停止 |
最後のものは、ドキュメントにあるEdge Functionsの上限(メモリ256MB、1回のCPU時間2秒)に収まらなかったのだと思います。ただし、ログに理由は書かれていなかったので、原因は断定できません。また、Supabaseに最初から入っている変換モデル(gte-small)は英語向けで、日本語には向きません。なので、今回の主役は次の構成になりました。
自分のMacで動かした日本語対応モデルを、トンネル経由でEdge Functionから呼ぶ
「それ、本番でもやるの?」と聞かれたら、やりません(理由は後ろの「本番にするなら」に書きました)。この記事は、仕組みを手元で動かして理解するための構成だと思って読んでください。
「かかるお金」を先にまとめます
| 項目 | 今回の私の場合 | 補足 |
|---|---|---|
| Supabase(Freeプラン) | 0円 | Edge Functionの呼び出しは月50万回まで無料、データベースは1プロジェクト500MBまで(ドキュメントの表より) |
| モデル(Ollama + bge-m3) | 0円 | オープンなモデルを自分のMacで動かします。Macの電気代と、約1.2GBのダウンロードは必要です |
| トンネル(cloudflared) | 0円 | アカウント不要の一時的なトンネルです。常時運用向けではない、と私は理解しています(規約はご自身で確認してください) |
| 有料プランに上げた場合 | 未検証 | ドキュメントでは、Proは月200万回まで含まれ、超えた分は100万回ごとに2ドルです。私は課金していません |
ここから先は、数字を使った見積もりです。測ったものと、計算しただけのものを分けて書きます。
- 測ったもの: 1行ぶんの「意味の数字」(1024個)は、データベースの中で4,100バイトでした。
- 計算しただけのもの: 10万行なら、数字だけで約410MBです(索引は別)。Freeの500MBに、あっという間に近づきます。
- 計算しただけのもの: うまく処理できない仕事が1件残ると、後ろで説明する仕組みで約60秒ごとにやり直しが走ります。1件につき月に約43,000回の呼び出しです。失敗した呼び出しも回数に数えられると書かれていたので、詰まった仕事が10件あれば、Freeの月50万回の大半をそれだけで使い切る計算です。実際に私が試したのは、後ろの401エラーのときの16回だけです。
確かめられていないこと
- Macがスリープしたり、トンネルが切れたときの、長時間の挙動
- 件数が増えたとき(数千件以上)の速度と費用
- 他の日本語対応モデルとの比較
- WindowsやLinuxでの手順(私はMacだけです)
- 本物の議事録での精度(今回は、自分で作った8件のデータです)
1. つくるもの
議事録を追加 → (自動で)キューに仕事が積まれる
→ 10秒ごとにcronが仕事を取り出す
→ Edge Functionが、自分のMacのモデルに「数字にして」とお願い
→ 数字を行に保存
検索したい言葉 → 同じ方法で数字にする → 近い順に並べる
画面は、ダッシュボードとターミナルだけです。サンプルデータは、私が作った架空の議事録・面接メモ8件です。
2. 自分のMacでモデルを動かす
Ollamaを入れて、モデルをダウンロードする
Ollama(オープンなAIモデルを手元で動かすための道具)を、Homebrewで入れました。
brew install ollama cloudflared
つまずきメモ:Homebrewの案内に
brew services start ollamaと出ますが、私は使いませんでした。ログイン時に自動で起動し続けるので、あとで説明するトンネルと組み合わせると、出しっぱなしになってしまうためです。
起動して、日本語に対応した変換モデル bge-m3(約1.2GB)をダウンロードします。
ollama serve & # 手動で起動(自分のMacの中だけで待ち受けます)
ollama pull bge-m3
本当に日本語で使えるか、手元で確かめる
いきなりSupabaseにつなぐ前に、モデルだけで確かめました。「同僚をアプリに追加する方法」と、3つの文章がどれくらい近いかを測ります。
import json, urllib.request, math
def emb(t):
r = urllib.request.Request("http://127.0.0.1:11434/api/embed",
json.dumps({"model": "bge-m3", "input": t}).encode(),
{"Content-Type": "application/json"})
return json.load(urllib.request.urlopen(r))["embeddings"][0]
def cos(a, b):
return sum(x*y for x, y in zip(a, b)) / math.sqrt(sum(x*x for x in a) * sum(y*y for y in b))
q = emb("同僚をアプリに追加する方法")
for t in ["新しいメンバーをチームに招待する手順", "請求書の締め日は毎月末です",
"候補者はリーダー経験について具体的に話してくれた"]:
print(round(cos(q, emb(t)), 3), t)
print(len(q))
私の結果はこうでした。
0.669 新しいメンバーをチームに招待する手順
0.374 請求書の締め日は毎月末です
0.41 候補者はリーダー経験について具体的に話してくれた
1024
意味が近い文章は0.669、関係のない2つは0.37〜0.41。数字が大きいほど近い(コサイン類似度)です。そして1つの文章は1024個の数字になります。この「1024」が、あとでテーブルの定義に出てきます。
3. Edge Functionから届くようにする(トンネル)
ここが、今回いちばん「なるほど」と思ったところです。
Edge FunctionはSupabaseのクラウドで動くので、私のMacの localhost には届きません。 そこで、Macの入口に「トンネル」を作って、インターネットから届く住所をもらいます。
ただ、Ollamaにはログインの仕組みがありません。トンネルにそのまま繋ぐと、住所を知った人が誰でも私のMacのモデルを使えてしまいます。そこで、間に門番を置きました。小さなプログラムで、やることは3つだけです。
- 合言葉(ヘッダー)が合わない人は、追い返す(401)
- 通すのは
POST /api/embedだけ(モデルの削除や会話などは通さない) - モデルは
bge-m3に固定する
// embed-proxy.mjs
import http from 'node:http';
const SECRET = process.env.EMBED_SECRET;
if (!SECRET || SECRET.length < 24) { console.error('set EMBED_SECRET'); process.exit(1); }
http.createServer(async (req, res) => {
const deny = (code, msg) => { res.writeHead(code, { 'content-type': 'text/plain' }); res.end(msg); };
if (req.headers['x-embed-secret'] !== SECRET) return deny(401, 'unauthorized');
if (req.method !== 'POST' || req.url !== '/api/embed') return deny(404, 'not found');
const chunks = []; let size = 0;
for await (const c of req) { size += c.length; if (size > 200_000) return deny(413, 'too large'); chunks.push(c); }
let body; try { body = JSON.parse(Buffer.concat(chunks).toString()); } catch { return deny(400, 'bad json'); }
const r = await fetch('http://127.0.0.1:11434/api/embed', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model: 'bge-m3', input: body.input, truncate: true }),
}).catch(() => null);
if (!r) return deny(502, 'ollama unreachable');
res.writeHead(r.status, { 'content-type': 'application/json' });
res.end(Buffer.from(await r.arrayBuffer()));
}).listen(11435, '127.0.0.1'); // 自分のMacの中だけ
合言葉は、長いランダムな文字列にします(画面や記事には出さないでください)。
export EMBED_SECRET=$(openssl rand -hex 24)
node embed-proxy.mjs &
cloudflared tunnel --url http://127.0.0.1:11435 # 出てきた https://xxxx.trycloudflare.com をメモ
動作確認は2つです。合言葉なしだと401、あると数字が返ることを見ます。
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://xxxx.trycloudflare.com/api/embed -d '{}'
# → 401
curl -s -H "x-embed-secret: $EMBED_SECRET" -X POST https://xxxx.trycloudflare.com/api/embed -d '{"input":"こんにちは"}' | head -c 80
# → {"model":"bge-m3","embeddings":[[-0.0150, ...
私の環境では、どちらも想定どおりでした。また、この一時トンネルの住所は、起動するたびに変わります。作り直したら、次の手順の秘密情報も更新が必要です。
4. 住所と合言葉を、Edge Functionの「秘密情報」に登録する
Edge Functionのコードに住所や合言葉を直接書くと、見えてしまいます。ダッシュボードの Edge Functions → Secrets に登録します。
EMBED_URL(さっきのトンネルの住所)と EMBED_SECRET(合言葉)の2つを入力します。
保存すると、一覧に並びます。
登録後は、値そのものは画面に出ません。出るのは名前と、値から作った「ダイジェスト」(この画像では隠しています)だけです。
5. データベースを準備する(SQL Editor)
拡張機能
SQL Editorで次を実行します。
create extension if not exists vector with schema extensions; -- 意味の数字を扱う
create extension if not exists pgmq; -- キュー(仕事の待ち行列)
create extension if not exists pg_net with schema extensions; -- DBからHTTPを呼ぶ
create extension if not exists pg_cron; -- 定期実行
select extname, extversion from pg_extension
where extname in ('vector', 'pgmq', 'pg_net', 'pg_cron') order by extname;
4つ出てくれば成功です。
テーブルと、自動で数字を作る仕組み
create schema if not exists util;
create table public.meeting_notes (
id bigint primary key generated always as identity,
kind text not null check (kind in ('議事録', '面接')),
title text not null,
content text not null,
embedding extensions.vector(1024),
created_at timestamptz default now()
);
alter table public.meeting_notes enable row level security;
create index on public.meeting_notes
using hnsw (embedding extensions.vector_cosine_ops);
-
vector(1024)の1024は、さっきのbge-m3が出す数字の個数です。モデルを変えたら、ここも変わります。 -
hnswは「近いものを素早く探す」ための索引です。データが少ないうちは無くても動きます。 - RLS(行ごとのアクセス制限)は有効にしました。ポリシーは作っていないので、アプリ側のキーからはこのテーブルは見えません。今回の検索は、サーバー側のEdge Functionから行います。
続いて、キュー、トリガー、処理する関数、cronです。
select pgmq.create('embedding_jobs');
create function util.queue_embedding() returns trigger
language plpgsql security definer set search_path = '' as $$
begin
perform pgmq.send('embedding_jobs', jsonb_build_object('id', new.id));
return new;
end; $$;
create trigger embed_on_insert after insert on public.meeting_notes
for each row execute function util.queue_embedding();
create trigger embed_on_update after update of title, content on public.meeting_notes
for each row execute function util.queue_embedding();
create function util.process_embeddings() returns void language plpgsql as $$
declare job record;
begin
for job in select * from pgmq.read('embedding_jobs', 60, 10) loop
perform net.http_post(
url => 'https://<あなたのプロジェクトREF>.supabase.co/functions/v1/embed',
headers => jsonb_build_object('Content-Type', 'application/json'),
body => jsonb_build_object('id', (job.message->>'id')::bigint, 'jobId', job.msg_id));
end loop;
end; $$;
select cron.schedule('process-embeddings', '10 seconds', 'select util.process_embeddings();');
流れは3つです。
- 行を追加・更新(titleかcontentだけ)すると、キューに「この行をお願い」と積まれる
- 10秒ごとにcronが、キューから最大10件取り出してEdge Functionを呼ぶ
-
pgmq.read(..., 60, 10)の60は「取り出した仕事を60秒間、他から隠す」時間。失敗しても60秒後に見えるようになるので、自動でやり直しになる(さきほどの費用の見積もりに出てきた「60秒ごと」は、これです)
<あなたのプロジェクトREF> は、ご自身のプロジェクトの識別子に置き換えてください。
cronジョブは、Integrations → Cron で確認できます。
6. Edge Function「embed」と「search」をつくる
Edge Functions → Deploy a new function → Via Editor で、関数名を embed にして次を貼り、Deploy function を押します。
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import postgres from "https://deno.land/x/postgresjs@v3.4.5/mod.js";
const sql = postgres(Deno.env.get("SUPABASE_DB_URL")!);
const EMBED_URL = Deno.env.get("EMBED_URL")!;
const EMBED_SECRET = Deno.env.get("EMBED_SECRET")!;
// 自分のPCで動いているモデル(トンネル経由)に「数字に変えて」とお願いする
async function embedText(text: string): Promise<number[]> {
const res = await fetch(`${EMBED_URL}/api/embed`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-embed-secret": EMBED_SECRET },
body: JSON.stringify({ input: text }),
});
if (!res.ok) throw new Error(`embedding server returned ${res.status}`);
return (await res.json()).embeddings[0];
}
Deno.serve(async (req) => {
const { id, jobId } = await req.json();
const [row] = await sql`select title, content from public.meeting_notes where id = ${id}`;
if (!row) return new Response("row not found", { status: 404 });
const embedding = await embedText(`${row.title}\n\n${row.content}`);
await sql`update public.meeting_notes
set embedding = ${JSON.stringify(embedding)}::extensions.vector
where id = ${id}`;
// 最後に仕事を消す。途中で失敗したら消えず、あとで自動リトライされる
await sql`select pgmq.delete('embedding_jobs', ${jobId}::bigint)`;
return new Response("ok");
});
4ステップです。(1) 行を読む → (2) タイトルと本文をつなげて数字にする → (3) 行に書き戻す → (4) キューから仕事を消す。最後に仕事を消すのがミソで、途中で失敗したら消されず、やり直しになります。
次に検索用の関数 search も、同じ要領で作ります。検索する言葉を数字にして、近い順に3件返します。
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import postgres from "https://deno.land/x/postgresjs@v3.4.5/mod.js";
const sql = postgres(Deno.env.get("SUPABASE_DB_URL")!);
const EMBED_URL = Deno.env.get("EMBED_URL")!;
const EMBED_SECRET = Deno.env.get("EMBED_SECRET")!;
async function embedText(text: string): Promise<number[]> {
const res = await fetch(`${EMBED_URL}/api/embed`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-embed-secret": EMBED_SECRET },
body: JSON.stringify({ input: text }),
});
if (!res.ok) throw new Error(`embedding server returned ${res.status}`);
return (await res.json()).embeddings[0];
}
Deno.serve(async (req) => {
const { query } = await req.json();
const embedding = JSON.stringify(await embedText(query));
const results = await sql`
select id, kind, title,
round((1 - (embedding <=> ${embedding}::extensions.vector))::numeric, 3) as similarity
from public.meeting_notes
where embedding is not null
order by embedding <=> ${embedding}::extensions.vector
limit 3`;
return Response.json({ query, results });
});
<=> は「コサイン距離」(小さいほど近い)です。1 - 距離 にして、大きいほど似ている「類似度」にしました。
2つ並べば完了です。
7. 議事録を入れてみる(ここで私はつまずきました)
架空の議事録5件と、面接メモ3件を入れます。
8件ぶんのサンプルデータ(クリックで開く)
insert into public.meeting_notes (kind, title, content) values
('議事録', '週次ミーティング(サポート)',
'解約が増えている原因を話し合った。料金の分かりにくさと、導入直後のサポート不足が主な理由として挙がった。まず初月だけ、導入サポートの担当をつけることにした。'),
('議事録', '週次ミーティング(開発)',
'先週の障害は、夜間のバックアップ処理が重なったのが原因だった。実行する時間をずらして、再発を防ぐことにした。'),
('議事録', '経営会議',
'来期の予算は、広告費を減らして、その分を採用に回す方針で合意した。四半期ごとに見直す。'),
('議事録', '新人オンボーディング打ち合わせ',
'新入社員のアカウントは、管理画面の「ユーザー追加」から作成する。仮パスワードは、初回ログイン時に必ず変更してもらう。'),
('議事録', 'リリース判定会議',
'新機能のテストが終わっていないため、公開日を1週間後ろにずらすことにした。お客様への案内は、日程が決まってから出す。'),
('面接', '面接メモ(営業職・一次)',
'前職では、5人のチームをまとめた経験があると話してくれた。具体的な事例を挙げて説明でき、聞き方も丁寧だった。数字で成果を示すのは、やや苦手そうだった。'),
('面接', '面接メモ(企画職・一次)',
'志望理由は、少人数で裁量を持って働きたいから。前職は大企業で、物事が決まるまでが遅いのが不満だったという。'),
('面接', '面接メモ(エンジニア・二次)',
'給与の希望は、現職と同じくらい。それよりも、在宅で働けるかどうかを何度も確認していた。');
入れた直後に、embedding が空(null)であることを確認します。
select id, kind, embedding is null as embedding_is_null from public.meeting_notes order by id;
ここから、10〜数十秒で埋まるはず…でした。でも、1分たっても埋まりませんでした。
何が起きたか調べる
cronが呼んだ結果は、net._http_response に残っています。
select status_code, count(*) as calls from net._http_response group by 1;
**401(認証エラー)**です。エラーの中身は UNAUTHORIZED_NO_AUTH_HEADER(認証ヘッダーがない)でした。
原因と、私がやった対処
新しい関数は、初期設定で「認証のトークン(JWT。画面の説明では、anonキーでも満たせる、とありました)が付いたリクエストだけ受け付ける」になっています(Verify JWT)。でも、cronが送るリクエストにはトークンを付けていません。そのため、門前払いされていました。
関数の Settings で、この設定を確認します。
今回はデモなので、embed と search の両方で、スイッチをオフにして Save changes を押しました。
これは、デモ限定の対処です。 オフにすると、関数の住所を知っている人なら誰でも呼べます。この記事の構成だと、
searchを誰でも呼べる=私のMacのモデルを誰でも使えることにもなります。本番では、関数の中で合言葉を確認するなど、別の守り方が必要です(私は試していません)。
埋まった
うまく処理できなかった仕事は、60秒後に見えるようになってやり直されます。しばらくして確認すると、8件ぜんぶ埋まっていて、キューも空でした。
select id, extensions.vector_dims(embedding) as dims,
left(embedding::text, 28) || '…' as embedding_preview
from public.meeting_notes order by id;
別の実験で、すでに入っている行の embedding を空にしてから更新したところ、約11秒で自動的に埋め直されました(1回きりの測定です)。
8. 「意味」で検索してみる
ダッシュボードで search を開き、Test からリクエストを送れます。本文に {"query":"同僚をアプリに追加するには"} を入れて Send Request を押しました。
「同僚」も「追加」も入っていない議事録が、1位に出ました。
キーワード検索との比べ
比較のために、同じような言葉でふつうの部分一致(ILIKE)も試しました。
select k.word, count(m.id) as hits
from (values ('同僚'), ('離れ'), ('率い'), ('使い道'), ('転職'), ('在宅勤務'), ('原因')) as k(word)
left join public.meeting_notes m on m.content ilike '%' || k.word || '%'
group by k.word;
7つの言葉のうち、ヒットしたのは「原因」の1つだけ(2件)でした。
7つの言い換えの結果
自分で考えた言い換えで、意味検索を試した結果です。
| 検索した言葉 | 1位 | 類似度 | 当たり? |
|---|---|---|---|
| 同僚をアプリに追加するには | 新人オンボーディング打ち合わせ | 0.515 | ○ |
| お客さんが離れていく理由 | 週次ミーティング(サポート) | 0.529 | ○ |
| チームを率いた経験がある人 | 面接メモ(営業職) | 0.589 | ○ |
| お金の使い道の方針 | 経営会議 | 0.547 | ○ |
| なぜ転職したいのか | 面接メモ(企画職) | 0.628 | ○ |
| 在宅勤務を希望している人 | 面接メモ(エンジニア) | 0.607 | ○ |
| システムが止まった原因と対策 | 週次ミーティング(開発) | 0.576 | ○ |
7つとも、1位は私が「これだ」と思っていた議事録でした。
ちなみに、英語で「how do I add a new teammate」と聞いても、同じ議事録が1位でした(0.547)。日本語と英語をまたいで探せるのも、このモデルの特徴のようです。
9. うまくいかなかった検索(これも大事です)
ここからが、私にとって一番の学びでした。
① 反対の意味を区別しにくい
「広告費を増やす方針の話」と探したら、1位は「広告費を減らして、その分を採用に回す」という議事録でした(0.661、全体で最高点)。話題が同じだと、意味が反対でも近いと判断されるようです。
② 1位と2位が、ほぼ同点になることがある
- 「ミーティングの日程が延びた件」:リリース判定会議 0.627 / 週次ミーティング(開発) 0.622
- 「コミュニケーション能力が高い候補者」:営業職 0.499 / 企画職 0.491
「ミーティング」のような共通の言葉に引っ張られることもありました。
③ 関係のない言葉でも、必ず結果が返ってくる
「今日のお昼ごはん」と探したのに、3件返ってきました(最高でも0.435)。意味検索は「近い順に並べる」だけで、「該当なし」とは言ってくれません。
④ 「点数の切り捨てライン」は決めにくい
当たりの1位は0.515〜0.645でした。一方、うまくいかなかった検索の1位は0.433〜0.661です。0.5で線を引く、のようなやり方では、うまく分けられませんでした(私の8件のデータでの話です)。
ここから私が考えたのは、検索結果は「答え」ではなく「候補」として見せて、最後は人が読んで確かめる使い方が合う、ということです。
10. 本番にするなら(私が気になった点)
- この構成のままでは、本番に向きません。 私のMacが落ちたら止まります。トンネルの住所は起動のたびに変わります。常に動いている場所(モデルを動かせるサーバーなど)が必要です。その場合の費用は、私は試していません。
- Verify JWTをオフにしたまま公開しない。 先ほど書いたとおり、誰でも呼べてしまいます。
- 検索は1回あたり約2秒でした(5回の測定で1.9〜2.6秒。トンネル経由・自分のMacでの話です)。
- 詰まった仕事の見張りが必要です。やり直しの回数に上限をつける仕組みは、今回は作っていません(費用の見積もりの項を参照)。
- データ量の費用:1行4,100バイトなので、件数が増えるとデータベースの容量に響きます。
11. 後片付け
使い終わったら、トンネルと門番とOllamaを止めてください。私は次の3つを止めて、古い住所に繋がらない(530エラー)ことを確認しました。
pkill -f "cloudflared tunnel"; pkill -f embed-proxy.mjs; pkill -f "ollama serve"
まとめ
- 行を追加すると、キュー → cron → Edge Function → モデルの流れで、自動で意味の数字が作られる(私の環境では約11秒)
- Edge Function内でのモデル実行は、私の環境では動かなかった。手元のモデルをトンネル経由で呼ぶ形にした(学習用)
- 日本語でも、言葉が違っても意味が近い議事録を探せた(7つの言い換えのうち、7つで1位が当たり)
- ただし、反対の意味・同点・「該当なし」が出ないといった弱点がある。結果は「候補」として扱う
- 費用は、今回はすべて無料枠+手元のMac。本番にするなら、常時動く場所と、やり直しの見張りの費用を、別に見積もる必要がある
次は、SSL強制(接続の暗号化)を、実際に設定して試す話です。
スクリーンショットは、記事用に作った無料プランのデモ環境で撮影したものです。サンプルのデータは、すべて架空の内容です。












