メインコンテンツへスキップ
友田 陽大
データベース・RLS
TypeScript
Next.js
PostgreSQL
RAG
Supabase

pgvector × TypeScript × Drizzle ORM × Next.js で作る型安全なベクトル検索(Server Actions・Zod境界検証)

TypeScript / Next.js から pgvector を型安全に扱う実装ガイド。Drizzle ORM の vector 列と HNSW インデックスのスキーマ定義、拡張の有効化(drizzle-kit は生成しないので手動マイグレーション)、cosineDistance による kNN クエリ、Server Action での Zod 境界検証と埋め込み生成、SQLインジェクション安全性、アクセシブルな検索UIまでを実コードで解説します。

公開日
読了時間
11分
著者
友田 陽大
シェア

pgvector の解説は Python と生SQLで書かれたものが多く、TypeScript / Next.js のフルスタック開発者にとっては「で、これをどう型安全に書くの?」が抜けがちです。

この記事は、その穴を埋めます。Drizzle ORM を使って、スキーマ定義から Next.js の Server Action まで一気通貫で型安全な pgvector ベクトル検索を実装します。any を使わず、ユーザー入力を境界で Zod 検証し、SQLインジェクションを構造的に防ぎ、結果をアクセシブルに描画する——本番に出せる実装を、最小構成で示します。

この記事のルール:Drizzle の API は 公式ドキュメント(orm.drizzle.team)と drizzle-orm の仕様(vector対応は 0.31.0 以降) に基づきます。pgvector の構文は公式 README に準拠。Drizzle のインデックス・ビルドパラメータ周りはバージョンで挙動が変わり得るため、後述の注意点に従い生成されるDDLを必ず確認してください。接続文字列・APIキーは環境変数前提(ハードコード厳禁)です。


0. 前提

pgvector の有効化(CREATE EXTENSION vector)が済んでいる前提で進めます。まだなら pgvector の始め方 を先に。RAGの全体設計や埋め込み次元の決め方は 本番RAG設計 に、インデックスのチューニングは チューニング完全ガイド にあります。本稿は TypeScript 実装に集中します。

npm i drizzle-orm postgres
npm i -D drizzle-kit
# 埋め込み生成に OpenAI を使う例(任意のプロバイダでよい)
npm i openai

1. スキーマ:vector 列と HNSW インデックスを型で定義する

Drizzle では vector 列を drizzle-orm/pg-core から、次元を dimensions で宣言します。列の次元と埋め込みモデルの出力次元は必ず一致させます(ズレると挿入時にエラー=型・制約で守られる)。

// db/schema.ts
import { sql } from "drizzle-orm";
import {
  bigint,
  index,
  jsonb,
  pgTable,
  text,
  timestamp,
  vector,
} from "drizzle-orm/pg-core";

/** ドキュメントのチャンク(分割片)と埋め込みを格納するテーブル。 */
export const docChunks = pgTable(
  "doc_chunks",
  {
    id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(),
    documentId: bigint("document_id", { mode: "number" }).notNull(),
    content: text("content").notNull(),
    // 次元はモデルに合わせる(例: text-embedding-3-large を dimensions=1024 で運用)
    embedding: vector("embedding", { dimensions: 1024 }).notNull(),
    metadata: jsonb("metadata").$type<Record<string, string>>().notNull().default({}),
    createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
  },
  (table) => [
    // HNSW インデックス。演算子クラスは検索する距離に合わせる(コサインなら vector_cosine_ops)
    index("doc_chunks_embedding_idx").using(
      "hnsw",
      table.embedding.op("vector_cosine_ops"),
    ),
    // メタデータでのフィルタ用(テナント・カテゴリ等)
    index("doc_chunks_metadata_idx").using("gin", table.metadata),
  ],
);

export type DocChunk = typeof docChunks.$inferSelect;
export type NewDocChunk = typeof docChunks.$inferInsert;

$inferSelect / $inferInsert行の型がスキーマから自動導出されます。これが Drizzle を選ぶ理由——スキーマが**単一真実源(SSoT)**になり、型とDBが乖離しません(DRY)。


2. 拡張の有効化は「手動マイグレーション」で(重要)

ここはハマりどころです。drizzle-kit は CREATE EXTENSION vector を自動生成しません。 スキーマに vector 列があっても、拡張自体は別途有効化する必要があります。正解は、マイグレーションの最初のステップとして自分で書くこと。

// db/migrate.ts(マイグレーション実行の入口)
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import { sql } from "drizzle-orm";
import postgres from "postgres";

const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error("DATABASE_URL is not set"); // 起動時に明示的に失敗させる

const client = postgres(connectionString, { max: 1 });
const db = drizzle(client);

// ① 拡張を先に有効化(冪等。IF NOT EXISTS で再実行しても安全)
await db.execute(sql`CREATE EXTENSION IF NOT EXISTS vector`);
// ② スキーマ・インデックスのマイグレーションを適用
await migrate(db, { migrationsFolder: "./drizzle" });

await client.end();

なぜ自動生成されないか:拡張の有効化には高い権限が要り、環境(RDS/Supabase/Neon…)で前提が異なるため、Drizzle は踏み込みません。**「拡張は人が用意し、スキーマはツールが管理する」**という責務分離(SRP)だと捉えると腑に落ちます。


3. 型安全な kNN クエリ

Drizzle は距離ヘルパーを drizzle-ormpg-core ではない)から提供します。テキスト埋め込みなら cosineDistance。**距離は「小さいほど近い」**ので、人に見せる類似度には 1 - 距離 に変換します。

// db/search.ts
import { cosineDistance, desc, gt, sql } from "drizzle-orm";
import { db } from "./client";
import { docChunks } from "./schema";

/** クエリ埋め込みに意味が近いチャンクを上位 k 件返す(型安全・パラメータバインド)。 */
export async function searchChunks(queryEmbedding: number[], limit = 5) {
  // cosineDistance(列, クエリ埋め込み) → SQL式。1 - 距離 = 類似度(0〜1, 大きいほど近い)
  const similarity = sql<number>`1 - (${cosineDistance(docChunks.embedding, queryEmbedding)})`;

  return db
    .select({
      id: docChunks.id,
      content: docChunks.content,
      similarity, // ← number として型付く
    })
    .from(docChunks)
    .where(gt(similarity, 0.3)) // 類似度の下限でノイズを足切り(値は計測で調整)
    .orderBy((t) => desc(t.similarity)) // 近い順。HNSW インデックスが効く形
    .limit(limit);
}

SQLインジェクションは構造的に安全です。cosineDistance(...) に渡す queryEmbeddinggt(similarity, 0.3) の閾値も、Drizzle の sql テンプレートが**バインドパラメータ($1, $2, …)**として送るため、文字列連結は発生しません。ユーザー入力が直接SQL文字列に混ざる経路がない——これが ORM を境界に置く価値です(セキュリティ)。

インデックスを効かせる形:HNSW が効くのは「ORDER BY <距離> ... LIMIT k」の形です。desc(類似度) は内部的に距離の昇順、すなわちインデックスが効く並びになります。効いているかは EXPLAINIndex Scan using doc_chunks_embedding_idx を確認してください(→ チューニング完全ガイド)。

挿入:number[] を渡すだけ

import { db } from "./client";
import { docChunks } from "./schema";

await db.insert(docChunks).values({
  documentId: 1,
  content: "返品は購入後30日以内に対応します。",
  embedding: [0.012, -0.041, 0.98 /* … 長さは dimensions=1024 と一致 */],
  metadata: { tenantId: "acme", category: "faq" },
});

配列の長さが dimensions と一致していれば、pgvector リテラルへの変換は Drizzle が行うので手動の文字列化は不要です。


4. Next.js Server Action:境界で検証し、サーバーに閉じる

ユーザーの検索文字列を受け取り、Zod で境界検証 → 埋め込み生成 → ベクトル検索まで、すべて**サーバー側(Server Action)**で完結させます。埋め込みAPIキーはクライアントに漏れません。

// app/search/actions.ts
"use server";

import { z } from "zod";
import OpenAI from "openai";
import { searchChunks } from "@/db/search";

const openai = new OpenAI(); // APIキーは環境変数 OPENAI_API_KEY(ハードコード禁止)

// 境界バリデーション:信頼できない入力を最初に narrow する
const QuerySchema = z.object({
  q: z.string().trim().min(1, "検索語を入力してください").max(500),
});

export type SearchResult = {
  id: number;
  content: string;
  similarity: number;
};

/** クエリ文字列 → 埋め込み → pgvector 検索。失敗は型で表現する(throw しない)。 */
export async function search(
  formData: FormData,
): Promise<{ ok: true; results: SearchResult[] } | { ok: false; error: string }> {
  const parsed = QuerySchema.safeParse({ q: formData.get("q") });
  if (!parsed.success) {
    return { ok: false, error: parsed.error.issues[0]?.message ?? "入力が不正です" };
  }

  // クエリを埋め込みに変換(列の dimensions と必ず一致させる)
  const embedRes = await openai.embeddings.create({
    model: "text-embedding-3-large",
    input: parsed.data.q,
    dimensions: 1024, // ← vector(1024) と一致
  });
  const queryEmbedding = embedRes.data[0]?.embedding;
  if (!queryEmbedding) return { ok: false, error: "埋め込み生成に失敗しました" };

  const results = await searchChunks(queryEmbedding, 5);
  return { ok: true, results };
}

設計のキモを整理します。

  1. 境界で Zod 検証formData.get("q")FormDataEntryValue | null=信頼できない。最初に safeParse で型と長さをnarrow してからしか先へ進ませない(型安全・セキュリティ)。
  2. 失敗を型で表現{ ok: true } | { ok: false } の判別可能ユニオンで返し、throw に頼らない。呼び出し側がエラー処理を忘れられない(信頼性)。
  3. 秘密情報はサーバーに閉じる:埋め込み生成は Server Action 内。APIキーがクライアントへ出る経路がない(最小権限)。

5. アクセシブルな検索UI(a11y を最初から)

検索は非同期です。待ち時間と結果の更新を支援技術にも伝えるのが本番品質の最低ラインです。useActionState で Server Action を呼び、結果をセマンティックなリストaria-live で描画します。

// app/search/search-form.tsx
"use client";

import { useActionState } from "react";
import { search, type SearchResult } from "./actions";

type State = { ok: true; results: SearchResult[] } | { ok: false; error: string } | null;

export function SearchForm() {
  const [state, formAction, isPending] = useActionState<State, FormData>(
    (_prev, formData) => search(formData),
    null,
  );

  return (
    <section aria-labelledby="search-heading">
      <h2 id="search-heading">ドキュメント検索</h2>

      <form action={formAction} role="search">
        <label htmlFor="q">検索語</label>
        <input id="q" name="q" type="search" required maxLength={500} autoComplete="off" />
        <button type="submit" disabled={isPending} aria-busy={isPending}>
          {isPending ? "検索中…" : "検索"}
        </button>
      </form>

      {/* 非同期の結果・エラーを支援技術に通知(視覚的にも論理的にも一貫) */}
      <div aria-live="polite" aria-atomic="true">
        {state?.ok === false && <p role="alert">{state.error}</p>}
        {state?.ok === true &&
          (state.results.length === 0 ? (
            <p>一致する結果はありませんでした。</p>
          ) : (
            <ul>
              {state.results.map((r) => (
                <li key={r.id}>
                  <p>{r.content}</p>
                  {/* 類似度はパーセント表記で意味を明示 */}
                  <span aria-label={`類似度 ${Math.round(r.similarity * 100)}パーセント`}>
                    {Math.round(r.similarity * 100)}%
                  </span>
                </li>
              ))}
            </ul>
          ))}
      </div>
    </section>
  );
}

a11y のポイント:role="search" ランドマーク、ラベルと入力の明示的な関連付けhtmlFor/id)、送信中の aria-busy、結果領域の aria-live="polite"(割り込まず読み上げ)、エラーの role="alert"、数値の aria-label 補足。視覚効果ではなく構造でアクセシビリティを担保します(WCAG)。


6. 落とし穴:インデックスのビルドパラメータは「生成DDLを見る」

第1章では HNSW インデックスを Drizzle スキーマで宣言しました。m / ef_construction明示したい場合、Drizzle には .with({ ... }) がありますが、ここはバージョン依存で注意が要ります

  • .with() にはプレーンなオブジェクトを渡します(sql.raw(...) を渡すと壊れたDDLになる既知の不具合報告あり)。
  • ベクトル特有のビルドパラメータ(m / ef_construction / lists)を .with() に載せる例は公式に明記されていないため、生成されるマイグレーションSQLを必ず目視確認してください。

最も確実なのは、ビルドパラメータまで厳密に制御したいインデックスを「raw SQL マイグレーション」で定義することです。これなら pgvector の公式構文そのままで、バージョンに左右されません(KISS)。

-- drizzle/0001_hnsw_index.sql(手書きマイグレーション)
CREATE INDEX IF NOT EXISTS doc_chunks_embedding_idx
    ON doc_chunks
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);
// または TS マイグレーション内で
await db.execute(sql`
  CREATE INDEX IF NOT EXISTS doc_chunks_embedding_idx
  ON doc_chunks USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64)
`);

方針列とテーブルは Drizzle スキーマ(型の源)/ パラメータ厳密なベクトルインデックスは raw SQL という分担が、型安全と制御性を両立させる現実解です。drizzle-kit generate の出力を毎回レビューに通すのを習慣にしてください。


7. まとめ:型安全な pgvector 実装チェックリスト

  • スキーマvector("embedding", { dimensions: 1024 })index(...).using("hnsw", col.op("vector_cosine_ops"))$inferSelect/Insert で型をSSoT化。
  • 拡張CREATE EXTENSION IF NOT EXISTS vector手動マイグレーションの先頭に(drizzle-kit は生成しない)。
  • 検索cosineDistancesql\1 - (...)`で類似度化 →orderBy(desc(...))limit`。入力はバインドパラメータでSQLインジェクション安全
  • 境界:Server Action で Zod 検証 → 埋め込み生成 → 検索。失敗は判別可能ユニオンで返し throw に頼らない。秘密情報はサーバーに閉じる。
  • a11yrole="search"・ラベル関連付け・aria-busyaria-liverole="alert" を最初から。
  • インデックスのビルドパラメータ.with() はバージョン依存 → 厳密に制御するなら raw SQL マイグレーション、生成DDLは必ずレビュー。

TypeScript で pgvector を扱う価値は、スキーマから検索クエリ、Server Action の境界、UIの描画まで、型とバリデーションが一本の線でつながることです。any を排し、入力を境界で narrow し、SQLインジェクションを構造的に閉じ、a11y を構造で担保する——これが「動く」と「本番に出せる」の差です。

私は 生成AI音声チャットボット で pgvector を本番運用しました(バックエンドは Python/Flask でしたが、設計思想——型・境界検証・冪等性・整合性——は本稿の TypeScript 実装とそのまま同じです)。Next.js / Drizzle のフルスタックでも、同じ規律で速く・安全に作れます。

「Next.js / TypeScript で、自社データのAI意味検索を型安全に実装したい」——スキーマ設計から本番運用までを一気通貫で伴走できます。 Drizzle や Supabase/Neon を含む構成の相談も歓迎です。お気軽にどうぞ。


参考(公式ドキュメント)

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

生成AI音声チャットボット(PostgreSQL + pgvector に業務データと埋め込みを集約したRAG接客システム)

ケーススタディを見る