Search
One shared index behind every collection you mark searchable. Postgres full-text search with no configuration at all, pgvector when you want ranking by meaning, and one config line to swap either for a service of your own.
The collection decides what goes in. app.search is the query API. The adapter
owns storage and ranking, and it is the only one of the three that moves when
you change backends. Because the index is derived and never authoritative, it
can be thrown away and rebuilt from the rows at any time.
The default
Leave search out of your config and QUESTPIE builds
createPostgresSearchAdapter() for you. That is Postgres full-text search over
a generated tsvector, every word queried as a :* prefix, with trigram
similarity on the title for typos. No service beyond the database you run.
Nothing is indexed until a collection asks for it.
import { collection } from "#questpie/factories";
export const posts = collection("posts")
.fields(({ f }) => ({ title: f.text(255).required(), excerpt: f.text() }))
.title(({ f }) => f.title)
.searchable({ content: (record) => record.excerpt });The index lives in questpie_search and questpie_search_facets. Both come
back from getTableSchemas(), so Drizzle owns their DDL and questpie push
creates them with the rest of your schema.
The default needs the `pg_trgm` extension
The gin_trgm_ops index is part of the Drizzle table, so the extension has to
exist before questpie push or questpie migrate runs that DDL. The
create-questpie starters provision it from docker/init-extensions.sql on
first container init. On managed Postgres, enable it yourself.
Available adapters
| Adapter | Factory | Needs | Pick it when |
|---|---|---|---|
postgres | createPostgresSearchAdapter() from questpie/adapters/postgres-search | pg_trgm | Always, until keyword matching stops being enough. It is the default. |
pgvector | createPgVectorSearchAdapter({ … }) from questpie/adapters/pgvector-search | pg_trgm, vector, and an embedding provider | You want recall by meaning, not just by keyword overlap. |
| yours | anything implementing SearchAdapter | whatever the service needs | You already run Meilisearch, Elasticsearch or Typesense. |
Swapping the adapter
search in runtimeConfig takes a SearchAdapter instance and nothing else,
so a swap is one import and one key. Here is pgvector, which composes the
Postgres adapter for lexical work and adds an embedding column beside it.
import { createPgVectorSearchAdapter } from "questpie/adapters/pgvector-search";
import { runtimeConfig } from "questpie/app";
import { createOpenAIEmbeddingProvider } from "questpie/search";
export default runtimeConfig({
app: { url: process.env.APP_URL! },
db: { url: process.env.DATABASE_URL! },
search: createPgVectorSearchAdapter({
embeddingProvider: createOpenAIEmbeddingProvider({
apiKey: process.env.OPENAI_API_KEY!,
}),
indexType: "ivfflat",
}),
});Every call site stays as it was. app.search.search(...), the client search
method and your .searchable() declarations all target the interface.
Adapter options
| Option | Adapter | Default | What it does |
|---|---|---|---|
trigramThreshold | both | 0.3 | Minimum trigram similarity, 0 to 1, for a fuzzy title match. |
ftsWeight | both | 0.7 | FTS share of the lexical score. Trigram gets 1 - ftsWeight. |
embeddingProvider | pgvector | none, required | Turns query and record text into vectors. |
indexType | pgvector | "ivfflat" | ivfflat builds with lists = 100, hnsw builds an HNSW graph. |
The pgvector column is not part of the Drizzle schema
embedding vector(N) and its index come from getMigrations(), which
app.migrations.search() applies at the end of every questpie migrate.
questpie push syncs only the Drizzle schema, so a push-only database never
gets the column. N is fixed from the provider's dimensions.
Ranking modes
mode picks the ranking strategy. An adapter that cannot serve one throws.
| Mode | postgres | pgvector |
|---|---|---|
lexical, default | FTS rank plus trigram score | forwarded to the Postgres lexical path |
semantic | rejected | embeds the query, ranks by cosine distance |
hybrid | rejected | rejected |
Both built-in adapters advertise hybrid: false, which is reserved for real
fusion of the two. Semantic ranking skips rows whose embedding is NULL.
Querying
const { results, total, facets } = await app.search.search({
query: "release notes",
collections: ["posts"],
filters: { status: "published" },
});| Option | Default | Notes |
|---|---|---|
query | none, required | The search text. "" is browse mode, newest first, no ranking. |
collections | every collection | Restrict to these names. |
locale | the default locale | Which locale's projection to search. |
limit | 10 | Page size. |
offset | 0 | Page offset. |
mode | "lexical" | See the table above. |
filters | none | Matched against indexed metadata. Array is OR, across keys AND. |
highlights | true | Wraps matches in <mark>. Server calls only. |
facets | none | Aggregations to compute. See Facets. |
Server and client return different things
app.search.search(...) hands back { results, total, facets? }, and each
result is the raw index row, including title, content, highlights and
score. It is trusted server code, so it sees the projection as stored.
client.search.search(...) hands back { docs, total, facets? }. Each doc is
the live collection row, hydrated through CRUD so hooks and field access run,
plus _collection and _search: { score }. Index snapshots are left out on
purpose, because they were projected before request-time field access existed.
Access is applied at the route
The HTTP route compiles each collection's
read rule with the same fail-closed WHERE
compiler CRUD uses, skips collections you cannot read, and hands one
authorized candidate set to hits, totals, facets and vector ordering alike. A
ranked row that can no longer be hydrated rejects the whole response rather than
shortening the page under a total that disagrees with it. app.search.search()
called from your own code is trusted and filters nothing.
What lands in the index
.searchable({}) indexes the title only, from your .title() field, falling
back to id. content, metadata, facets and embeddings are each opt-in.
undefined, .searchable(false) and { disabled: true } index nothing, and an
unindexed collection is invisible to the HTTP route.
A projection is an access boundary
Row-level rules scope which candidates a search may return, but a shared index
cannot apply field-level access inside a value you projected. Anything you put
in content, metadata, facets or an embedding must be safe for every
actor allowed to read that row.
Writing your own adapter
The search slot accepts any SearchAdapter, and framework, module and
application code all go through this same contract.
interface SearchAdapter {
readonly name: string;
readonly capabilities: AdapterCapabilities;
initialize(ctx: AdapterInitContext): Promise<void>;
getMigrations(): AdapterMigration[];
search(options: SearchOptions): Promise<SearchResponse>;
index(params: IndexParams): Promise<void>;
remove(params: RemoveParams): Promise<void>;
reindex(collection: string): Promise<void>;
clear(): Promise<void>;
indexBatch?(params: IndexParams[]): Promise<void>;
getTableSchemas?(): Record<string, any>;
}capabilities is { lexical, trigram, semantic, hybrid, facets }, readable at
app.search.getAdapter(). initialize() must not create tables. Return Drizzle
tables from getTableSchemas() if the backend is local and migrations should
own the DDL, or leave the method off for an external service, which then needs
no tables and no Postgres extensions at all.
Types live in questpie/search, re-exported from questpie. Each factory and
its options ship from its own entry point.
Where each topic lives
| Topic | Page |
|---|---|
| When writes reach the index, and indexing by hand | Indexing |
| Counts, ranges and hierarchies over metadata | Facets |
| Embedding providers, OpenAI and your own | Embeddings |
The full .searchable() config | Collections |
Calling search from the frontend | Typed client SDK |