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

AI & RAG

This guide builds the retrieval half of a RAG (retrieval-augmented generation) feature: store documents with their embeddings, pick the passages closest to a question, and hand them to a model. An embedding is a column and similarity is a sort, so it is all ordinary UQL queries, with no separate search service to keep in sync.

For every operator, metric, index option and engine difference, see the semantic search reference.

A vector field is @Field(f.vector(dimensions)), its dimensions matching your embedding model. Add a vector index so search does not scan every row:

import { f, Entity, Field } from 'uql-orm';
@Entity({
indexes: (article, index) => [
index([article.embedding], { type: 'hnsw', distance: 'cosine' }),
],
})
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.string().nullable()) body?: string | null;
@Field(f.vector(1536).nullable())
embedding?: number[] | null;
}

Index types differ by engine: hnsw works on Postgres, PGlite, CockroachDB, libSQL and Turso Cloud, and MariaDB takes vector. MySQL and SQL Server have none, and plain SQLite and D1 fall back to exact search. Check entity setup and vector indexes for yours before you migrate.

Embed each document with any embedding model and insert it with its vector:

import { pool } from './uql.config.js';
import { Article } from './entities.js';
const body = 'UQL is a TypeScript ORM ...';
await pool.insertOne(Article, {
title: 'What is UQL?',
category: 'docs',
body,
embedding: await embed(body),
});

Long documents retrieve better split into passages, one row and one embedding each. With a Passage entity related to its document, you can also rank documents by their best passage.

Filter by distance with $near so weak matches never reach the model, rank what is left with a $vector sort, and project the distance so you can log it:

const question = 'Does UQL support vector search?';
const queryVec = await embed(question);
const context = await pool.transaction((querier) =>
querier.findMany(Article, {
$select: { title: true, body: true },
$where: {
category: 'docs',
embedding: {
$near: { $vector: queryVec, $distance: 'cosine', $lt: 0.35 },
},
},
$sort: {
embedding: {
$vector: queryVec,
$distance: 'cosine',
$project: 'distance',
},
},
$limit: 8,
$candidates: 100,
}),
);

With cosine distance, lower is closer, so $lt is the cut-off; start around 0.35 and tune it against real questions. The category filter narrows the candidates before ranking (combined with filtering). $candidates makes an approximate index look further so the threshold does not leave you short of rows, applied for this query alone (tuning recall).

Pass the retrieved passages to the model along with the question:

const prompt = [
'Answer using only the context below.',
...context.map((article) => `## ${article.title}\n${article.body}`),
`Question: ${question}`,
].join('\n\n');
const answer = await generate(prompt); // any chat model

When context is empty, nothing was close enough. Answer that you do not know instead of letting the model guess.