Embeddings
The vectors behind semantic search. What the pgvector adapter embeds on its own, the two provider factories QUESTPIE ships, and how to override the vector for one collection.
An embedding provider is not an adapter. It is a small object the pgvector adapter borrows to turn text into numbers, both for the records it indexes and for the query it is about to rank against them. Nothing else in QUESTPIE uses one, so it exists only when semantic search does.
What gets embedded
On index(), the pgvector adapter writes the lexical row first, then joins
title and content with a space and embeds that, unless the caller already
supplied a vector. Empty text embeds nothing and leaves the column NULL, which
keeps the row lexically searchable and quietly excludes it from semantic
ranking.
At query time the same provider embeds the search string, and rows are ordered
by cosine distance ascending. The score you get back is 1 - distance, so
higher still means closer.
OpenAI
import { createOpenAIEmbeddingProvider } from "questpie/search";
createOpenAIEmbeddingProvider({
apiKey: process.env.OPENAI_API_KEY!,
});| Option | Default | Notes |
|---|---|---|
apiKey | none, required | OpenAI or a compatible key. |
model | "text-embedding-3-small" | Embedding model. |
dimensions | 1536 | Must match the model, 3-large takes 3072, 1024 or 256. |
baseUrl | "https://api.openai.com/v1" | Point it at an OpenAI-compatible proxy. |
It also implements generateBatch, but nothing in QUESTPIE calls it. The
pgvector adapter has no indexBatch, so a batch of records becomes one
index() each, and one embedding request each. Size your rate limit for that.
Your own
createCustomEmbeddingProvider wraps anything, a local model or another API.
Only generate is required. Leave generateBatch off and it falls back to
Promise.all over generate.
import { createCustomEmbeddingProvider } from "questpie/search";
createCustomEmbeddingProvider({
name: "local",
model: "all-MiniLM-L6-v2",
dimensions: 384,
generate: async (text) => myLocalModel.embed(text),
});The contract is { name, model, dimensions, generate(text), generateBatch?(texts) },
exported as EmbeddingProvider. Anything satisfying it can be handed to the
adapter directly.
Pick the model before you migrate
embedding vector(N) takes its N from whichever provider was configured
when app.migrations.search() ran. Switching to a different dimension count
later leaves every stored vector the wrong width, and altering the column and
re-indexing is the only way back.
Overriding one collection
.searchable({ embeddings }) replaces what the adapter would have generated for
that collection. It receives the record plus a context carrying app, locale
and defaultLocale, and whatever array it resolves to is stored as it comes.
collection("posts").searchable({
content: (record) => record.body,
embeddings: async (record, { locale }) =>
myProvider.embed(record.body, locale),
});Reach for it when the text you want embedded is not the text you want ranked lexically, or when one collection deserves a different model than the rest.
Related
Indexing
When a write reaches the index, what happens without a queue, the methods for driving indexing yourself, and how to rebuild a collection whose rows predate the search config.
Facets
Counts, buckets and hierarchies over the metadata a collection indexes. Declare the shape once on the collection, then ask for the aggregations you want per query.