User Guide
Everything you need to install, configure, and build with Montycat — from getting started to governance, advanced operations, and security.
Semantic Search
Semantic search overview
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.
Enable / disable semantic search
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}Read status and change the model
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. On a Montycat Semantic server 1.3.5 or newer it also reports an indexing block — the live embedding queue, the backfill queue, and how many backfills are in flight. 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,
# "indexing": {
# "live_queue": 0,
# "backfill_queue": 0,
# "backfill_in_flight": 0
# },
# "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
# }Search by meaning
Query a keyspace with natural language and get back the most semantically similar items, ranked by cosine similarity. Use search_values to return the value inline with each hit, or search_keys for a lighter key-and-score result. Pass a min_score floor to drop weak matches. These replace the older semantic_search_* methods, which remain as semantic-only wrappers with unchanged behavior.
from keyspaces import Employees #keyspaces.py
# Rank stored items by MEANING, not keywords. Two variants:
# search_values -> each hit is {__key__, __score__, __value__}
# search_keys -> each hit is {__key__, __score__} (lighter; fetch a value later with get_bulk)
res = await Employees.search_values(query="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.search_keys(query="engineers based near the coast", limit=5, min_score=0.35)
# {
# "status": True,
# "payload": [{"__key__": "128222336824100726154851618391811195396", "__score__": 0.82}],
# "error": None
# }Keyword and hybrid ranking
Meaning is not always what you are matching on. Pass mode to choose how results are ranked: semantic (the default) ranks by vector similarity, keyword ranks with BM25 over the stored text, and hybrid runs both and fuses them with reciprocal rank fusion. Reach for keyword when the query hinges on an exact term — an identifier, an error code, a product name — that a vector model will happily rank alongside its synonyms. Reach for hybrid when a query is both at once.
Scores are not comparable across modes. Cosine similarity is bounded to [-1, 1], hybrid RRF is normalized to [0, 1], and raw BM25 is unbounded above — a BM25 score of 8 is ordinary. Compare keyword scores only within one query, and set min_score on the scale of the mode you asked for.
from keyspaces import Employees #keyspaces.py
from montycat import SearchMode
# KEYWORD: BM25 over the stored text. The term has to actually appear, which is
# what you want for an identifier, error code, or product name.
exact = await Employees.search_values(
query="Kubernetes",
mode=SearchMode.KEYWORD,
limit=5,
)
# {
# "status": True,
# "payload": [
# {"__key__": "128222336824100726154851618391811195396", "__score__": 4.71,
# "__value__": {"username": "Name", "location": "Location", "age": 21}}
# ],
# "error": None
# }
# HYBRID: vector similarity and BM25 in one request, fused with reciprocal rank
# fusion. The safest default when a query mixes meaning with an exact term.
mixed = await Employees.search_values(
query="Kubernetes experience on the platform team",
mode=SearchMode.HYBRID,
limit=5,
min_score=0.2,
)
# Keys + scores only, same modes:
keys = await Employees.search_keys(query="Kubernetes", mode=SearchMode.KEYWORD, limit=5)
# Score scales differ by mode: cosine similarity is [-1, 1], hybrid RRF is
# normalized to [0, 1], and raw BM25 is unbounded above — compare BM25 scores
# only within one query.Note: keyword and hybrid ranking require a Montycat Semantic server 1.3.4 or newer. Each mode is a distinct wire command, so an older engine rejects the request outright rather than silently returning vector-ranked results. Keyword indexes are built automatically for every enrolled keyspace, including external-vector profiles, and existing keyspaces are backfilled on upgrade.
Filtered semantic search
Combine ranking with structured metadata constraints by passing filters. The filter is a hard AND pre-filter with the same criteria shape as lookup_keys_where: only matching records become search candidates. It narrows which records are ranked in every mode and never boosts relevance scores. Filters support indexed fields, timestamps, pointers, and schemas.
from keyspaces import Employees #keyspaces.py
# Rank among only the records that match the structured filter. The filter
# narrows candidates in every mode; it never rescores. Filters use the same
# criteria shape as lookup_keys_where.
matching_keys = await Employees.search_keys(
query="works on distributed systems",
filters={"location": "Location"},
limit=5,
min_score=0.35,
)
# {
# "status": True,
# "payload": [{"__key__": "128222336824100726154851618391811195396", "__score__": 0.82}],
# "error": None
# }
matching_values = await Employees.search_values(
query="works on distributed systems",
filters={"location": "Location"},
limit=5,
)
# {
# "status": True,
# "payload": [
# {"__key__": "128222336824100726154851618391811195396", "__score__": 0.82,
# "__value__": {"username": "Name", "location": "Location", "age": 21}}
# ],
# "error": None
# }Bring your own vectors
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.search_values(query="", 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.