🔎

【Flutter】Supabase Edge Functionsを使ったベクトル検索

に公開

今回はpgvectorを使ってベクトル検索を実装したのでそのことについてまとめます
今回対象としたデータはポストデータの検索ではなくメニュー表のような固定値のデータ検索を行いました。

データに高次元ベクトルを追加する

まずSupabaseではCSVエクスポートが可能です。これを使って既存のデータからCSVをダウンロードしました。このCSVからpythonでsklearnやpandasを使って高次元ベクトルデータを追加します。
いくつの次元でもいいですが私はCSVのデータ数や文章の長さなどを含めてAIに聞いたところ8次元くらいがよいとのことで8次元としました。
今回の対象文章をtitleとして高次元ベクトルを計算していきます。

vec.py
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に類似するアイテムを返す関数を作成します。

sql
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を使用する方法は以下のドキュメントを参考にしてください。
https://supabase.com/docs/guides/functions/quickstart
Denoを使って動かしました。
resultsの中身は戻り値そのままをを使用しました。

supabase/functions/search/index.ts
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