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 first. The overall RAG design and how to decide the embedding dimensions are in production RAG design, and index tuning is in the complete tuning guide. This article concentrates on the TypeScript implementation.
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).
// 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.
// 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.
// 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 checkingIndex Scan using doc_chunks_embedding_idxwithEXPLAIN(→ complete tuning guide).
Insert: just pass 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" },
});
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.
// 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.
- Zod validation at the boundary:
formData.get("q")isFormDataEntryValue | null= untrustworthy. Don't let it proceed until you narrow the type and length withsafeParsefirst (type safety, security). - Express failure with a type: return with the discriminated union
{ ok: true } | { ok: false }and don't rely onthrow. The caller can't forget error handling (reliability). - 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.
// 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 passingsql.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).
-- 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)
`);
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 generateoutput 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 vectorat 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 (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 / PostgreSQL extensions (pgvector) / Indexes & constraints —
vectorcolumn,cosineDistance/l2Distance/innerProduct,.using('hnsw', col.op(...)) - pgvector (GitHub, README) —
CREATE EXTENSION vector, HNSW'sWITH (m, ef_construction), operator classes - OpenAI Embeddings guide —
text-embedding-3-large/ thedimensionsparameter