# 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までを実コードで解説します。

- 公開日: 2026-06-26
- 著者: 友田 陽大
- タグ: TypeScript, Next.js, PostgreSQL, RAG, Supabase
- URL: https://tomodahinata.com/blog/pgvector-typescript-drizzle-orm-nextjs-type-safe-vector-search-guide

## 要点

- Drizzle の vector 列（dimensions）と HNSW インデックス（.using('hnsw', col.op('vector_cosine_ops'))）でスキーマを型安全に定義する
- 拡張の有効化は drizzle-kit が生成しない。`create extension if not exists vector` を手動マイグレーションの最初に置くのが正解
- kNN は cosineDistance + sql`1 - (...)` で類似度に変換し、orderBy + limit。クエリ埋め込みはバインドパラメータなのでSQLインジェクションに安全
- Server Action 境界で Zod 検証 → 埋め込み生成 → 検索、で入力をサーバー側に閉じる。秘密情報は環境変数前提
- ビルドパラメータ(m/ef_construction)の .with() 受け渡しはバージョン依存。厳密に制御したいインデックスは raw SQL マイグレーションで定義し、生成DDLを必ず確認する

---

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 の始め方](/blog/pgvector-getting-started-installation-docker-supabase-rds-neon-guide) を先に。RAGの全体設計や埋め込み次元の決め方は [本番RAG設計](/blog/pgvector-postgres-production-rag-hybrid-search) に、インデックスのチューニングは [チューニング完全ガイド](/blog/pgvector-index-tuning-hnsw-ivfflat-quantization-iterative-scan-guide) にあります。本稿は **TypeScript 実装**に集中します。

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

---

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

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

```typescript
// 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` 列があっても、拡張自体は別途有効化する必要があります。正解は、**マイグレーションの最初のステップとして自分で書く**こと。

```typescript
// 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-orm`**（`pg-core` ではない）から提供します。テキスト埋め込みなら `cosineDistance`。**距離は「小さいほど近い」**ので、人に見せる*類似度*には `1 - 距離` に変換します。

```typescript
// 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(...)` に渡す `queryEmbedding` も `gt(similarity, 0.3)` の閾値も、Drizzle の `sql` テンプレートが**バインドパラメータ（`$1, $2, …`）**として送るため、文字列連結は発生しません。ユーザー入力が直接SQL文字列に混ざる経路がない——これが ORM を境界に置く価値です（セキュリティ）。

> **インデックスを効かせる形**：HNSW が効くのは「`ORDER BY <距離> ... LIMIT k`」の形です。`desc(類似度)` は内部的に距離の昇順、すなわちインデックスが効く並びになります。効いているかは `EXPLAIN` で `Index Scan using doc_chunks_embedding_idx` を確認してください（→ [チューニング完全ガイド](/blog/pgvector-index-tuning-hnsw-ivfflat-quantization-iterative-scan-guide)）。

### 挿入：`number[]` を渡すだけ

```typescript
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キーはクライアントに漏れません。

```typescript
// 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`** で描画します。

```tsx
// 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）。

```sql
-- 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);
```

```typescript
// または 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 は生成しない）。
- **検索**：`cosineDistance` ＋ `sql\`1 - (...)\`` で類似度化 → `orderBy(desc(...))` ＋ `limit`。入力はバインドパラメータで**SQLインジェクション安全**。
- **境界**：Server Action で **Zod 検証 → 埋め込み生成 → 検索**。失敗は判別可能ユニオンで返し `throw` に頼らない。秘密情報はサーバーに閉じる。
- **a11y**：`role="search"`・ラベル関連付け・`aria-busy`・`aria-live`・`role="alert"` を最初から。
- **インデックスのビルドパラメータ**：`.with()` はバージョン依存 → **厳密に制御するなら raw SQL マイグレーション**、生成DDLは必ずレビュー。

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

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

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

---

### 参考（公式ドキュメント）

- [Drizzle ORM — ベクトル類似検索ガイド](https://orm.drizzle.team/docs/guides/vector-similarity-search) ／ [PostgreSQL拡張（pgvector）](https://orm.drizzle.team/docs/extensions/pg) ／ [インデックス & 制約](https://orm.drizzle.team/docs/indexes-constraints) — `vector` 列・`cosineDistance`/`l2Distance`/`innerProduct`・`.using('hnsw', col.op(...))`
- [pgvector（GitHub・README）](https://github.com/pgvector/pgvector) — `CREATE EXTENSION vector`・HNSW の `WITH (m, ef_construction)`・演算子クラス
- [OpenAI Embeddings ガイド](https://platform.openai.com/docs/guides/embeddings) — `text-embedding-3-large` ／ `dimensions` パラメータ
