Vector Search#
Semantic search that matches by meaning, not keywords. Powered by PostgreSQL pgvector and AI-generated embeddings.
Warning
Vector search requires PostgreSQL with the
pgvector extension. MySQL is not supported.Overview#
Full-text search matches by keywords - the words in the query must appear in the data. Vector search matches by meaning - a search for "best wineries" can surface an article titled "Top Vineyards to Visit" even though the words don't overlap.
| Feature | Full-Text | Vector |
|---|---|---|
| Matches by | Keywords | Meaning |
| Speed | Fast | Slower (embedding + similarity) |
| Accuracy | Exact terms | Semantic similarity |
| Best for | IDs, codes, exact names | Descriptions, articles, recommendations |
How It Works#
typescript
// 1. Store: convert text to vector embedding, save alongside data
const embedding = await Embeddings::for([article.title]).generate();
await prisma.article.update({
where: { id: article.id },
data: { embedding: embedding[0].embedding } // [0.012, -0.044, ...]
});
// 2. Query: convert search query to embedding, find closest vectors
const queryEmbedding = await Embeddings::for(["best wineries"]).generate();
const results = await prisma.$queryRaw`
SELECT *, 1 - (embedding <=> ${queryVector}::vector) AS similarity
FROM articles
WHERE embedding IS NOT NULL
ORDER BY embedding <=> ${queryVector}::vector
LIMIT 10
`;Setup#
1. Enable pgvector Extension#
typescript
-- Run once as superuser
CREATE EXTENSION IF NOT EXISTS vector;2. Add Vector Column#
Add a vector column to your table. The dimensions must match your embedding model's output (e.g., 1536 for OpenAI text-embedding-3-small).
3. Create HNSW Index#
typescript
-- HNSW index for fast similarity search
CREATE INDEX ON articles USING hnsw (embedding vector_cosine_ops);Info
HNSW (Hierarchical Navigable Small World) is the recommended index for read-heavy semantic search. It offers strong recall without a training phase.
Prisma#
Query#
typescript
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function vectorSearch(queryEmbedding: number[], limit = 10, minSimilarity = 0.3) {
const vector = `[${queryEmbedding.join(",")}]`;
return prisma.$queryRaw`
SELECT *,
1 - (embedding <=> ${vector}::vector) AS similarity
FROM articles
WHERE embedding IS NOT NULL
AND 1 - (embedding <=> ${vector}::vector) >= ${minSimilarity}
ORDER BY embedding <=> ${vector}::vector
LIMIT ${limit}
`;
}
// Usage
const results = await vectorSearch(queryEmbedding, 10, 0.3);Helper Function#
typescript
// Reusable vector search helper for Prisma
async function vectorSearch<T>(
prisma: PrismaClient,
table: string,
embedding: number[],
opts: { minSimilarity?: number; limit?: number; where?: string } = {}
): Promise<(T & { similarity: number })[]> {
const vector = `[${embedding.join(",")}]`;
const minSimilarity = opts.minSimilarity ?? 0.3;
const limit = opts.limit ?? 10;
const whereClause = opts.where ? `AND ${opts.where}` : "";
return prisma.$queryRawUnsafe(`
SELECT *,
1 - (embedding <=> '${vector}'::vector) AS similarity
FROM ${table}
WHERE embedding IS NOT NULL
AND 1 - (embedding <=> '${vector}'::vector) >= ${minSimilarity}
${whereClause}
ORDER BY embedding <=> '${vector}'::vector
LIMIT ${limit}
`);
}
// Usage
const results = await vectorSearch(prisma, "articles", queryEmbedding, {
minSimilarity: 0.3,
limit: 10,
where: "published = true"
});Drizzle#
Query#
typescript
import { sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/node-postgres";
const db = drizzle(process.env.DATABASE_URL!);
async function vectorSearch(queryEmbedding: number[], limit = 10, minSimilarity = 0.3) {
const vector = `[${queryEmbedding.join(",")}]`;
return db.execute(sql`
SELECT *,
1 - (embedding <=> ${vector}::vector) AS similarity
FROM articles
WHERE embedding IS NOT NULL
AND 1 - (embedding <=> ${vector}::vector) >= ${minSimilarity}
ORDER BY embedding <=> ${vector}::vector
LIMIT ${limit}
`);
}
// Usage
const results = await vectorSearch(queryEmbedding, 10, 0.3);Helper Function#
typescript
import { sql } from "drizzle-orm";
import type { NodePGDatabase } from "drizzle-orm/node-postgres";
async function vectorSearch<T>(
db: NodePGDatabase<any>,
table: string,
embedding: number[],
opts: { minSimilarity?: number; limit?: number; where?: string } = {}
) {
const vector = `[${embedding.join(",")}]`;
const minSimilarity = opts.minSimilarity ?? 0.3;
const limit = opts.limit ?? 10;
const whereClause = opts.where ? sql` AND ${sql.raw(opts.where)}` : sql``;
return db.execute(sql`
SELECT *,
1 - (embedding <=> ${vector}::vector) AS similarity
FROM ${sql.raw(table)}
WHERE embedding IS NOT NULL
AND 1 - (embedding <=> ${vector}::vector) >= ${minSimilarity}
${whereClause}
ORDER BY embedding <=> ${vector}::vector
LIMIT ${limit}
`);
}TypeORM#
Query#
typescript
import { DataSource } from "typeorm";
const dataSource = new DataSource({ /* ... */ });
async function vectorSearch(queryEmbedding: number[], limit = 10, minSimilarity = 0.3) {
const vector = `[${queryEmbedding.join(",")}]`;
return dataSource.query(`
SELECT *,
1 - (embedding <=> $1::vector) AS similarity
FROM articles
WHERE embedding IS NOT NULL
AND 1 - (embedding <=> $1::vector) >= $2
ORDER BY embedding <=> $1::vector
LIMIT $3
`, [vector, minSimilarity, limit]);
}
// Usage
const results = await vectorSearch(queryEmbedding, 10, 0.3);Helper Function#
typescript
import type { DataSource } from "typeorm";
async function vectorSearch<T>(
dataSource: DataSource,
table: string,
embedding: number[],
opts: { minSimilarity?: number; limit?: number; where?: string } = {}
): Promise<(T & { similarity: number })[]> {
const vector = `[${embedding.join(",")}]`;
const minSimilarity = opts.minSimilarity ?? 0.3;
const limit = opts.limit ?? 10;
const whereClause = opts.where ? `AND ${opts.where}` : "";
return dataSource.query(`
SELECT *,
1 - (embedding <=> $1::vector) AS similarity
FROM ${table}
WHERE embedding IS NOT NULL
AND 1 - (embedding <=> $1::vector) >= $2
${whereClause}
ORDER BY embedding <=> $1::vector
LIMIT $3
`, [vector, minSimilarity, limit]);
}Hybrid Search#
Combine full-text search (keyword precision) with vector search (semantic meaning) for best results.
typescript
// Prisma example: hybrid search
async function hybridSearch(query: string, queryEmbedding: number[]) {
const vector = `[${queryEmbedding.join(",")}]`;
// Full-text search for exact keywords
const keywordResults = await prisma.article.findMany({
where: {
OR: [
{ title: { contains: query, mode: "insensitive" } },
{ body: { contains: query, mode: "insensitive" } },
],
},
take: 20,
});
// Vector search for semantic matches
const semanticResults = await prisma.$queryRaw`
SELECT *, 1 - (embedding <=> ${vector}::vector) AS similarity
FROM articles
WHERE embedding IS NOT NULL
ORDER BY embedding <=> ${vector}::vector
LIMIT 20
`;
// Merge and deduplicate
const seen = new Set();
return [...semanticResults, ...keywordResults].filter((item) => {
if (seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
}Recommendations#
Find similar items by using an existing record's embedding as the anchor:
typescript
// Find articles similar to article #42
const article = await prisma.article.findUnique({ where: { id: 42 } });
const recommendations = await prisma.$queryRaw`
SELECT *,
1 - (embedding <=> ${article.embedding}::vector) AS similarity
FROM articles
WHERE id != 42
AND embedding IS NOT NULL
ORDER BY embedding <=> ${article.embedding}::vector
LIMIT 5
`;Cost Considerations#
| Operation | Cost |
|---|---|
| pgvector extension | Free (open source) |
| Embedding generation | API call to AI provider (~$0.0001 per text) |
| Vector storage | Same as regular column storage |
| Similarity query | Free (computed by PostgreSQL) |
Info
Generate embeddings asynchronously using a queue job. Do not block HTTP requests on embedding API calls.