> Every UQL docs page, as Markdown: https://uql-orm.dev/llms.txt
> The same docs over MCP: https://uql-orm.dev/mcp
> Before writing UQL code, read the skill: https://uql-orm.dev/.well-known/agent-skills/uql-orm/SKILL.md

# Semantic Search

> Reference for vector search with $vector, $near and $candidates: entity setup, operators, metrics, indexes and recall per engine.

Source: https://uql-orm.dev/querying/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](https://uql-orm.dev/ai-semantic-search.md).

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](https://developers.cloudflare.com/vectorize/).

## Entity setup

A vector field is `f.vector(dimensions)`. A [vector index](#vector-indexes) makes search approximate and fast instead of exact and slow:

```ts title="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](https://github.com/asg017/sqlite-vec) extension loaded through the pool:

```ts
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](#engine-notes).

## Query by similarity

Sort by the field with `$vector`, optionally choosing a [metric](#distance-metrics) with `$distance`:

```ts title="You write"
const results = await pool.findMany(Article, {
  $select: { id: true, title: true },
  $sort: { embedding: { $vector: queryVec, $distance: 'cosine' } },
  $limit: 10,
});
```

PostgreSQL / CockroachDB:

```sql
SELECT "id", "title" FROM "Article"
ORDER BY "embedding" <=> $1::vector
LIMIT 10
```

MariaDB:

```sql
SELECT `id`, `title` FROM `Article`
ORDER BY VEC_DISTANCE_COSINE(`embedding`, ?)
LIMIT 10
```

SQLite, sqlite-vec:

```sql
SELECT `id`, `title` FROM `Article`
ORDER BY vec_distance_cosine(`embedding`, ?)
LIMIT 10
```

libSQL / Turso Cloud:

```sql
SELECT `id`, `title` FROM `Article`
WHERE `Article`.rowid IN (SELECT id FROM vector_top_k(?, ?, ?))
ORDER BY vector_distance_cos(`embedding`, ?)
LIMIT 10
```

MSSQL:

```sql
SELECT "id", "title" FROM "Article"
ORDER BY VECTOR_DISTANCE('cosine', "embedding", CAST(@p1 AS VECTOR(1536)))
OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY
```

MongoDB Atlas:

```json
[
  {
    "$vectorSearch": {
      "index": "embedding_index",
      "path": "embedding",
      "queryVector": [/* queryVec */],
      "numCandidates": 100,
      "limit": 10
    }
  }
]
```

### Combined with filtering

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

```ts
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

`$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.

```ts title="You write"
const results = await pool.findMany(Article, {
  $where: { embedding: { $near: { $vector: queryVec, $lt: 0.35 } } },
  $limit: 10,
});
```

```sql title="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`:

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

> **Not on MongoDB Atlas**
>
> Atlas cannot filter by distance, so `$near` throws there. [Project the distance](#distance-projection) and filter it in your app.

### Filter and rank together

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

```ts
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`](#tuning-recall) too, or you may get fewer rows than qualify.

## 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:

```ts
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

`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`.

## 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:

```ts
const guides = await pool.findMany(Guide, {
  $where: { title: { $istartsWith: 'indexing' } },
  $sort: { passages: { embedding: { $vector: queryVec } } },
  $limit: 10,
});
```

```sql title="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.

## Vector types

| 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.

## Vector indexes

Declare them in [`indexes`](https://uql-orm.dev/entities/indexes.md):

| `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` |

```ts
@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

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

```ts title="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(...)`.

### 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](#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

- 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](https://uql-orm.dev/querying/full-text.md).
