【Flutter】Supabase Edge Functionsを使ったベクトル検索
今回はpgvectorを使ってベクトル検索を実装したのでそのことについてまとめます
今回対象としたデータはポストデータの検索ではなくメニュー表のような固定値のデータ検索を行いました。
データに高次元ベクトルを追加する
まずSupabaseではCSVエクスポートが可能です。これを使って既存のデータからCSVをダウンロードしました。このCSVからpythonでsklearnやpandasを使って高次元ベクトルデータを追加します。
いくつの次元でもいいですが私はCSVのデータ数や文章の長さなどを含めてAIに聞いたところ8次元くらいがよいとのことで8次元としました。
今回の対象文章をtitleとして高次元ベクトルを計算していきます。
import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.decomposition import TruncatedSVD
df = pd.read_csv("table_name.csv") # id, subject カラム想定
# TF-IDF ベクトル化
vectorizer = TfidfVectorizer(token_pattern=r"(?u)\b\w+\b")
X = vectorizer.fit_transform(df["title"].astype(str))
# SVD で 8次元へ圧縮
svd = TruncatedSVD(n_components=8, random_state=42)
X8 = svd.fit_transform(X)
for i in range(8):
df[f"e{i+1}"] = X8[:, i]
df.to_csv("table_data_vec8.csv", index=False)
print("生成完了")
print(df.head())
作成されたCSVデータをSupabaseに追加します。SupabaseにはCSVインポート機能があるのでそれでインポートするのですが、この時e1~e8というカラムが追加されていてSupabaseのカラムと不一致のエラーが起きるので先にe1~e8のカラムをfloat型で作成しておきます。
e1~e8が追加できたら次にそれら8つの小数が入ったデータを結合する処理を行います。これはSQLエディタでクエリを書けば良いです。embeddingという名前のカラムで結合を行いました。
SQL側の設定
SQLエディタで、titleカラムを対象として検索クエリqに類似するアイテムを返す関数を作成します。
create or replace function match_items_from_text(
q text,
match_count int
)
returns table (
id bigint,
title text,
distance float4
)
language sql
stable
as $$
with query_embedding_row as (
-- q を含むレコードを1件拾って、その embedding をクエリに使う
select embedding as query_embedding
from items
where title ilike '%' || q || '%'
order by id
limit 1
)
select
i.id,
i.title,
i.embedding <-> qer.query_embedding as distance
from items i
join query_embedding_row qer on true
order by i.embedding <-> qer.query_embedding
limit match_count;
$$;
この関数は以下の通りです。
- title ilike '%q%' で “とりあえずそれっぽい1件” の embedding をクエリに使う
- embedding <-> query_embedding は L2 距離(小さいほど近い)
- 戻り値は { id, title, distance }。必要ならここにメタ情報カラムを足せばよい
たとえば items に category や author があるなら returns table と select に足せばOK。
Supabase Edge Functions 上で動かすTSコード
Supabase Edge Functionsを使用する方法は以下のドキュメントを参考にしてください。
Denoを使って動かしました。
resultsの中身は戻り値そのままをを使用しました。
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; // RLS次第では anon でもOK
const supabase = createClient(supabaseUrl, supabaseKey);
Deno.serve(async (req) => {
if (req.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
try {
const body = await req.json();
const q = (body?.q ?? "").toString();
const limit = Number(body?.limit ?? 10);
if (!q.trim()) {
return new Response(
JSON.stringify({ error: "q is required" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const { data, error } = await supabase.rpc("match_items_from_text", {
q,
match_count: limit,
});
if (error) {
console.error(error);
return new Response(
JSON.stringify({ error: error.message }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
return new Response(
JSON.stringify({ results: data ?? [] }),
{ headers: { "Content-Type": "application/json" } },
);
} catch (e) {
console.error(e);
return new Response(
JSON.stringify({ error: "invalid json" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
});
これをEdge Functionsにデプロイに成功すると以下のようになります。

ここまでくると最後フロント側です。
Flutterで確認する
FlutterのSupabase SDKのSupabaseClientクラスにはfunctionsというオプションが存在するのでそれを使用すると簡単に実行できます。
以下はそのコードです。
final supabase = Supabase.instance.client;
Future<void> searchItem(String word) async {
final response = await supabase.functions.invoke(
'search',
body: {'q': word},
);
print(response.data);
}
このメソッドを検索ボタンに仕込んで検索窓を作れば検索できると思います。
まとめ
今回の実装では、Supabase と pgvector を組み合わせることで外部のAIサービスを使わずに、意味ベースの検索体験を実現できることを確認しました。
ポイントは、
- データは CSV で抜いて Python で 8 次元ベクトルにする
- Supabase に戻して vector(8) で保存
- pgvector の <-> 距離を使えばそのまま類似検索ができる
- Edge Function を1つ書くだけで API 化できる
- Flutter からも一行で呼べる
実装してみると驚くほど手軽で固定データの検索や軽量なレコメンド機能なら、この構成だけで十分やっていけると感じました。
ただ今回の対象とした文章が少し短く精度が悪い時があったのである程度の長さを持った文章を対象にすることをお勧めします。
Discussion