From b0feff88872bf66377037b4e4af35b2ce1e1bb85 Mon Sep 17 00:00:00 2001 From: Eugene&Monty <136487467+EugeneSukharev1988@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:38:17 -0500 Subject: [PATCH 1/4] init --- montycat/store_classes/kv.py | 83 ++++++++++++++++++- .../store_generic_functions.py | 9 ++ 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/montycat/store_classes/kv.py b/montycat/store_classes/kv.py index 96102a3..484de84 100644 --- a/montycat/store_classes/kv.py +++ b/montycat/store_classes/kv.py @@ -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 @@ -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, @@ -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): @@ -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): diff --git a/montycat/store_functions/store_generic_functions.py b/montycat/store_functions/store_generic_functions.py index 9810bbb..429dbdc 100644 --- a/montycat/store_functions/store_generic_functions.py +++ b/montycat/store_functions/store_generic_functions.py @@ -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: """ @@ -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: From effd5a7036beeeeff82b240ad5c0ecc452d51bff Mon Sep 17 00:00:00 2001 From: Eugene&Monty <136487467+EugeneSukharev1988@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:36:17 -0500 Subject: [PATCH 2/4] version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a27b08f..d3b4689 100644 --- a/setup.py +++ b/setup.py @@ -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 ' From a94b850a94e56939218445a5d5c941b7372015ac Mon Sep 17 00:00:00 2001 From: Eugene&Monty <136487467+EugeneSukharev1988@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:39:47 -0500 Subject: [PATCH 3/4] readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a88063e..1e6ea17 100644 --- a/README.md +++ b/README.md @@ -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 ``` From b618fbbb2c2a81887d20571c8759af8a2477df57 Mon Sep 17 00:00:00 2001 From: Eugene&Monty <136487467+EugeneSukharev1988@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:57:06 -0500 Subject: [PATCH 4/4] changelog --- README.md | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1e6ea17..a921759 100644 --- a/README.md +++ b/README.md @@ -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.** @@ -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 @@ -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`). -