QUESTPIE
Infrastructure

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.

View markdown

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.

src/questpie/server/collections/posts.ts
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

AdapterFactoryNeedsPick it when
postgrescreatePostgresSearchAdapter() from questpie/adapters/postgres-searchpg_trgmAlways, until keyword matching stops being enough. It is the default.
pgvectorcreatePgVectorSearchAdapter({ … }) from questpie/adapters/pgvector-searchpg_trgm, vector, and an embedding providerYou want recall by meaning, not just by keyword overlap.
yoursanything implementing SearchAdapterwhatever the service needsYou 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.

src/questpie/server/questpie.config.ts
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

OptionAdapterDefaultWhat it does
trigramThresholdboth0.3Minimum trigram similarity, 0 to 1, for a fuzzy title match.
ftsWeightboth0.7FTS share of the lexical score. Trigram gets 1 - ftsWeight.
embeddingProviderpgvectornone, requiredTurns query and record text into vectors.
indexTypepgvector"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.

Modepostgrespgvector
lexical, defaultFTS rank plus trigram scoreforwarded to the Postgres lexical path
semanticrejectedembeds the query, ranks by cosine distance
hybridrejectedrejected

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" },
});
OptionDefaultNotes
querynone, requiredThe search text. "" is browse mode, newest first, no ranking.
collectionsevery collectionRestrict to these names.
localethe default localeWhich locale's projection to search.
limit10Page size.
offset0Page offset.
mode"lexical"See the table above.
filtersnoneMatched against indexed metadata. Array is OR, across keys AND.
highlightstrueWraps matches in <mark>. Server calls only.
facetsnoneAggregations 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

TopicPage
When writes reach the index, and indexing by handIndexing
Counts, ranges and hierarchies over metadataFacets
Embedding providers, OpenAI and your ownEmbeddings
The full .searchable() configCollections
Calling search from the frontendTyped client SDK

On this page