# Embeddings (/docs/infrastructure/search/embeddings)

---
title: Embeddings
description: 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.
kind: guide
package: questpie
---

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

```ts
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`.

```ts
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.

<Callout type="warn" title="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.
</Callout>

## 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.

```ts
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

- [Search](/docs/infrastructure/search), the pgvector adapter and its options.
- [Indexing](/docs/infrastructure/search/indexing), when the embedding is written.
