Task guide
Search records by meaning
Declare the fields that carry meaning. Generate their embeddings. Query a collection with semanticSearch or findSimilar. Review where ranking occurs relative to the filters.
Verified against s-m-r-t 0.42.4
Declare the fields on the class
Embedding configuration is an option on the @smrt() decorator rather than a per-field decorator. fields names the properties whose text carries the meaning you want to search; everything else about the model is unchanged.
- provider is local, ai, or auto. Only provider can be overridden per class; the model names and dimensions come from project configuration.
- autoGenerate and regenerateOnChange both default to true.
- combinedField builds one extra vector from a template across several fields.
import { ObjectRegistry, SmrtCollection, SmrtObject, smrt }
from '@happyvertical/smrt-core';
@smrt({
api: true,
embeddings: { fields: ['summary'], provider: 'local' }
})
export class Recipe extends SmrtObject {
title = '';
summary = '';
}
export class RecipeCollection extends SmrtCollection<Recipe> {
static readonly _itemClass = Recipe;
}
ObjectRegistry.registerCollection('Recipe', RecipeCollection);Choose where the vectors come from
The project-level block selects the model that produces the numbers. Local embedding uses a transformers runtime on your machine and needs no API key. The ai provider calls the configured model provider through the SDK adapter. The default is local with 768 dimensions.
- Local embedding requires @huggingface/transformers (or the older @xenova/transformers) to be installed; without it the provider raises a clear error naming both packages.
- The model name is part of the identity of a stored vector. Changing provider or model makes existing embeddings invisible rather than stale, and they must be regenerated.
- storage json keeps vectors as text and compares them in the application; storage native uses pgvector on PostgreSQL, or the SQLite vector extension.
export default defineConfig({
smrt: {
embeddings: {
provider: 'local',
localModel: 'Xenova/bge-base-en-v1.5',
aiModel: 'text-embedding-3-small',
dimensions: 768,
storage: 'json'
}
}
});Generate the vectors
Saving a record schedules embedding generation only when a model client can resolve. The save does not wait for generation. Use the explicit call in a script, seed, or test. The explicit call finishes before the next line runs.
- generateMissingEmbeddings pages through the collection in batches of fifty and accepts an onProgress callback.
- Its skipped count means "already current" and also absorbs per-object failures, which are only logged.
- object.generateEmbeddings({ force: true }) regenerates one record; content is hashed, so an unchanged field is not re-embedded.
- There is no CLI command for backfill at this release; this collection method is the supported path.
const recipes = await RecipeCollection.create({ db: dbConfig });
await recipes.create({
title: 'Sourdough loaf',
summary: 'A slow-fermented bread with a crisp crust.'
});
// Deterministic: generate now rather than waiting on the save-time path.
const stats = await recipes.generateMissingEmbeddings();
// { generated: 3, skipped: 0 }Query it
semanticSearch embeds the query text and returns hydrated model instances with their methods. Each instance has a _similarity score between zero and one. Results are sorted from the highest similarity to the lowest. findSimilar starts the same operation from an existing record.
- The threshold option is named minSimilarity and defaults to 0. It is a minimum score and not a boundary between clusters. A value of 0.5 in the example still returns all three rows because the lowest score is 0.514.
- limit defaults to 10 on semanticSearch and 5 on findSimilar.
- findSimilar excludes the source record by default and reads its stored vector, so it raises if that record was never embedded.
- field selects which configured field to search when a class declares more than one.
const hits = await recipes.semanticSearch('baking bread at home', { limit: 3 });
hits.map((hit) => [hit.title, hit._similarity]);
// [ ['Sourdough loaf', 0.7203995814592284],
// ['Tomato soup', 0.5640168165699766],
// ['Bicycle repair', 0.5138145283587121] ]
const related = await recipes.findSimilar(hits[0], { limit: 5 });Serve it from a route
There is no generated REST or MCP surface for semantic search at this release, so the endpoint is yours to write. That is also where the permission check belongs, because the collection call itself does not apply one.
import { json } from '@sveltejs/kit';
export const GET = async ({ url, locals }) => {
if (!locals.permissions.includes('recipes.read')) return json([], { status: 403 });
const query = url.searchParams.get('q') ?? '';
if (!query) return json([]);
const recipes = await RecipeCollection.create({ db: getDbConfig() });
const hits = await recipes.semanticSearch(query, { limit: 10, minSimilarity: 0.4 });
return json(hits.map(({ id, title, _similarity }) => ({ id, title, _similarity })));
};Read the ranking honestly
The ranking and filtering stages are separate, and their order explains most unexpected result sets. Similarity is calculated for each stored class vector. The result is shortened to limit. Then, the collection loads the remaining ids and applies tenant scope and the where clause.
- Nothing leaks: a tenant-scoped class still filters at the hydration step, so another tenant’s rows never reach the caller.
- Field policy is not applied. Results are fully hydrated instances. Sensitive-field rules in the generated interfaces are not in this path. Project the fields that you return, as the route above does.
- With json storage, every vector for the class is read and parsed on every query. That is sufficient for a catalog and not for a corpus.
- Native vector search falls back to the in-application scan when the database call fails. Results stay correct and get much slower, and the only trace is a logged warning.
Turn on native vectors when the dataset outgrows the scan
Native storage moves the comparison into the database. On PostgreSQL, first use creates the extension and column. On SQLite, enable the native capability. SQLite native storage also requires a local file instead of a remote connection.
- Setting native on an adapter without vector support logs a warning and silently uses json storage.
- The SQLite path needs @sqliteai/sqlite-vector installed and rejects remote libsql or http URLs.
- Vectors live in the shared _smrt_embeddings table, keyed by class, object, field, and model — never as a column on your own table.
// smrt.config.js
embeddings: { provider: 'local', storage: 'native' }
// PostgreSQL: CREATE EXTENSION vector, then an embedding_vector column
// and an HNSW index, created on first use.
// SQLite: opt in explicitly, and stay on a local file.
const db = await getDatabase({
type: 'sqlite',
url: 'file:./dev.db',
capabilities: { vector: { quantization: 'turbo4', preload: true } }
});