Basic operations

The Python examples use the cookbook_docs schema declared below and run inside an async function. The REST examples use a separate support_articles index. Start with the quickstart for a complete runnable script.

Custom fields

Custom fields

Every field a Document or DocumentChunk subclass adds to the base models becomes a typed column on the index. Document fields describe a whole source document; chunk fields describe one retrievable passage. The backing table is flat, so document fields are copied onto every chunk row — a single filter can combine document fields (such as category) and chunk fields (such as page).

The wire type is inferred from the annotation, and MistralSearchIndexField overrides any part of it:

AnnotationWire typeIndexed by default
strtextyes
StrEnumkeywordyes
intlongno
floatfloatno
boolboolno
datetimetimestampno
list[T] of the aboveT[]no
dict, a pydantic modeljsonno
from typing import Annotated, Sequence

from mistralai.search.toolkit.document import Document, DocumentChunk
from mistralai.search.toolkit.embedding import MistralEmbeddingPreset
from mistralai.search.toolkit.mistral_search_index.schema import (
    MistralSearchIndexCollectionSchema,
    MistralSearchIndexField,
    MistralSearchIndexFieldType,
)

class ReportChunk(DocumentChunk):
    # Chunk metadata is stored as an opaque JSON blob and cannot be filtered on;
    # a field of its own makes the page number a filterable column.
    page: Annotated[int | None, MistralSearchIndexField(indexed=True)] = None

class ReportDocument(Document):
    title: str  # text: full-text searchable
    category: Annotated[str, MistralSearchIndexField(type=MistralSearchIndexFieldType.KEYWORD, indexed=True)]
    page_count: int  # long: Range filters
    published_year: Annotated[int | None, MistralSearchIndexField(type=MistralSearchIndexFieldType.INT, indexed=True)] = None
    keywords: Annotated[list[str], MistralSearchIndexField(type=MistralSearchIndexFieldType.KEYWORD_ARRAY)] = []
    chunks: Sequence[ReportChunk] = []

schema = MistralSearchIndexCollectionSchema.from_document_type(
    collection_name="cookbook_docs",
    document_type=ReportDocument,
    embedding_model=MistralEmbeddingPreset.MISTRAL_EMBED_DIM_1024,
)

Things to know about custom fields:

  • Optional means nullable. A field whose annotation includes None is optional; every other field is required, and the index rejects a document without it. A field with a default but no None, such as keywords above, is still required. Set MistralSearchIndexField(required=...) to override this; over REST, required defaults to false.
  • indexed builds a database index (btree for scalars, full-text for text). Filters work without one; set indexed=True on fields you filter on often. Keyword retrieval works only on indexed text fields.
  • Filterable fields are scalars: text, keyword, int, long, float, and timestamp. Array and json fields are stored and returned with every hit, but you cannot filter on them. bool fields are not filterable yet.
  • Names are constrained. Field names must match [a-z][a-z0-9]*(_[a-z0-9]+)* and be at most 32 characters. Index names follow the same rule. A custom field must not repeat a key of the same chunk's metadata (paged OCR chunks carry page_number in their metadata, so the chunk field above is named page), and the system fields and chunks are reserved: declaring one as a custom field returns a 422.
  • Document.content is not stored — only its chunks are. Chunk ids derive from (source_id, locator), so two chunks of one document must have distinct locators.
  • Schemas evolve additively. MistralSearchIndexService.update_index can add optional fields or turn on indexed for an existing field. Removing or retyping a field, moving it between document and chunk scope, un-indexing it, or adding a required one is refused with a 422 — recreate the index instead. An index created before custom fields existed has none of them: recreate it and re-ingest.
  • PUT replaces the schema. Over REST, send the whole schema on every update, not only the changed fields.

System fields

System fields

The canonical Document / DocumentChunk fields map onto the index's own fields:

ScopeWire fieldFrom the toolkitNotes
documentsource_idDocument.source_idIdentifies the source document.
documentdocument_idnot sentServer-computed from source_id, same as Document.id.
documentdocument_metadataDocument.metadataDefaults to {}.
chunkidnot sentServer-computed from (source_id, locator), same as DocumentChunk.id.
chunklocatorDocumentChunk.locatorPosition in the source, for example char:0-512.
chunkstart_offset, end_offsetsame
chunkcontentDocumentChunk.contentThe passage text.
chunkcontent_embeddingDocumentChunk.embeddingRequired: ingestion never embeds, and an unembedded chunk raises before the request.
chunkchunk_typeDocumentChunk.chunk_typeDefaults to content.
chunkparent_refDocumentChunk.parent_refOptional.
chunkmetadataDocumentChunk.metadataDefaults to {}.

Create an index

Create an index

MistralSearchIndexService manages the index lifecycle and returns a MistralSearchIndex. The same API is available in the mistralai SDK under client.beta.context.search_indexes.

Creating an index needs the workspace create_search_indexes permission; without it, or once the organization reaches its index limit, the request returns 403. The creator is written as the index owner and can see, query, ingest, modify, and delete it afterwards. A denial on an existing index is reported as 404, never 403.

The toolkit is async, so run the Python snippets below inside an async function.

With the toolkit, derive the schema from your document type and wait for provisioning:

import asyncio

from mistralai.search.toolkit.mistral_search_index import MistralSearchIndexService
from mistralai.search.toolkit.mistral_search_index.models import MistralSearchIndexStatus

service = MistralSearchIndexService()
info = await service.create_index(schema)
while info.status == MistralSearchIndexStatus.PROVISIONING:
    await asyncio.sleep(1)
    info = await service.get_index_info("cookbook_docs")
if info.status != MistralSearchIndexStatus.READY:
    raise RuntimeError(f"provisioning failed: {info.status_message}")

The embedding model is immutable — it decides the physical column type and the vector operator class, so it cannot be changed after creation.

Over REST, the same call is POST /v1/context/search-indexes with the schema declared inline:

curl -X POST https://api.mistral.ai/v1/context/search-indexes \
  -H "Authorization: Bearer $MISTRAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "support_articles",
    "schema": {
      "document_fields": {
        "title": { "type": "text", "indexed": true },
        "product": { "type": "keyword", "indexed": true }
      },
      "chunk_fields": {
        "page_number": { "type": "int", "indexed": true }
      }
    },
    "config": {
      "embedding": { "type": "mistral", "name": "mistral-embed", "dimensions": 1024 }
    }
  }'

The lifecycle endpoints:

OperationEndpoint
Create an indexPOST /v1/context/search-indexes
List indexesGET /v1/context/search-indexes
Get an indexGET /v1/context/search-indexes/{index_name}
Update the schema (additive only)PUT /v1/context/search-indexes/{index_name}
Delete an indexDELETE /v1/context/search-indexes/{index_name}
Ingest documentsPOST /v1/context/search-indexes/{index_name}/documents
Delete documentsDELETE /v1/context/search-indexes/{index_name}/documents
SearchPOST /v1/context/search-indexes/{index_name}/search

Create, update, and delete are asynchronous and return 202. Deletion moves the index to deleting; the name stays reserved until it completes, and only then can you recreate it. Every document and query call on an index that is not ready returns 409.

Ingest documents

Ingest documents

Ingestion never embeds: every chunk carries its embedding, and an unembedded chunk raises before the request. Re-ingesting the same source_id replaces that document — its existing chunks are deleted and the new ones inserted in one transaction — so a document's source_id is its identity. By default source_id is the file path or name, and re-ingesting the same file updates it instead of adding a second copy.

Over REST, submit up to 100 documents per request, each with its custom fields and up to 1,000 chunks. The response is 200 with an accepted or rejected entry per document:

Each chunk must include a complete embedding vector with the dimensions configured on the index. Use the quickstart to compute real embeddings before ingestion.

With the toolkit, embed the content and ingest the document:

from mistralai.search.toolkit.document import SourceId
from mistralai.search.toolkit.embedding import MistralEmbedder

text = "Rotate an API key from the console under Workspace > API keys."
embedder = MistralEmbedder(model_name="mistral-embed")
embedding = (await embedder.embed([text])).embeddings[0]
article = ReportDocument(
    source_id=SourceId("s3://docs/api-keys.md"),
    content=text,
    title="Managing API keys",
    category="console",
    page_count=1,
    keywords=["api", "keys"],
    chunks=[ReportChunk(
        source_id=SourceId("s3://docs/api-keys.md"),
        locator=f"char:0-{len(text)}",
        start_offset=0,
        end_offset=len(text),
        content=text,
        embedding=embedding,
        page=1,
    )],
)
index = await service.get_search_index("cookbook_docs")
await index.index_document(article)

Query an index

Query an index

Query with POST /v1/context/search-indexes/{index_name}/search, or from Python with MistralSearchIndex.retrieve, which takes the retriever query DSL:

RetrieverPurpose
NearestNeighbourRetrieverApproximate-nearest-neighbour over content_embedding. For a mistral embedding model, pass query and the server embeds it; a custom model has no server-side embedder, so pass query_embedding yourself.
KeywordRetrieverFull-text match on content, or another indexed text field via field.
RRFRetrieverFuses its children's rankings with reciprocal rank fusion. weights and rank_constant tune it, and its filter is ANDed onto every child.

Filters are Equal, In, Range (gt / gte / lt / lte), And, Or, and Not, over any scalar field. Every retriever takes a filter, not just RRFRetriever. top_k defaults to 20 and is capped at 100 on the top-level retriever, and exceeding it returns 400.

Children of an RRFRetriever can set top_k up to 1,000. Each child's top_k must be at least the parent's.

from mistralai.search.toolkit.mistral_search_index.retriever import (
    Equal,
    KeywordRetriever,
    NearestNeighbourRetriever,
    RRFRetriever,
)

results = await index.retrieve(
    RRFRetriever(
        top_k=5,
        filter=Equal(field="category", value="console"),
        retrievers=[
            KeywordRetriever(query="how do I rotate an api key", top_k=5),
            NearestNeighbourRetriever(query="how do I rotate an api key", top_k=5),
        ],
    )
)

For the support_articles index, query over REST:

curl -X POST https://api.mistral.ai/v1/context/search-indexes/support_articles/search \
  -H "Authorization: Bearer $MISTRAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "retriever": {
      "type": "rrf",
      "top_k": 5,
      "filter": { "type": "equal", "field": "product", "value": "console" },
      "retrievers": [
        { "type": "keyword", "query": "how do I rotate an api key", "top_k": 5 },
        { "type": "nearest_neighbour", "query": "how do I rotate an api key", "top_k": 5 }
      ]
    }
  }'

MistralSearchIndex.search(VectorSearchQuery) — what VectorRetriever and QueryEngine call — builds the retriever for you: nearest-neighbour when the query has no text, RRF over keyword and nearest-neighbour when it does. It matches content only, though it returns custom fields on every hit. search applies a VectorSearchQuery.filter, but VectorRetriever and QueryEngine never set one. Use the retriever DSL when you need keyword matching on a custom field, or filters from a QueryEngine pipeline.

Each result is a SearchResult whose chunk carries the document-scope fields alongside the chunk fields. Embeddings are never returned. score is higher-is-better: 1 - distance for cosine, -distance for l2 and inner_product, the keyword rank for KeywordRetriever, and the fused RRF score for RRFRetriever. distance is the raw vector distance, or None for keyword and RRF results.

A search index is also a navigable index: navigate, read, grep, and get_chunk walk a source document in reading order instead of by relevance. These are the same primitives behind Agentic Search, and they are exposed on the index endpoints (POST .../navigate, POST .../read, POST .../grep, GET .../chunks/{chunk_id}).

Update the schema

Update the schema

Declare the expanded schema and call update_index. Schema updates are additive: keep existing fields. Wait for the index to return to READY before relying on the added fields.

class UpdatedReportDocument(ReportDocument):
    owner: str | None = None

updated_schema = MistralSearchIndexCollectionSchema.from_document_type(
    collection_name="cookbook_docs",
    document_type=UpdatedReportDocument,
    embedding_model=MistralEmbeddingPreset.MISTRAL_EMBED_DIM_1024,
)
await service.update_index(updated_schema)
Delete documents

Delete documents

Deleting a source document removes its chunks. Pass the document ID, not its source ID; this is not reversible unless you ingest the documents again.

await index.delete_document(article.id)
Delete an index

Delete an index

Deleting an index removes its data. Check the index name before calling this method. Deletion is asynchronous: the index enters DELETING and eventually returns not found.

await service.delete_index("cookbook_docs")