Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The official async Python client for [Montycat](https://montygovernance.com) —
# Search your data by MEANING — no external APIs, no separate vector database.
# (already ON by default in the montycat-semantic server edition)
hits = await Sales.semantic_search_get_values("Show all Bluetooth devices", limit=5)
# → [{__key__, __score__, __value__: {"name": "Wireless Headphones"}}, ...] (matched by meaning, not keywords)
# → [{__key__: 123..., __score__: 0.82, __value__: {"name": "Wireless Headphones"}}]
```

> ### 🧩 All-in-one. AI-native. **Zero external dependencies.**
Expand Down Expand Up @@ -66,7 +66,7 @@ docker run -d --name montycat \
-p 21210:21210 -p 21211:21211 \
-e MONTYCAT_SUPEROWNER="admin" \
-e MONTYCAT_PASSWORD="change-me" \
-v montycat_data:/app/.montycat \
-v montycat_data:/var/lib/.montycat \
montygovernance/montycat:semantic
```

Expand Down Expand Up @@ -185,6 +185,29 @@ await connection.enable_semantic_search(model="bge-base")
await connection.disable_semantic_search()
```

### Hybrid semantic search

Restrict meaning-based ranking to records matching structured metadata. The
filter is a hard AND pre-filter with the same criteria shape as
`lookup_keys_where`; it does not boost cosine scores.

```python
matching_keys = await Sales.semantic_search_get_keys_where(
"astronomy and outer space",
{"category": "space"},
limit=5,
min_score=0.35,
)

matching_values = await Sales.semantic_search_get_values_where(
"astronomy and outer space",
{"category": "space"},
limit=5,
)
# key hits: {"__key__", "__score__"}
# value hits: {"__key__", "__score__", "__value__"}
```

## 🔗 Links

- 🌐 **Website & Docs** — https://montygovernance.com
Expand All @@ -199,4 +222,3 @@ await connection.disable_semantic_search()
- **Is it a Pinecone / Weaviate / Chroma / Qdrant alternative?** Yes — self-hosted and open-source, with a NoSQL store built in.
- **Which Python versions?** 3.9+ — fully async (`asyncio`).


83 changes: 80 additions & 3 deletions montycat/store_classes/kv.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ async def lookup_values_where(cls, limit: Union[int, list] = 0, with_pointers: b
return await cls._run_query(query)

@classmethod
async def _semantic_search(cls, query: str, limit: Union[int, list], min_score: Union[float, None], with_pointers: bool, key_included: bool, pointers_metadata: bool):
async def _semantic_search(cls, query: str, limit: Union[int, list], min_score: Union[float, None], filters: Union[dict, None], with_pointers: bool, key_included: bool, pointers_metadata: bool):
"""Shared core for `semantic_search_get_keys` / `semantic_search_get_values`.

The server command is the same either way (`semantic_search`); the two
Expand All @@ -374,6 +374,7 @@ async def _semantic_search(cls, query: str, limit: Union[int, list], min_score:
semantic_query=query,
limit_output=handle_limit(limit),
min_score=min_score,
semantic_filter=filters,
with_pointers=with_pointers,
key_included=key_included,
pointers_metadata=pointers_metadata,
Expand Down Expand Up @@ -411,7 +412,7 @@ async def semantic_search_get_keys(cls, query: str, limit: Union[int, list] = 0,
Raises:
ValueError: If no query text is provided.
"""
return await cls._semantic_search(query, limit, min_score, False, False, False)
return await cls._semantic_search(query, limit, min_score, None, False, False, False)

@classmethod
async def semantic_search_get_values(cls, query: str, limit: Union[int, list] = 0, min_score: Union[float, None] = None, with_pointers: bool = False, pointers_metadata: bool = False):
Expand Down Expand Up @@ -449,7 +450,83 @@ async def semantic_search_get_values(cls, query: str, limit: Union[int, list] =
Raises:
ValueError: If no query text is provided.
"""
return await cls._semantic_search(query, limit, min_score, with_pointers, True, pointers_metadata)
return await cls._semantic_search(query, limit, min_score, None, with_pointers, True, pointers_metadata)

@classmethod
async def semantic_search_get_keys_where(cls, query: str, filters: dict, limit: Union[int, list] = 0, min_score: Union[float, None] = None):
"""
Hybrid semantic search returning ranked keys only, restricted by a metadata filter.

Same ranking as `semantic_search_get_keys`, but only items matching `filters`
are considered — a hard AND constraint through the same criteria stack as
`lookup_keys_where` (indexed fields, Timestamp, Pointer). Scores stay pure
cosine; the filter never boosts, it only restricts. A filter matching
nothing returns `[]`.

A separate method (not a parameter on `semantic_search_get_keys`) so
existing integrations keep their exact signature.

Args:
query (str): The natural-language query text to embed and search for.
filters (dict): Metadata criteria, same shape as `lookup_keys_where`.
limit (int | list, optional): The maximum number of ranked results to return.
An int is treated as the top-k; a two-item list
[start, stop] paginates the ranked hits. Default 0,
which lets the server apply its default top-k (10).
min_score (float, optional): Drop hits whose cosine similarity (in [-1, 1]) is
below this value. Default None (no score filter).

Returns:
list | str: A list of ranked hits, each `{'__key__': ..., '__score__': ...}`.
Returns a string error message if the query fails.

Raises:
ValueError: If no query text or no filters are provided.
"""
if not filters:
raise ValueError("No filters provided for hybrid semantic search.")
return await cls._semantic_search(query, limit, min_score, filters, False, False, False)

@classmethod
async def semantic_search_get_values_where(cls, query: str, filters: dict, limit: Union[int, list] = 0, min_score: Union[float, None] = None, with_pointers: bool = False, pointers_metadata: bool = False):
"""
Hybrid semantic search returning ranked hits with their values, restricted by a metadata filter.

Same ranking as `semantic_search_get_values`, but only items matching `filters`
are considered — a hard AND constraint through the same criteria stack as
`lookup_keys_where` (indexed fields, Timestamp, Pointer). Scores stay pure
cosine; the filter never boosts, it only restricts. A filter matching
nothing returns `[]`.

A separate method (not a parameter on `semantic_search_get_values`) so
existing integrations keep their exact signature.

Args:
query (str): The natural-language query text to embed and search for.
filters (dict): Metadata criteria, same shape as `lookup_keys_where`.
limit (int | list, optional): The maximum number of ranked results to return.
An int is treated as the top-k; a two-item list
[start, stop] paginates the ranked hits. Default 0,
which lets the server apply its default top-k (10).
min_score (float, optional): Drop hits whose cosine similarity (in [-1, 1]) is
below this value. Default None (no score filter).
with_pointers (bool, optional): If True, include pointers (foreign values) in each
returned value. Default False.
pointers_metadata (bool, optional): If True, include pointer metadata in each
returned value. Default False.

Returns:
list | str: A list of ranked hits, each
`{'__key__': ..., '__score__': ..., '__value__': ...}` — the same
dunder envelope `lookup_values_where(key_included=True)` returns,
plus the score. Returns a string error message if the query fails.

Raises:
ValueError: If no query text or no filters are provided.
"""
if not filters:
raise ValueError("No filters provided for hybrid semantic search.")
return await cls._semantic_search(query, limit, min_score, filters, with_pointers, True, pointers_metadata)

@classmethod
async def list_all_depending_keys(cls, key: Union[str, None] = None, custom_key: Union[str, None] = None):
Expand Down
9 changes: 9 additions & 0 deletions montycat/store_functions/store_generic_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ def convert_to_binary_query(
schema: Union[str, None] = None,
semantic_query: Union[str, None] = None,
min_score: Union[float, None] = None,
semantic_filter: Union[dict, None] = None,
wait_for_index: Union[bool, None] = None,
) -> bytes:
"""
Expand Down Expand Up @@ -216,6 +217,14 @@ def convert_to_binary_query(
if min_score is not None:
query_dict["min_score"] = min_score

# Hybrid metadata pre-filter for `semantic_search` (hard AND constraint,
# same criteria shape as lookup_keys_where — Timestamp/Pointer supported).
# Omit when None so the wire is unchanged for existing commands.
if semantic_filter is not None:
query_dict["semantic_filter"] = normalize_bools(
handle_timestamps_and_pointers(semantic_filter)
)

# Per-request wait_for_index override for persistent writes; omit when None
# so the server falls back to its DB-wide default (existing wire unchanged).
if wait_for_index is not None:
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

setup(
name='montycat',
version='1.0.6',
version='1.0.7',
description=(
'Self-hosted vector database + NoSQL with built-in AI semantic search — the async '
'Python client for Montycat. A Rust-powered, AI-native Pinecone / Weaviate / Chroma '
Expand Down
Loading