# Type-safe vector search built with pgvector × TypeScript × Drizzle ORM × Next.js (Server Actions, Zod boundary validation)

> An implementation guide to handling pgvector type-safely from TypeScript / Next.js. With real code it explains Drizzle ORM's vector column and HNSW index schema definition, enabling the extension (manual migration since drizzle-kit doesn't generate it), a kNN query with cosineDistance, Zod boundary validation and embedding generation in a Server Action, SQL-injection safety, and an accessible search UI.

- Published: 2026-06-26
- Author: 友田 陽大
- Tags: TypeScript, Next.js, PostgreSQL, RAG, Supabase
- URL: https://tomodahinata.com/en/blog/pgvector-typescript-drizzle-orm-nextjs-type-safe-vector-search-guide
- Category: Databases & RLS
- Pillar guide: https://tomodahinata.com/en/blog/supabase-production-guide-nextjs-rls-realtime-edge-functions

## Key points

- Define the schema type-safely with Drizzle's vector column (dimensions) and an HNSW index (.using('hnsw', col.op('vector_cosine_ops'))).
- drizzle-kit doesn't generate the extension enabling. Placing `create extension if not exists vector` at the start of a manual migration is the correct answer.
- kNN is converted to similarity with cosineDistance + sql`1 - (...)`, then orderBy + limit. The query embedding is a bind parameter so it's SQL-injection safe.
- Close input on the server side with Zod validation → embedding generation → search at the Server Action boundary. Secrets assume environment variables.
- The .with() passing of build parameters (m/ef_construction) is version-dependent. For indexes you want to control strictly, define them in a raw SQL migration and always confirm the generated DDL.

---

Explanations of pgvector are often written in Python and raw SQL, and for **full-stack TypeScript / Next.js developers**, "so how do I write this type-safely?" tends to be missing.

This article fills that hole. Using **Drizzle ORM**, it implements **type-safe pgvector vector search end-to-end, from schema definition to a Next.js Server Action.** Without using `any`, validating user input with `Zod` at the boundary, structurally preventing SQL injection, and rendering results accessibly — it shows a **production-ready** implementation in a minimal configuration.

> **Rules for this article**: Drizzle's API is based on the **official documentation (orm.drizzle.team) and the drizzle-orm spec (vector support is 0.31.0 or later).** The pgvector syntax conforms to the **official README.** Because Drizzle's index/build-parameter area **can change behavior by version**, follow the notes below and **always confirm the generated DDL.** The connection string and API key **assume environment variables** (hardcoding strictly forbidden).

---

## 0. Prerequisites

We proceed on the premise that pgvector's enabling (`CREATE EXTENSION vector`) is done. If not yet, do [getting started with pgvector](/blog/pgvector-getting-started-installation-docker-supabase-rds-neon-guide) first. The overall RAG design and how to decide the embedding dimensions are in [production RAG design](/blog/pgvector-postgres-production-rag-hybrid-search), and index tuning is in the [complete tuning guide](/blog/pgvector-index-tuning-hnsw-ivfflat-quantization-iterative-scan-guide). This article concentrates on the **TypeScript implementation.**

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

---

## 1. Schema: define the vector column and HNSW index by type

In Drizzle, declare the `vector` column from `drizzle-orm/pg-core` and the dimensions with `dimensions`. **Always match the column's dimensions and the embedding model's output dimensions** (a mismatch is an error on insert = protected by type/constraint).

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

With `$inferSelect` / `$inferInsert`, **the row's type is auto-derived from the schema.** This is the reason to choose Drizzle — the schema becomes the **single source of truth (SSoT)** and the type and DB don't diverge (DRY).

---

## 2. Enable the extension with a "manual migration" (important)

This is a sticking point. **drizzle-kit doesn't auto-generate `CREATE EXTENSION vector`.** Even with a `vector` column in the schema, the extension itself needs to be enabled separately. The correct answer is **to write it yourself as the first step of the migration.**

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

> **Why it isn't auto-generated**: enabling the extension requires high privileges, and premises differ by environment (RDS/Supabase/Neon…), so Drizzle doesn't step in. It sinks in if you grasp it as the responsibility separation (SRP) of **"a human prepares the extension, the tool manages the schema."**

---

## 3. Type-safe kNN query

Drizzle provides distance helpers from **`drizzle-orm`** (not `pg-core`). For text embeddings, `cosineDistance`. **Distance is "the smaller the closer,"** so for a *similarity* shown to people, convert to `1 - distance`.

```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 injection is structurally safe.** Both the `queryEmbedding` passed to `cosineDistance(...)` and the `gt(similarity, 0.3)` threshold are sent by Drizzle's `sql` template as **bind parameters (`$1, $2, …`)**, so no string concatenation occurs. There's no path where user input mixes directly into the SQL string — this is the value of placing an ORM at the boundary (security).

> **The form that makes the index work**: HNSW works for the form "`ORDER BY <distance> ... LIMIT k`." `desc(similarity)` is internally ascending order of distance, i.e., an order where the index works. Confirm it's working by checking `Index Scan using doc_chunks_embedding_idx` with `EXPLAIN` (→ [complete tuning guide](/blog/pgvector-index-tuning-hnsw-ivfflat-quantization-iterative-scan-guide)).

### Insert: just pass `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" },
});
```

If the array length matches `dimensions`, **Drizzle does the conversion to the pgvector literal**, so manual stringification is unnecessary.

---

## 4. Next.js Server Action: validate at the boundary, close on the server

Receive the user's search string and complete everything — **Zod boundary validation → embedding generation → vector search** — on the **server side (Server Action).** The embedding API key doesn't leak to the client.

```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 };
}
```

Let me organize the crux of the design.

1. **Zod validation at the boundary**: `formData.get("q")` is `FormDataEntryValue | null` = untrustworthy. Don't let it proceed until you **narrow** the type and length with `safeParse` first (type safety, security).
2. **Express failure with a type**: return with the discriminated union `{ ok: true } | { ok: false }` and don't rely on `throw`. The caller **can't forget** error handling (reliability).
3. **Close secrets on the server**: embedding generation is inside the Server Action. There's no path for the API key to go to the client (least privilege).

---

## 5. An accessible search UI (a11y from the start)

Search is asynchronous. **Conveying the wait time and the result update to assistive technology too** is the minimum line of production quality. Call the Server Action with `useActionState` and render the results with a **semantic list** + **`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 points: the **`role="search"` landmark**, **explicit association** of label and input (`htmlFor`/`id`), **`aria-busy`** while submitting, **`aria-live="polite"`** on the result area (read aloud without interrupting), **`role="alert"`** for errors, and the **`aria-label`** supplement for the number. Guarantee accessibility by **structure**, not visual effects (WCAG).

---

## 6. Pitfall: for an index's build parameters, "look at the generated DDL"

In chapter 1, we declared the HNSW index in the Drizzle schema. If you **want to make `m` / `ef_construction` explicit**, Drizzle has `.with({ ... })`, but **this is version-dependent and needs care.**

- Pass **a plain object** to `.with()` (there's a known bug report that passing `sql.raw(...)` produces broken DDL).
- Because an example of putting vector-specific build parameters (`m` / `ef_construction` / `lists`) on `.with()` is **not explicitly in the official docs**, **always visually confirm the generated migration SQL.**

**The surest is to define indexes you want to control strictly down to the build parameters with a "raw SQL migration."** This way it's exactly the pgvector official syntax and isn't swayed by version (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)
`);
```

> **The policy**: the division of **columns and tables in the Drizzle schema (the source of types) / parameter-strict vector indexes in raw SQL** is the realistic answer that balances type safety and controllability. Make it a habit to run the `drizzle-kit generate` output through review every time.

---

## 7. Conclusion: a type-safe pgvector implementation checklist

- **Schema**: `vector("embedding", { dimensions: 1024 })` + `index(...).using("hnsw", col.op("vector_cosine_ops"))`. SSoT the types with `$inferSelect/Insert`.
- **Extension**: put `CREATE EXTENSION IF NOT EXISTS vector` at the **head of a manual migration** (drizzle-kit doesn't generate it).
- **Search**: similarity-ize with `cosineDistance` + `sql\`1 - (...)\`` → `orderBy(desc(...))` + `limit`. Input is **SQL-injection safe** with bind parameters.
- **Boundary**: in the Server Action, **Zod validation → embedding generation → search.** Return failure with a discriminated union and don't rely on `throw`. Close secrets on the server.
- **a11y**: `role="search"`, label association, `aria-busy`, `aria-live`, `role="alert"` from the start.
- **Index build parameters**: `.with()` is version-dependent → **raw SQL migration if controlling strictly**, always review the generated DDL.

The value of handling pgvector in TypeScript is that **types and validation connect in a single line from schema to search query, the Server Action boundary, and UI rendering.** Eliminate `any`, narrow input at the boundary, structurally close SQL injection, and guarantee a11y by structure — this is the difference between "it works" and "it can be shipped to production."

I operated pgvector in production in the [generative-AI voice chatbot](/case-studies/ai-voice-chatbot) (the backend was Python/Flask, but the design philosophy — types, boundary validation, idempotency, consistency — is exactly the same as this article's TypeScript implementation). Even in a Next.js / Drizzle full stack, you can build fast and safely with the same discipline.

**"I want to implement AI semantic search of my own company's data type-safely in Next.js / TypeScript" — I can accompany you end-to-end from schema design to production operation.** Consultations on configurations including Drizzle or Supabase/Neon are also welcome. Feel free to reach out.

---

### References (official documentation)

- [Drizzle ORM — vector similarity search guide](https://orm.drizzle.team/docs/guides/vector-similarity-search) / [PostgreSQL extensions (pgvector)](https://orm.drizzle.team/docs/extensions/pg) / [Indexes & constraints](https://orm.drizzle.team/docs/indexes-constraints) — `vector` column, `cosineDistance`/`l2Distance`/`innerProduct`, `.using('hnsw', col.op(...))`
- [pgvector (GitHub, README)](https://github.com/pgvector/pgvector) — `CREATE EXTENSION vector`, HNSW's `WITH (m, ef_construction)`, operator classes
- [OpenAI Embeddings guide](https://platform.openai.com/docs/guides/embeddings) — `text-embedding-3-large` / the `dimensions` parameter
