Postgres
Use PostgreSQL with the pgvector and pg_textsearch extensions as a Search Toolkit backend. PostgresStoreIndex implements VectorStoreIndex, NavigableIndex, PatchableIndex, VersionedIndex, and RetrievableIndex, so it uses the same pipeline interfaces as the Vespa backend. Provisioning, schema management, and query scoping differ between the backends.
The Postgres backend ships as an optional extra:
uv add 'mistralai-search-toolkit[postgres]'The Retrieval, Retrievers, and Filters pages are written for a Mistral Search Index. PostgresStoreIndex also supports retrieve and filters, with the differences listed in Retrieve and filters.
Tenant isolation
The Postgres backend doesn't derive tenant filters from IngestContext or RetrievalContext. Use a separate collection for each tenant or apply filtering before the store. For details, see Fallbacks and limitations.
How the Postgres backend works
Three objects make up the Postgres backend:
PostgresCollectionSchema: declares the document model, embedding model, and PostgreSQL schema for a collection. The store reads this declaration instead of reflecting the database schema.PostgresApp: resolves a declared collection to a live store throughget_search_index(config, collection). It providescreate_schemafor deployments without a migration chain. Use a migration tool such as Alembic or Flyway to evolve other deployments.PostgresStoreIndex: implements dense and hybrid search, indexing, deletion, navigation, and partial updates. Pass it toPipeline(stores=...)for ingestion, and query it withsearchandretrieve.
Provision the extensions, declare the collection, connect to PostgreSQL, create the table, and then run searches. Use create_schema or the mistral-postgres CLI to create the table.
Requirements
The target database must have two extensions installed before you create a collection table:
vector: provides HNSW indexing over embeddings.pg_textsearch: provides the BM25 access method for hybrid search. Add it toshared_preload_librariesbefore the server starts.CREATE EXTENSIONfails until the server restarts with the library preloaded.
Install both extensions once per database with a privileged role:
CREATE EXTENSION vector;
CREATE EXTENSION pg_textsearch;The plugin never runs CREATE EXTENSION. On managed Postgres (RDS, Cloud SQL), that statement needs privileges that a least-privilege application role doesn't usually hold, so provisioning belongs to whoever manages the database. PostgresApp.create_schema verifies both before attempting any DDL, raising MissingVectorExtensionError or MissingBM25AccessMethodError when one is absent.
Declare a collection
A collection is declared in code with PostgresCollectionSchema. embedding_model takes the same EmbeddingModel / MistralEmbeddingPreset as the Vespa schema (see Embedding model):
from mistralai.search.toolkit.document import Document
from mistralai.search.toolkit.embedding import MistralEmbeddingPreset
from mistralai.search.toolkit.plugins.postgres import PostgresApp, PostgresCollectionSchema
DOCS = PostgresCollectionSchema(
collection_name="docs",
document_type=Document,
embedding_model=MistralEmbeddingPreset.MISTRAL_EMBED_DIM_1024,
)
app = PostgresApp([DOCS])| Field | Type | Default | Purpose |
|---|---|---|---|
collection_name | str | (required) | Collection name. The table is named <collection_name>_chunks. |
document_type | type[Document] | (required) | Your Document subclass |
embedding_model | EmbeddingModel | MistralEmbeddingPreset | (required) | Embedding model (dimensions, dtype, distance metric) applied to the embedding column |
db_schema | str | None | None | PostgreSQL schema that contains the table. None uses the connection's default. |
hnsw_m | int | 16 | HNSW m parameter |
hnsw_ef_construction | int | 64 | HNSW ef_construction parameter |
text_search_config | str | "english" | PostgreSQL text search configuration for the content BM25 index |
PostgresCollectionSchema is the only thing the store is told about the collection: it builds the table, declares the metric to rank with, and resolves the document model's custom fields to its columns. Nothing is reflected from the database, and no column name is inferred by convention.
Custom columns
Custom fields on your Document subclass map to table columns. They are unbounded unless the model asks for a length ceiling:
from typing import Annotated
from mistralai.search.toolkit.document import Document
from mistralai.search.toolkit.plugins.postgres import PostgresColumn
class MyDoc(Document):
section: Annotated[str | None, PostgresColumn(max_length=120)] = NoneConnecting
PostgresApp.get_search_index accepts either a PostgresConnectionConfig or a SQLAlchemy AsyncEngine, and returns a PostgresStoreIndex:
from mistralai.search.toolkit.plugins.postgres import PostgresConnectionConfig
config = PostgresConnectionConfig(dsn="postgresql://user:pw@localhost:5432/db")
await app.create_schema(config, "docs") # idempotent; verifies the extensions first
store = app.get_search_index(config, "docs") # -> PostgresStoreIndexConnection forms
PostgresConnectionConfig takes a DSN:
PostgresConnectionConfig(dsn="postgresql://user:pw@localhost:5432/db")or parts:
PostgresConnectionConfig(host="localhost", port=5432, database="db", user="user", password="pw")TLS
Managed PostgreSQL services provide a DSN with ?sslmode=require or a stricter mode. Pass it unchanged. The configuration removes sslmode from the URL and passes the translated value to the asyncpg driver:
PostgresConnectionConfig(dsn="postgresql://user:pw@host:5432/db?sslmode=require")The parts form takes the mode directly via ssl= (an explicit ssl= also overrides one embedded in a DSN):
PostgresConnectionConfig(host="host", database="db", user="user", ssl="verify-full")Creating the table
The table must exist before the store can use it. How it gets created depends on your migration setup:
- No migration chain:
PostgresApp.create_schema(config, collection)creates the table and indexes directly. This idempotent method verifies the extensions first. - Alembic: add the collection's table to the chain's
target_metadatawithPostgresCollectionSchema.to_table(), then let autogenerate write the revision. This approach keeps one migration order for the database. - Other migration tools: generate the
CREATE TABLEandCREATE INDEXstatements with themistral-postgresCLI, then apply them with Flyway, Liquibase, or a raw.sqlmigration.
mistral-postgres CLI
The plugin ships a single-command CLI that prints the CREATE TABLE and CREATE INDEX statements for a declared collection, for applying with whatever migration tool the project uses:
mistral-postgres ddl myapp.search:DOCS_COLLECTIONThe module:attribute argument identifies a declared PostgresCollectionSchema. The module must be importable, as it must be for an Alembic env.py. The output comes from the same to_table() definition that the store uses, so it describes the table that the store queries. It doesn't include CREATE EXTENSION; provision the extensions separately as described in Requirements.
Use render_ddl(collection) to generate equivalent DDL from a script.
Search
Dense search
A VectorSearchQuery with only an embedding runs the HNSW retriever:
from mistralai.search.toolkit.search import VectorSearchQuery
results = await store.search(
VectorSearchQuery(embedding=vec, top_k=10)
)Hybrid search
A VectorSearchQuery that contains both query and embedding runs both retrievers without extra configuration:
results = await store.search(
VectorSearchQuery(query="quarterly revenue", embedding=vec, top_k=10)
)Every collection's table carries a BM25 index over content (built by pg_textsearch). The two retrievers are combined by weighted reciprocal rank fusion:
score = w_vector / (k + rank_vector) + w_text / (k + rank_text)Tune the fusion per query with PostgresSearchQuery. This class is deprecated in 0.1.0 and emits a DeprecationWarning. For new code, call store.retrieve(RRFRetriever(retrievers=[...], weights=[...], rank_constant=...)) from mistralai.search.toolkit.plugins.postgres.retriever:
from mistralai.search.toolkit.plugins.postgres import PostgresSearchQuery
results = await store.search(
PostgresSearchQuery(
query="quarterly revenue",
embedding=vec,
vector_weight=1.0,
text_weight=2.0, # favour the lexical half
rrf_k=60, # lower sharpens the top of each list
)
)| Field | Type | Default | Purpose |
|---|---|---|---|
vector_weight | float | 1.0 | Weight of the vector retriever's contribution to the fused ranking |
text_weight | float | 1.0 | Weight of the BM25 retriever's contribution |
rrf_k | int | 4 | Reciprocal rank fusion smoothing constant; lower sharpens the top of each list |
Only the ratio between the weights affects ranking, so 1.0/1.0 and 0.5/0.5 are equivalent. Choose weights based on your corpus. You can set text_weight for each query.
Search dials
Search also honors exclude_ids, exclude_source_ids, and approximate_options=ApproximateQueryOptions(max_candidates=...) (from mistralai.search.toolkit.search). The backend maps max_candidates to hnsw.ef_search for each query and accepts values from 1 to 1000. If the value is lower than top_k, the backend raises it to top_k.
Retrieve and filters
The Retrievers and Filters pages describe retrieve on a Mistral Search Index. PostgresStoreIndex supports the same method, with its own retriever tree and the differences described in this section. The same filters also apply to search through VectorSearchQuery.filter.
Retrievers
Import the retrievers and filters from mistralai.search.toolkit.plugins.postgres.retriever, not from the Mistral Search Index module.
| Retriever | Postgres specifics |
|---|---|
NearestNeighbourRetriever | Requires query_embedding: the store doesn't embed queries. A keyword-only collection rejects it. |
KeywordRetriever | Ranks the column that the collection's BM25 index covers (content, unless the collection sets bm25_column). field can only name that column. |
RRFRetriever | Accepts any number of child retrievers. top_k is capped at 1000. |
from mistralai.search.toolkit.plugins.postgres.retriever import (
Equal,
KeywordRetriever,
NearestNeighbourRetriever,
RRFRetriever,
)
question = "termination clauses"
embedding = await embedder.embed_query(question)
results = await store.retrieve(
RRFRetriever(
top_k=10,
filter=Equal(field="section", value="contracts"),
retrievers=[
KeywordRetriever(query=question, top_k=20),
NearestNeighbourRetriever(query_embedding=embedding, top_k=20),
],
weights=[2.0, 1.0],
)
)Unlike hybrid search, retrieve doesn't fall back to vector results: if the BM25 index is missing or unusable, a retriever that includes a KeywordRetriever raises SearchError.
Filterable fields
You can filter on any scalar column of the collection table. This includes the standard chunk columns, such as id, source_id, and chunk_type, and your custom columns. You can't filter on array and JSON columns. Before it sends the query, the store checks the type of each value against the column type. It raises SearchError on an unknown field or a mismatched type.
A dotted field name, such as metadata.claim.field, is a path into the chunk metadata. Equal and In work on metadata paths; Range doesn't.
An empty And matches every chunk, and an empty Or matches none.
Fallbacks and limitations
When the lexical index is not usable
On its first hybrid query, the store checks whether lexical ranking is available. If the BM25 index is missing or INVALID, or if pg_textsearch isn't installed, the store returns vector results instead of failing. Each downgraded query logs at the ERROR level with the collection name and suggested fix. PostgresStoreIndex.lexical_ranking_resolved reports True, False, or None before the first hybrid query. The result is cached for the lifetime of the process.
No context-derived filtering
IngestContext and RetrievalContext are accepted by every entry point, but the backend only propagates them. The Postgres backend doesn't derive query scope from these contexts. Unlike the Vespa backend, it doesn't apply group_id or yql_filter for tenant isolation. Every query can see the full collection. Isolate each tenant in a separate collection and table, or apply filtering before the store.
pgvector versions
We recommend pgvector 0.8 or later. Earlier versions support all features except complete exclude_ids result sets. HNSW applies filters after retrieval, so excluded rows aren't replaced and a filtered search can return fewer than top_k results. Version 0.8 added hnsw.iterative_scan, which the store enables for these queries. On an earlier server, the store logs one warning and can return fewer results instead of failing.
pg_textsearch versions
We recommend pg_textsearch 1.5 or later. Earlier releases stop an ordered BM25 scan after 100,000 index entries, so a keyword or hybrid query over a large collection can return fewer than top_k rows, or none, without an error. The store logs a warning on its first keyword or hybrid query against an older server.
See also
- Embedding model: the
embedding_modelargument toPostgresCollectionSchema. - Document model: the
Document/DocumentChunktypes a collection is built from. - Search index: how the Postgres backend fits into the search index.
- Vespa: another search backend, for when you need clustering and replication.
- Mistral Search Index: the hosted backend, for when you don't want to operate a database.