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.
Entity setup
Section titled “Entity setup”A vector field is f.vector(dimensions). A vector index makes search approximate and fast instead of exact and slow:
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.
Query by similarity
Section titled “Query by similarity”Sort by the field with $vector, optionally choosing a metric with $distance:
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::vectorLIMIT 10SELECT `id`, `title` FROM `Article`ORDER BY VEC_DISTANCE_COSINE(`embedding`, ?)LIMIT 10SELECT `id`, `title` FROM `Article`ORDER BY vec_distance_cosine(`embedding`, ?)LIMIT 10SELECT `id`, `title` FROM `Article`WHERE `Article`.rowid IN (SELECT id FROM vector_top_k(?, ?, ?))ORDER BY vector_distance_cos(`embedding`, ?)LIMIT 10SELECT "id", "title" FROM "Article"ORDER BY VECTOR_DISTANCE('cosine', "embedding", CAST(@p1 AS VECTOR(1536)))OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY[ { "$vectorSearch": { "index": "embedding_index", "path": "embedding", "queryVector": [/* queryVec */], "numCandidates": 100, "limit": 10 } }]Combined with filtering
Section titled “Combined with filtering”$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.
Distance predicate
Section titled “Distance predicate”$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.
const results = await pool.findMany(Article, { $where: { embedding: { $near: { $vector: queryVec, $lt: 0.35 } } }, $limit: 10,});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 } } },});Filter and rank together
Section titled “Filter and rank together”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.
Distance projection
Section titled “Distance projection”$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.
Distance metrics
Section titled “Distance metrics”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_ |
VEC_ |
no | no |
| SQLite (sqlite-vec) | vec_ |
vec_ |
no | vec_ |
| libSQL | vector_ |
vector_ |
no | no |
| Turso (local) | vector_ |
vector_ |
vector_ |
no |
| MSSQL | VECTOR_ |
VECTOR_ |
VECTOR_ |
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.
Ranking by a related row
Section titled “Ranking by a related row”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,});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 10It 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.
Vector types
Section titled “Vector types”| Type | Postgres type | Storage | Max dimensions | Use for |
|---|---|---|---|---|
f. |
VECTOR |
32-bit float | 2,000 | Standard embeddings |
f. |
HALFVEC |
16-bit float | 4,000 | Half the storage |
f. |
SPARSEVEC |
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.
Vector indexes
Section titled “Vector indexes”Declare them in indexes:
type |
Engines | Options |
|---|---|---|
hnsw |
Postgres, CockroachDB, libSQL, Turso Cloud | m, ef |
ivfflat |
Postgres (not with sparsevec or l1), libSQL, Turso Cloud |
lists |
vector |
CockroachDB, MariaDB, libSQL, Turso Cloud | m on MariaDB |
vector |
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.
Tuning recall
Section titled “Tuning recall”An approximate index can miss a closer row. $candidates widens the search for one query:
const results = await pool.findMany(Article, { $sort: { embedding: { $vector: queryVec } }, $limit: 10, $candidates: 200,});| Engine | Index | Becomes | Default |
|---|---|---|---|
| PostgreSQL | hnsw |
SET LOCAL hnsw. |
40 |
| PostgreSQL | ivfflat |
SET LOCAL ivfflat. |
1 |
| CockroachDB | hnsw/vector |
SET LOCAL vector_ |
32 |
| MariaDB | vector |
SET STATEMENT mhnsw_ |
20 |
| libSQL / Turso Cloud | any | k of vector_ |
the page |
| MongoDB Atlas | vector |
num |
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(...).
Thresholds need it most
Section titled “Thresholds need it most”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.
Engine notes
Section titled “Engine notes”- Postgres: migrations create the
vectorextension. - CockroachDB:
hnswmaps to its native index, withefConstructionasbuild_beam_size;mis ignored andivfflatrefused. - 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, forcosineorl2, withmasmax_neighborsandefConstructionasinsert_l. Index names allow only letters, digits and underscores. Vectors areF32_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 owndistance, else the field’s, and a$distancenaming another throws.
To mix vector and keyword search, see Full-text search.