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.
1. Store a vector per row
Section titled “1. Store a vector per row”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.
2. Ingest content
Section titled “2. Ingest content”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.
3. Retrieve the context
Section titled “3. Retrieve the context”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).
4. Ask the model
Section titled “4. Ask the model”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 modelWhen context is empty, nothing was close enough. Answer that you do not know instead of letting the model guess.