Skip to content
NewFrom Mongoose to SQL: migrating MongoDB to PostgreSQL7 min read

Semantic Search

Semantic search finds rows by meaning: each row stores an embedding, and you sort or filter by its distance to a query embedding. This page is the reference. For a guided RAG example, see AI & RAG.

It runs on PostgreSQL (pgvector), CockroachDB, MariaDB 11.7+, SQLite (sqlite-vec), libSQL, Turso, SQL Server 2025 and MongoDB Atlas. MySQL and Cloudflare D1 have no vector functions, so a vector query throws there; MySQL stores the field as JSON and D1 as text. On Cloudflare, use Vectorize.

A vector field is f.vector(dimensions). A vector index makes search approximate and fast instead of exact and slow:

You write
import { f, Entity, Field } from 'uql-orm';
@Entity({
indexes: (article, index) => [
index([article.embedding], {
type: 'hnsw',
distance: 'cosine',
m: 16,
efConstruction: 64,
}),
],
})
export class Article {
@Field(f.number().autoIncrement()) id?: number;
@Field(f.string().nullable()) title?: string | null;
@Field(f.string().nullable()) category?: string | null;
@Field(f.vector(1536).nullable()) embedding?: number[] | null;
}

Plain SQLite needs the sqlite-vec extension loaded through the pool:

import { getLoadablePath } from 'sqlite-vec';
import { SqliteQuerierPool } from 'uql-orm/sqlite';
const pool = new SqliteQuerierPool('app.db', {
extensions: [getLoadablePath()],
});

Other engine requirements are in engine notes.

Sort by the field with $vector, optionally choosing a metric with $distance:

You write
const results = await pool.findMany(Article, {
$select: { id: true, title: true },
$sort: { embedding: { $vector: queryVec, $distance: 'cosine' } },
$limit: 10,
});
SELECT "id", "title" FROM "Article"
ORDER BY "embedding" <=> $1::vector
LIMIT 10

$where and other $sort keys work as usual. The other keys rank after the distance:

await pool.findMany(Article, {
$where: { category: 'science' },
$sort: { embedding: { $vector: queryVec }, title: 'asc' },
$limit: 10,
});

On MongoDB Atlas, $where becomes the $vectorSearch filter, so its fields must be in the Atlas index.

$near in $where keeps only rows within a distance. Use it when a distant match is worse than none: RAG context, deduplication, match thresholds. It needs at least one of $lt, $lte, $gt, $gte or $between. There is no $eq, since exact equality on a float distance is a bug.

You write
const results = await pool.findMany(Article, {
$where: { embedding: { $near: { $vector: queryVec, $lt: 0.35 } } },
$limit: 10,
});
PostgreSQL
SELECT "id", "title", "category", "embedding" FROM "Article"
WHERE "embedding" <=> $1::vector < $2 LIMIT 10

$near carries its own vector, so it works with any sort, or none, and in exists:

const isDuplicate = await pool.exists(Article, {
$where: { embedding: { $near: { $vector: queryVec, $lt: 0.05 } } },
});

Pass the same vector to $near and $sort to keep close rows and rank them:

const results = await pool.findMany(Article, {
$where: { embedding: { $near: { $vector: queryVec, $lt: 0.35 } } },
$sort: { embedding: { $vector: queryVec, $project: 'score' } },
$limit: 30,
});

On an HNSW index, add $candidates too, or you may get fewer rows than qualify.

$project names a field that returns the distance, as in $sort: { embedding: { $vector: queryVec, $project: 'distance' } }. Each row has it as a number, beside what $select picked. The SQL sorts by the projected alias, so the distance is computed once. On MongoDB Atlas it is computed by the index’s metric over the documents Atlas found, so it reads the same as on the SQL engines.

A populated relation’s $sort projects the same way, onto each of its rows:

const guide = await pool.findOneById(Guide, id, {
$populate: {
passages: {
$sort: { embedding: { $vector: queryVec, $project: 'distance' } },
$limit: 3,
},
},
});
guide?.passages[0].distance;

Inside a relation the search is exact, with no vector index. MongoDB computes it over the relation’s documents, since Atlas cannot search inside a lookup.

cosine suits text embeddings, l2 images and spatial data, inner is maximum inner product, and l1 is Manhattan distance.

Engine cosine l2 inner l1
Postgres <=> <-> <#> <+>
CockroachDB <=> <-> <#> no
MariaDB VEC_DISTANCE_COSINE VEC_DISTANCE_EUCLIDEAN no no
SQLite (sqlite-vec) vec_distance_cosine vec_distance_L2 no vec_distance_L1
libSQL vector_distance_cos vector_distance_l2 no no
Turso (local) vector_distance_cos vector_distance_l2 vector_distance_dot no
MSSQL VECTOR_DISTANCE('cosine') VECTOR_DISTANCE('euclidean') VECTOR_DISTANCE('dot') no
MongoDB Atlas set by the index set by the index set by the index no

An unsupported metric throws when the query is built. uql-orm/turso refuses inner, since a Turso Cloud database may run libSQL.

A field can set its default metric with @Field(f.vector(1536).distance('l2')). A $distance overrides it; with neither, the field uses its index’s metric, or cosine.

A $vector under a relation ranks each row by its closest related row, such as a guide by its best passage:

const guides = await pool.findMany(Guide, {
$where: { title: { $istartsWith: 'indexing' } },
$sort: { passages: { embedding: { $vector: queryVec } } },
$limit: 10,
});
PostgreSQL
SELECT "id", "title" FROM "Guide" WHERE "title" ILIKE $1 ESCAPE '\'
ORDER BY (SELECT MIN("passages"."embedding" <=> $2::vector) FROM "Passage" "passages"
WHERE "passages"."guideId" = "Guide"."id")
LIMIT 10

It works on any relation kind without $populate, ranks ahead of other $sort keys, and takes $distance but not $project. A row with no related rows sorts where the engine puts nulls. It skips the vector index, so narrow the rows with $where. MongoDB computes it exactly over direct relations, with no Atlas needed.

Type Postgres type Storage Max dimensions Use for
f.vector(n) VECTOR(n) 32-bit float 2,000 Standard embeddings
f.column('halfvec').dimensions(n) HALFVEC(n) 16-bit float 4,000 Half the storage
f.column('sparsevec').dimensions(n) SPARSEVEC(n) Sparse 1,000,000 SPLADE and sparse models

Other engines store all three as their own vector type. You always pass a dense number[], sparsevec included.

Declare them in indexes:

type Engines Options
hnsw Postgres, CockroachDB, libSQL, Turso Cloud m, efConstruction
ivfflat Postgres (not with sparsevec or l1), libSQL, Turso Cloud lists
vector CockroachDB, MariaDB, libSQL, Turso Cloud m on MariaDB
vectorSearch MongoDB Atlas name
@Entity({
indexes: (article, index) => [
index([article.embedding], { type: 'ivfflat', distance: 'l2', lists: 100 }),
],
})
export class Article {
@Field(f.number().autoIncrement()) id?: number;
@Field(f.vector(1536).nullable()) embedding?: number[] | null;
}

MySQL and MSSQL refuse all of these in migrations. Plain SQLite, embedded Turso and D1 build a plain index instead, so a Postgres entity still migrates and search is exact.

An approximate index can miss a closer row. $candidates widens the search for one query:

You write
const results = await pool.findMany(Article, {
$sort: { embedding: { $vector: queryVec } },
$limit: 10,
$candidates: 200,
});
Engine Index Becomes Default
PostgreSQL hnsw SET LOCAL hnsw.ef_search 40
PostgreSQL ivfflat SET LOCAL ivfflat.probes 1
CockroachDB hnsw/vector SET LOCAL vector_search_beam_size 32
MariaDB vector SET STATEMENT mhnsw_ef_search=N FOR ... 20
libSQL / Turso Cloud any k of vector_top_k the page
MongoDB Atlas vectorSearch numCandidates 10x $limit

It is ignored where search is exact, and applies to the whole statement, not a populated relation.

On Postgres and CockroachDB, SET LOCAL applies only inside a transaction, so a tuned read outside one opens its own around the setting and the query: one request on Neon, a BEGIN and COMMIT more elsewhere. A tuned findManyStream is refused outside a transaction, which would stay open for the whole loop; run it inside pool.transaction(...).

On HNSW, $near filters only the candidates the index returns, so a threshold can return fewer rows than qualify. Filter and rank together and add $candidates: on Postgres, UQL then also sets hnsw.iterative_scan = strict_order, so the index keeps scanning in distance order until the limit is filled.

  • Postgres: migrations create the vector extension.
  • CockroachDB: hnsw maps to its native index, with efConstruction as build_beam_size; m is ignored and ivfflat refused.
  • MariaDB: the dimensions are required, and an indexed vector column is created NOT NULL.
  • libSQL and Turso Cloud: any index type builds libsql_vector_idx (DiskANN) on one column, for cosine or l2, with m as max_neighbors and efConstruction as insert_l. Index names allow only letters, digits and underscores. Vectors are F32_BLOB(n), as on SQLite.
  • SQL Server: the dimensions are required and search is always exact. A query with no sort gets ORDER BY (SELECT NULL) to page.
  • MongoDB: migrations create the Atlas index (default name <field>_index); extra fields in it can be pre-filtered with $where. Atlas builds it after the migration returns. The index sets the metric: its own distance, else the field’s, and a $distance naming another throws.

To mix vector and keyword search, see Full-text search.