Vector Search (pgvector)

Rank rows by similarity to a query vector, for semantic search, recommendations, or RAG retrieval, without dropping to raw SQL. Turbine queries pgvector columns through the same findMany you already use, with orderBy for nearest-neighbor ranking and where for distance thresholds.

You need the pgvector extension installed and a vector column on your table. Everything else is ordinary Turbine.

KNN, order by distance#

Pass a distance object on a vector column in orderBy to sort rows by their distance to a query vector. Add limit to take the closest k rows.

const similar = await db.items.findMany({
  orderBy: { embedding: { distance: { to: queryVector, metric: 'cosine' } } },
  limit: 5,
});
// queryVector is a number[]; the 5 nearest rows, nearest-first

to is a number[]. The default sort direction is 'asc', smallest distance first, i.e. nearest-first. To invert it, pass direction: 'desc':

const farthest = await db.items.findMany({
  orderBy: { embedding: { distance: { to: queryVector, metric: 'cosine', direction: 'desc' } } },
  limit: 5,
});

This compiles to ORDER BY "embedding" <=> $1::vector ASC with the vector bound as a parameter, never interpolated.

Distance filter, keep rows within a threshold#

Put the same distance object in where with a comparator to keep only rows inside (or outside) a distance threshold.

const close = await db.items.findMany({
  where: { embedding: { distance: { to: queryVector, metric: 'l2', lt: 0.3 } } },
});
// Generates: WHERE "embedding" <-> $1::vector < $2

Distance filters accept lt, lte, gt, and gte. Both the vector and the threshold are bound as parameters ($1::vector < $2). A distance filter with no comparator throws ValidationError.

Combine a filter with KNN ordering to retrieve the closest matches above a quality bar:

const results = await db.items.findMany({
  where: { embedding: { distance: { to: queryVector, metric: 'cosine', lt: 0.4 } } },
  orderBy: { embedding: { distance: { to: queryVector, metric: 'cosine' } } },
  limit: 10,
});

Metrics#

metric selects the pgvector distance operator:

metricOperatorDistance
'l2'<->Euclidean (L2) distance
'cosine'<=>Cosine distance
'ip'<#>Negative inner product

An unknown metric throws ValidationError. A distance op on a non-vector column also throws ValidationError.

Note: pg has no built-in parser for the vector type, so a fetched vector column comes back as a string literal like '[1,2,3]' unless you register a parser (for example via pgvector's own client helpers). Querying by distance works regardless, this only affects how the column value reads when you select it.

The exported types#

The vector surface is fully typed and every type is exported from the package root, so you can build a query object in a helper and hand it to findMany without losing type safety.

import type {
  VectorMetric,
  VectorFilter,
  VectorDistanceFilter,
  VectorOrderBy,
  VectorOrderByDistance,
} from 'turbine-orm';
TypeShapeWhere it goes
VectorMetric'l2' | 'cosine' | 'ip'The metric field of both specs below. A fixed allow-list, so a user-supplied string can never become a SQL operator.
VectorDistanceFilter{ to: number[]; metric: VectorMetric; lt?, lte?, gt?, gte?: number }The distance value inside a where.
VectorFilter{ distance: VectorDistanceFilter }The per-column value in where, i.e. where: { embedding: VectorFilter }.
VectorOrderByDistance{ to: number[]; metric: VectorMetric; direction?: 'asc' | 'desc' }The distance value inside an orderBy. direction defaults to 'asc', which is nearest-first.
VectorOrderBy{ distance: VectorOrderByDistance }The per-column value in orderBy, i.e. orderBy: { embedding: VectorOrderBy }.

A typical use is a shared retrieval helper that takes the query embedding once and builds both halves:

import type { VectorFilter, VectorMetric, VectorOrderBy } from 'turbine-orm';
 
function knn(to: number[], metric: VectorMetric = 'cosine', maxDistance?: number) {
  const order: VectorOrderBy = { distance: { to, metric } };
  const filter: VectorFilter | undefined =
    maxDistance === undefined ? undefined : { distance: { to, metric, lt: maxDistance } };
  return { order, filter };
}
 
const { order, filter } = knn(queryVector, 'cosine', 0.4);
 
const results = await db.items.findMany({
  where: filter ? { embedding: filter } : undefined,
  orderBy: { embedding: order },
  limit: 10,
});

Validation and safety#

Every value in a vector query is bound as a parameter, so hostile input can't escape the query:

  • The query vector is always bound as $n::vector.
  • The distance threshold is always bound as $n.
  • A non-number element in to (including NaN or Infinity) throws ValidationError before any query runs.

The same query, three ways#

The task: the 10 nearest rows by cosine distance, filtered to a quality threshold, fully parameterized.

Turbine, distance is a first-class where / orderBy operator on vector columns:

const results = await db.items.findMany({
  where: { embedding: { distance: { to: queryVector, metric: 'cosine', lt: 0.4 } } },
  orderBy: { embedding: { distance: { to: queryVector, metric: 'cosine' } } },
  limit: 10,
});

Prisma, pgvector columns are Unsupported("vector") in the schema, so querying by distance means $queryRaw with the vector serialized to a string and cast by hand:

const embedding = `[${queryVector.join(',')}]`;
 
const results = await prisma.$queryRaw<
  { id: number; title: string; distance: number }[]
>`
  SELECT id, title, embedding <=> ${embedding}::vector AS distance
  FROM items
  WHERE embedding <=> ${embedding}::vector < 0.4
  ORDER BY distance
  LIMIT 10
`;

Drizzle, a native vector column type plus SQL expression helpers; the distance expression is built with cosineDistance and threaded through where / orderBy:

import { asc, cosineDistance, lt } from 'drizzle-orm';
 
const distance = cosineDistance(items.embedding, queryVector);
 
const results = await db
  .select({ id: items.id, title: items.title, distance })
  .from(items)
  .where(lt(distance, 0.4))
  .orderBy(asc(distance))
  .limit(10);

All three run the same SQL. The difference is where the vector semantics live: in Turbine they're part of the typed query object; in Prisma they're raw SQL you maintain; in Drizzle they're SQL expression helpers you compose yourself.

See also#

  • API Reference, findMany, where, orderBy, and the full operator table.
  • Realtime, LISTEN / NOTIFY pub/sub.
  • Relations, combine vector ranking with nested with loads.