QUESTPIE

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.

View markdown

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!,
});
OptionDefaultNotes
apiKeynone, requiredOpenAI or a compatible key.
model"text-embedding-3-small"Embedding model.
dimensions1536Must 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.

  • Search, the pgvector adapter and its options.
  • Indexing, when the embedding is written.

On this page