User Guide

Everything you need to install, configure, and build with Montycat — from getting started to governance, advanced operations, and security.

Montycat can rank stored items by meaning instead of exact keywords. Each value is embedded into a vector on write and indexed for approximate nearest-neighbor (kNN) search, so a query returns the items whose meaning is closest to it — the retrieval layer for RAG, AI-agent memory, and semantic search.
Embeddings are computed on-device: there is no external embedding API, no API keys, and no separate vector database to run.

Note: Semantic search requires the Montycat Semantic server edition (Docker image, apt package, or prebuilt package). It is enabled by default there using the bge-small model — the lean montycat server does not include the embedding engine.

The switch is DB-wide by default, and can be scoped to a single store or keyspace. Because the semantic edition turns it on by default, enable is mainly for enrolling a keyspace that has no configuration yet, or for setting the default model and field. The model is downloaded on first enable and cached locally; every keyspace is embedded in the background as data is written.

Enable does not switch an enrolled keyspace. A keyspace that already has a semantic configuration is left untouched, and asking for a different model or field is rejected. Use reembed_semantic_search below to replace one.

from montycat import SemanticModel

# Enable semantic search DB-wide. Requires the Montycat Semantic server edition,
# where it is ON by default — call this only to enroll a keyspace that has none.
# model: MINI_LM | BGE_SMALL (default) | BGE_BASE | E5_SMALL
await connection.enable_semantic_search(model=SemanticModel.BGE_SMALL)

# {"status": True, "payload": None, "error": None}

# The chosen model is downloaded on first enable and cached locally. Every
# keyspace is embedded in the background as data is written.

# Enrolling one keyspace explicitly. A keyspace that is already enrolled is left
# untouched — this will not switch its model.
await connection.enable_semantic_search(
    model=SemanticModel.BGE_BASE, store="catalog", keyspace="products"
)

# Turn it off (vectors are kept, so re-enabling resumes instantly).
# Pass drop_vectors=True to also clear all stored vectors.
await connection.disable_semantic_search()

# {"status": True, "payload": None, "error": None}

get_semantic_status reports what the server actually holds rather than what you assumed: the DB-wide switch, the default model and field, and for each keyspace its model, dimensions, field, storage type, and whether a backfill is still running. Call it with no arguments for the whole database, or pass a store and keyspace to narrow it.

Changing the embedding model of a keyspace that already holds vectors is destructive — the old vectors were produced by a different model and cannot be compared against the new one. reembed_semantic_search does this in one atomic operation: it drops that keyspace's vectors, records the new configuration, and starts a complete backfill, reporting the previous model alongside the new one. The keyspace stays queryable throughout; results become complete once backfill_pending clears.

from montycat import SemanticModel

# Read back what the server actually holds. Omit both arguments for the
# whole database, or narrow to one keyspace.
status = await connection.get_semantic_status(store="catalog", keyspace="products")

# {
#   "status": True,
#   "payload": {
#     "globally_enabled": True,
#     "default_model": "bge-small",
#     "default_field": None,
#     "keyspaces": {
#       "catalog/products": {
#         "enrolled": True,
#         "model": "bge-small",
#         "dimensions": 384,
#         "field": None,
#         "persistent": True,
#         "backfill_pending": False
#       }
#     }
#   },
#   "error": None
# }

# Replace the model of an already-enrolled keyspace. Drops its old vectors and
# starts a complete backfill in one atomic operation.
res = await connection.reembed_semantic_search(
    SemanticModel.MINI_LM, store="catalog", keyspace="products"
)

# {
#   "status": True,
#   "payload": {
#     "scope": "catalog/products",
#     "changed": True,
#     "previous_model": "bge-small",
#     "model": "minilm",
#     "dimensions": 384,
#     "field": None,
#     "backfill_started": True
#   },
#   "error": None
# }

Query a keyspace with natural language and get back the most semantically similar items, ranked by cosine similarity. Use get_values to return the value inline with each hit, or get_keys for a lighter key-and-score result. Pass a min_score floor to drop weak matches.

from keyspaces import Employees #keyspaces.py

# Rank stored items by MEANING, not keywords. Two variants:
#   get_values -> each hit is {__key__, __score__, __value__}
#   get_keys   -> each hit is {__key__, __score__} (lighter; fetch a value later with get_bulk)

res = await Employees.semantic_search_get_values("engineers based near the coast", limit=5)

# {
#   "status": True,
#   "payload": [
#     {"__key__": "128222336824100726154851618391811195396", "__score__": 0.82,
#      "__value__": {"username": "Name", "location": "Location", "age": 21}}
#   ],
#   "error": None
# }

# Keys + scores only, with a cosine-similarity floor (range [-1, 1]):

keys = await Employees.semantic_search_get_keys("engineers based near the coast", limit=5, min_score=0.35)

# {
#   "status": True,
#   "payload": [{"__key__": "128222336824100726154851618391811195396", "__score__": 0.82}],
#   "error": None
# }

Combine semantic ranking with structured metadata constraints by using the *_where variants. The filter is a hard AND pre-filter with the same criteria shape as lookup_keys_where: only matching records become vector-search candidates. Results are still ranked solely by cosine similarity; the filter does not boost relevance scores. Filters support indexed fields, timestamps, pointers, and schemas.

from keyspaces import Employees #keyspaces.py

# Rank by meaning, but only among records that match the structured filter.
# Filters use the same criteria shape as lookup_keys_where.

matching_keys = await Employees.semantic_search_get_keys_where(
    "works on distributed systems",
    {"location": "Location"},
    limit=5,
    min_score=0.35,
)

# {
#   "status": True,
#   "payload": [{"__key__": "128222336824100726154851618391811195396", "__score__": 0.82}],
#   "error": None
# }

matching_values = await Employees.semantic_search_get_values_where(
    "works on distributed systems",
    {"location": "Location"},
    limit=5,
)

# {
#   "status": True,
#   "payload": [
#     {"__key__": "128222336824100726154851618391811195396", "__score__": 0.82,
#      "__value__": {"username": "Name", "location": "Location", "age": 21}}
#   ],
#   "error": None
# }

If you already have embeddings from a compatible batch pipeline or vector store, supply them directly and the server skips embedding entirely. Every write accepts an optional vector alongside the value, and bulk writes accept vectors paired with the values by position. Searches accept a query vector too, which bypasses text embedding — so the query string may be empty when one is supplied, and no embedding model is invoked at all.

Note: precomputed vectors require a Montycat Semantic server 1.3.0 or newer. Bulk updates take vectors for numeric keys and custom_vectors for custom keys.

from keyspaces import Employees #keyspaces.py

# Vectors produced elsewhere — another model, a batch pipeline, an existing
# vector store — are stored as-is and the server skips embedding.

res = await Employees.insert_value(
    {"username": "Name", "location": "Location", "age": 21},
    vector=my_embedding,          # list[float], omit for server-side embedding
)

# {"status": True, "payload": "128222336824100726154851618391811195396", "error": None}

# Bulk: vectors are paired with bulk_values BY POSITION.

res = await Employees.insert_bulk(
    bulk_values=[record_one, record_two],
    vectors=[embedding_one, embedding_two],
)

# Searching: a query vector bypasses text embedding, so the query may be empty.

hits = await Employees.semantic_search_get_values("", vector=my_query_embedding, limit=10)

# {
#   "status": True,
#   "payload": [
#     {"__key__": "128222336824100726154851618391811195396", "__score__": 0.82,
#      "__value__": {"username": "Name", "location": "Location", "age": 21}}
#   ],
#   "error": None
# }

Every supplied record vector and query vector must come from the model enrolled for that keyspace, including the same model revision, preprocessing, pooling, and normalization. Matching dimensions alone is not enough. An auto-enrolled BGE-small keyspace accepts only BGE-small-compatible 384d vectors. To use another model, create the keyspace with semantic auto-enrollment disabled and enroll a matching external profile first. Montycat validates dimensions, but it cannot prove that equal-length vectors share an embedding space; mixing spaces makes cosine similarity meaningless.
A vector you supplied is never overwritten by background embedding. A later ordinary write to that item clears the protection and re-embeds from its text, which is exactly the point at which re-embedding is what you want.