patternmem-rag 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- patternmem/__init__.py +40 -0
- patternmem/_utils.py +25 -0
- patternmem/augmenter.py +155 -0
- patternmem/backend.py +138 -0
- patternmem/backends/__init__.py +1 -0
- patternmem/backends/chroma_backend.py +215 -0
- patternmem/backends/faiss_backend.py +302 -0
- patternmem/backends/json_backend.py +173 -0
- patternmem/backends/neo4j_backend.py +236 -0
- patternmem/backends/networkx_backend.py +157 -0
- patternmem/backends/sqlite_backend.py +197 -0
- patternmem/decay.py +90 -0
- patternmem/eval_router.py +316 -0
- patternmem/middleware.py +336 -0
- patternmem/observability.py +101 -0
- patternmem/reflector.py +211 -0
- patternmem/resolver.py +138 -0
- patternmem/types.py +212 -0
- patternmem_rag-0.1.1.dist-info/METADATA +238 -0
- patternmem_rag-0.1.1.dist-info/RECORD +23 -0
- patternmem_rag-0.1.1.dist-info/WHEEL +5 -0
- patternmem_rag-0.1.1.dist-info/licenses/LICENSE +21 -0
- patternmem_rag-0.1.1.dist-info/top_level.txt +1 -0
patternmem/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
patternmem
|
|
3
|
+
~~~~~~~~~~
|
|
4
|
+
Framework-agnostic RAG middleware that makes any pipeline self-improving
|
|
5
|
+
via persistent cross-query failure-pattern memory.
|
|
6
|
+
|
|
7
|
+
One-line integration::
|
|
8
|
+
|
|
9
|
+
answer = await PatternMemMiddleware(pipeline=my_rag_pipeline).ainvoke(query)
|
|
10
|
+
|
|
11
|
+
Public API
|
|
12
|
+
----------
|
|
13
|
+
The following names are the stable, versioned public surface of this package.
|
|
14
|
+
Anything not listed here is considered internal and may change without notice.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from patternmem.backend import MemoryBackend
|
|
20
|
+
from patternmem.middleware import PatternMemMiddleware
|
|
21
|
+
from patternmem.types import (
|
|
22
|
+
FailurePattern,
|
|
23
|
+
FailureSignal,
|
|
24
|
+
FailureType,
|
|
25
|
+
LLMResolverError,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"PatternMemMiddleware",
|
|
30
|
+
# data contracts
|
|
31
|
+
"FailureSignal",
|
|
32
|
+
"FailureType",
|
|
33
|
+
"FailurePattern",
|
|
34
|
+
# errors
|
|
35
|
+
"LLMResolverError",
|
|
36
|
+
# ABC (for community backend implementors)
|
|
37
|
+
"MemoryBackend",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
__version__ = "0.1.0"
|
patternmem/_utils.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
patternmem._utils
|
|
3
|
+
~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Internal utility helpers shared across backends.
|
|
5
|
+
|
|
6
|
+
All functions here are *pure* (no I/O, no async, no side-effects).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def cosine_similarity(a: list[float], b: list[float]) -> float:
|
|
15
|
+
"""Return cosine similarity in [−1, 1] between two L2-normalised vectors.
|
|
16
|
+
|
|
17
|
+
Returns 0.0 if either vector has zero norm (avoids division-by-zero).
|
|
18
|
+
"""
|
|
19
|
+
va = np.array(a, dtype=np.float32)
|
|
20
|
+
vb = np.array(b, dtype=np.float32)
|
|
21
|
+
norm_a = float(np.linalg.norm(va))
|
|
22
|
+
norm_b = float(np.linalg.norm(vb))
|
|
23
|
+
if norm_a == 0.0 or norm_b == 0.0:
|
|
24
|
+
return 0.0
|
|
25
|
+
return float(np.dot(va, vb) / (norm_a * norm_b))
|
patternmem/augmenter.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""
|
|
2
|
+
patternmem.augmenter
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Augmenter — builds the ``augmented_input`` dict from retrieved ``FailurePattern``
|
|
5
|
+
objects and injects it into the pipeline call for Phase 1 of the 3-phase loop.
|
|
6
|
+
|
|
7
|
+
Invariants enforced here
|
|
8
|
+
------------------------
|
|
9
|
+
- **Invariant 2**: Never mutates the user's prompt template. All influence
|
|
10
|
+
flows through ``augmented_input``.
|
|
11
|
+
- **Invariant 4**: Retrieval-type hints → ``augmented_input["retrieval_hint"]``
|
|
12
|
+
only. Generation-type constraints → ``augmented_input["generation_constraint"]``
|
|
13
|
+
only. No hint ever appears in the wrong key.
|
|
14
|
+
|
|
15
|
+
Framework detection
|
|
16
|
+
-------------------
|
|
17
|
+
Duck-typing only — no ``isinstance`` against framework types, no framework imports.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from patternmem.types import (
|
|
25
|
+
FailurePattern,
|
|
26
|
+
is_generation_failure,
|
|
27
|
+
is_retrieval_failure,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Augmenter:
|
|
32
|
+
"""Builds ``augmented_input`` dicts from failure patterns.
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
pipeline:
|
|
37
|
+
The wrapped pipeline object. Used for framework-specific augmentation
|
|
38
|
+
(duck-typing only).
|
|
39
|
+
rewrite_feedback:
|
|
40
|
+
If ``True``, add a ``rewrite_constraint`` key to ``augmented_input``
|
|
41
|
+
when retrieval hints are present. Never touches the prompt template.
|
|
42
|
+
allow_param_override:
|
|
43
|
+
If ``True``, add ``llm_call_kwargs`` (e.g. ``temperature=0.1``) to
|
|
44
|
+
``augmented_input`` for grounding-sensitive failure types.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
pipeline: Any,
|
|
50
|
+
rewrite_feedback: bool = False,
|
|
51
|
+
allow_param_override: bool = False,
|
|
52
|
+
) -> None:
|
|
53
|
+
self._pipeline = pipeline
|
|
54
|
+
self._rewrite_feedback = rewrite_feedback
|
|
55
|
+
self._allow_param_override = allow_param_override
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
# Framework detection helpers (duck-typing, no imports)
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def _is_langchain(self) -> bool:
|
|
62
|
+
"""Heuristic: pipeline has a ``retriever`` attribute with ``search_kwargs``."""
|
|
63
|
+
try:
|
|
64
|
+
return hasattr(self._pipeline, "retriever") and hasattr(
|
|
65
|
+
self._pipeline.retriever, "search_kwargs"
|
|
66
|
+
)
|
|
67
|
+
except Exception: # noqa: BLE001
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
def _is_llamaindex(self) -> bool:
|
|
71
|
+
"""Heuristic: pipeline responds to ``as_query_engine()`` call."""
|
|
72
|
+
try:
|
|
73
|
+
return callable(getattr(self._pipeline, "as_query_engine", None))
|
|
74
|
+
except Exception: # noqa: BLE001
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
# Public method
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def build(self, patterns: list[FailurePattern]) -> dict[str, Any]:
|
|
82
|
+
"""Build the ``augmented_input`` dict from the given patterns.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
patterns:
|
|
87
|
+
Matched patterns from ``backend.lookup_patterns()``. May contain
|
|
88
|
+
both retrieval-type and generation-type failures (compound case).
|
|
89
|
+
|
|
90
|
+
Returns
|
|
91
|
+
-------
|
|
92
|
+
dict[str, Any]
|
|
93
|
+
Keys set only as needed:
|
|
94
|
+
- ``"retrieval_hint"`` — present only if retrieval-type patterns exist
|
|
95
|
+
- ``"generation_constraint"`` — present only if generation-type patterns exist
|
|
96
|
+
- ``"rewrite_constraint"`` — present only if ``rewrite_feedback=True``
|
|
97
|
+
and a retrieval hint was produced
|
|
98
|
+
- ``"llm_call_kwargs"`` — present only if ``allow_param_override=True``
|
|
99
|
+
and a generation-type constraint was produced
|
|
100
|
+
- ``"langchain_retriever_hint"`` — present only for LangChain pipelines
|
|
101
|
+
- ``"llamaindex_query_bundle"`` — present only for LlamaIndex pipelines
|
|
102
|
+
|
|
103
|
+
Notes (Invariant 4)
|
|
104
|
+
-------------------
|
|
105
|
+
Retrieval-type ``FailureType``s → ``"retrieval_hint"`` only.
|
|
106
|
+
Generation-type ``FailureType``s → ``"generation_constraint"`` only.
|
|
107
|
+
This is enforced by ``is_retrieval_failure`` / ``is_generation_failure``
|
|
108
|
+
defined in ``patternmem.types``.
|
|
109
|
+
"""
|
|
110
|
+
retrieval_hints: list[str] = []
|
|
111
|
+
generation_constraints: list[str] = []
|
|
112
|
+
|
|
113
|
+
for p in patterns:
|
|
114
|
+
if is_retrieval_failure(p.failure_type):
|
|
115
|
+
retrieval_hints.append(p.hint_text)
|
|
116
|
+
elif is_generation_failure(p.failure_type):
|
|
117
|
+
generation_constraints.append(p.hint_text)
|
|
118
|
+
# UNKNOWN failure type → no augmentation
|
|
119
|
+
|
|
120
|
+
augmented: dict[str, Any] = {}
|
|
121
|
+
|
|
122
|
+
# --- Retrieval lane ---
|
|
123
|
+
if retrieval_hints:
|
|
124
|
+
combined_hint = "; ".join(retrieval_hints)
|
|
125
|
+
augmented["retrieval_hint"] = combined_hint
|
|
126
|
+
|
|
127
|
+
# Framework-specific injection
|
|
128
|
+
if self._is_langchain():
|
|
129
|
+
try:
|
|
130
|
+
self._pipeline.retriever.search_kwargs["hint"] = combined_hint
|
|
131
|
+
augmented["langchain_retriever_hint"] = combined_hint
|
|
132
|
+
except Exception: # noqa: BLE001
|
|
133
|
+
pass
|
|
134
|
+
elif self._is_llamaindex():
|
|
135
|
+
augmented["llamaindex_query_bundle"] = {
|
|
136
|
+
"custom_embedding_strs": [combined_hint]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
# Rewrite feedback lane (only if explicitly enabled)
|
|
140
|
+
if self._rewrite_feedback:
|
|
141
|
+
# Invariant 2: never patch the prompt object — append via context key only
|
|
142
|
+
augmented["rewrite_constraint"] = (
|
|
143
|
+
f"Constraint (auto): {combined_hint}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# --- Generation lane ---
|
|
147
|
+
if generation_constraints:
|
|
148
|
+
combined_constraint = "; ".join(generation_constraints)
|
|
149
|
+
augmented["generation_constraint"] = combined_constraint
|
|
150
|
+
|
|
151
|
+
# Optional temperature override for grounding-sensitive queries
|
|
152
|
+
if self._allow_param_override:
|
|
153
|
+
augmented["llm_call_kwargs"] = {"temperature": 0.1}
|
|
154
|
+
|
|
155
|
+
return augmented
|
patternmem/backend.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
patternmem.backend
|
|
3
|
+
~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Abstract base class for all PatternMem memory backends.
|
|
5
|
+
|
|
6
|
+
Every storage backend (JSON, SQLite, NetworkX, Neo4j) must subclass
|
|
7
|
+
``MemoryBackend`` and implement every abstract method. The contract test suite
|
|
8
|
+
in ``tests/contract/test_backend_contract.py`` asserts identical behaviour
|
|
9
|
+
across all implementations, making them provably interchangeable.
|
|
10
|
+
|
|
11
|
+
Design rules
|
|
12
|
+
------------
|
|
13
|
+
- All methods are ``async`` — implementations may use any I/O strategy
|
|
14
|
+
(asyncio-native, ``asyncio.to_thread``, etc.) as long as they do not block
|
|
15
|
+
the event loop for more than ~10 ms.
|
|
16
|
+
- The ABC deliberately does *not* import any storage driver — backends stay
|
|
17
|
+
in ``patternmem/backends/`` and import drivers themselves.
|
|
18
|
+
- ``update_pattern`` and ``delete_pattern`` are required by the decay loop
|
|
19
|
+
(Invariant 6) but are *not* exported from ``patternmem.__init__``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from abc import ABC, abstractmethod
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from patternmem.types import FailurePattern
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MemoryBackend(ABC):
|
|
31
|
+
"""Abstract base class that every storage backend must implement.
|
|
32
|
+
|
|
33
|
+
The public surface (``write_pattern``, ``lookup_patterns``, ``get_stats``)
|
|
34
|
+
is exported from ``patternmem.__init__``. The lifecycle methods
|
|
35
|
+
(``update_pattern``, ``delete_pattern``) are required internally for the
|
|
36
|
+
decay/eviction loop but are not part of the user-facing API.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
# ------------------------------------------------------------------
|
|
40
|
+
# Public API — exported via patternmem.__init__
|
|
41
|
+
# ------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
async def write_pattern(self, pattern: FailurePattern) -> None:
|
|
45
|
+
"""Persist a new ``FailurePattern`` to the backend.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
pattern:
|
|
50
|
+
The failure pattern to store. The ``id`` field is already set by
|
|
51
|
+
``FailurePattern``'s ``__post_init__``.
|
|
52
|
+
|
|
53
|
+
Notes
|
|
54
|
+
-----
|
|
55
|
+
Implementations must be idempotent on ``pattern.id``: writing the same
|
|
56
|
+
``id`` twice should update the existing record, not create a duplicate.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
async def lookup_patterns(
|
|
61
|
+
self,
|
|
62
|
+
query_embedding: list[float],
|
|
63
|
+
top_k: int = 3,
|
|
64
|
+
) -> list[FailurePattern]:
|
|
65
|
+
"""Return the top-*k* patterns whose embeddings are similar to *query_embedding*.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
query_embedding:
|
|
70
|
+
Dense query vector produced by the MiniLM encoder.
|
|
71
|
+
top_k:
|
|
72
|
+
Maximum number of patterns to return. Implementations must apply
|
|
73
|
+
the similarity threshold (0.82 by default, configurable via the
|
|
74
|
+
middleware's ``similarity_threshold`` parameter) before ranking.
|
|
75
|
+
|
|
76
|
+
Returns
|
|
77
|
+
-------
|
|
78
|
+
list[FailurePattern]
|
|
79
|
+
Ordered by descending similarity score. Empty list if no patterns
|
|
80
|
+
exceed the similarity threshold.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
@abstractmethod
|
|
84
|
+
async def get_stats(self) -> dict[str, Any]:
|
|
85
|
+
"""Return backend health and bookkeeping statistics.
|
|
86
|
+
|
|
87
|
+
The returned dict must contain at minimum:
|
|
88
|
+
|
|
89
|
+
.. code-block:: python
|
|
90
|
+
|
|
91
|
+
{"count": int} # total patterns stored
|
|
92
|
+
|
|
93
|
+
Implementations may include additional keys (e.g. ``"path"``,
|
|
94
|
+
``"uri"``, ``"evictions"``).
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
# Lifecycle API — required by decay/eviction loop (Invariant 6)
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
@abstractmethod
|
|
102
|
+
async def update_pattern(self, pattern_id: str, decay_weight: float) -> None:
|
|
103
|
+
"""Update the ``decay_weight`` of an existing pattern.
|
|
104
|
+
|
|
105
|
+
Parameters
|
|
106
|
+
----------
|
|
107
|
+
pattern_id:
|
|
108
|
+
UUID string of the pattern to update.
|
|
109
|
+
decay_weight:
|
|
110
|
+
New decay weight value. Must be stored verbatim; no clamping is
|
|
111
|
+
performed here — callers (``BackgroundReflector``) own that logic.
|
|
112
|
+
|
|
113
|
+
Raises
|
|
114
|
+
------
|
|
115
|
+
KeyError
|
|
116
|
+
If *pattern_id* does not exist in the backend.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
@abstractmethod
|
|
120
|
+
async def delete_pattern(self, pattern_id: str) -> None:
|
|
121
|
+
"""Permanently evict a pattern from the backend.
|
|
122
|
+
|
|
123
|
+
Parameters
|
|
124
|
+
----------
|
|
125
|
+
pattern_id:
|
|
126
|
+
UUID string of the pattern to delete.
|
|
127
|
+
|
|
128
|
+
Raises
|
|
129
|
+
------
|
|
130
|
+
KeyError
|
|
131
|
+
If *pattern_id* does not exist in the backend.
|
|
132
|
+
|
|
133
|
+
Notes
|
|
134
|
+
-----
|
|
135
|
+
This is called by the decay loop when ``decay_weight < 0.1``.
|
|
136
|
+
Implementations should ensure the deletion is durable (e.g. flushed
|
|
137
|
+
to disk for file-based backends) before returning.
|
|
138
|
+
"""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""patternmem.backends — internal package."""
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""
|
|
2
|
+
patternmem.backends.chroma_backend
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
ChromaDB backend for PatternMem.
|
|
5
|
+
|
|
6
|
+
Uses ChromaDB's native vector index for similarity search — no brute-force
|
|
7
|
+
numpy cosine loop is needed. This makes it the best choice for large
|
|
8
|
+
pattern memories (> 10 000 patterns) when you don't need Neo4j's graph model.
|
|
9
|
+
|
|
10
|
+
Storage layout
|
|
11
|
+
--------------
|
|
12
|
+
A single ChromaDB collection named ``"patternmem_patterns"`` (configurable).
|
|
13
|
+
Pattern metadata (failure_type, root_cause, hint_text, score, created_at,
|
|
14
|
+
decay_weight) is stored in Chroma's document metadata dict.
|
|
15
|
+
The query embedding is stored as the collection document embedding.
|
|
16
|
+
|
|
17
|
+
Similarity
|
|
18
|
+
----------
|
|
19
|
+
ChromaDB runs its own HNSW index for approximate nearest-neighbour search.
|
|
20
|
+
PatternMem's ``similarity_threshold`` is applied as a post-filter on the
|
|
21
|
+
returned cosine distances (Chroma returns distances not similarities, so
|
|
22
|
+
we convert: similarity = 1 − distance).
|
|
23
|
+
|
|
24
|
+
Requirements
|
|
25
|
+
------------
|
|
26
|
+
pip install patternmem-rag[chroma]
|
|
27
|
+
# or
|
|
28
|
+
pip install chromadb>=0.4
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import asyncio
|
|
34
|
+
from datetime import datetime, timezone
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from patternmem.backend import MemoryBackend
|
|
38
|
+
from patternmem.types import FailurePattern, FailureType
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
import chromadb
|
|
42
|
+
|
|
43
|
+
_CHROMA_AVAILABLE = True
|
|
44
|
+
except ImportError:
|
|
45
|
+
_CHROMA_AVAILABLE = False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _pattern_to_chroma(p: FailurePattern) -> dict[str, Any]:
|
|
49
|
+
"""Flatten FailurePattern into a Chroma metadata dict (scalar values only)."""
|
|
50
|
+
return {
|
|
51
|
+
"failure_type": p.failure_type.name,
|
|
52
|
+
"root_cause": p.root_cause,
|
|
53
|
+
"hint_text": p.hint_text,
|
|
54
|
+
"score": p.score,
|
|
55
|
+
"created_at": p.created_at.isoformat(),
|
|
56
|
+
"decay_weight": p.decay_weight,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _chroma_to_pattern(
|
|
61
|
+
pattern_id: str,
|
|
62
|
+
meta: dict[str, Any],
|
|
63
|
+
embedding: list[float],
|
|
64
|
+
) -> FailurePattern:
|
|
65
|
+
return FailurePattern(
|
|
66
|
+
id=pattern_id,
|
|
67
|
+
query_embedding=embedding,
|
|
68
|
+
failure_type=FailureType[meta["failure_type"]],
|
|
69
|
+
root_cause=meta["root_cause"],
|
|
70
|
+
hint_text=meta["hint_text"],
|
|
71
|
+
score=float(meta["score"]),
|
|
72
|
+
created_at=datetime.fromisoformat(meta["created_at"]).replace(
|
|
73
|
+
tzinfo=timezone.utc
|
|
74
|
+
),
|
|
75
|
+
decay_weight=float(meta["decay_weight"]),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ChromaBackend(MemoryBackend):
|
|
80
|
+
"""ChromaDB-backed vector store for PatternMem failure patterns.
|
|
81
|
+
|
|
82
|
+
Uses ChromaDB's native HNSW index for fast approximate nearest-neighbour
|
|
83
|
+
search. Recommended for pattern stores > 10 000 entries or when you
|
|
84
|
+
already run a Chroma server.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
collection_name:
|
|
89
|
+
Name of the Chroma collection to use. Created if it does not exist.
|
|
90
|
+
persist_directory:
|
|
91
|
+
Path for the persistent Chroma client. ``None`` uses an ephemeral
|
|
92
|
+
(in-memory) client — useful for tests and notebooks.
|
|
93
|
+
host / port:
|
|
94
|
+
If provided, connects to a running Chroma HTTP server instead of a
|
|
95
|
+
local file-backed client. ``host`` takes precedence over
|
|
96
|
+
``persist_directory``.
|
|
97
|
+
similarity_threshold:
|
|
98
|
+
Minimum cosine similarity (0–1) for a pattern to be returned.
|
|
99
|
+
Chroma returns L2 or cosine distances; PatternMem converts and filters.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
collection_name: str = "patternmem_patterns",
|
|
105
|
+
persist_directory: str | None = None,
|
|
106
|
+
host: str | None = None,
|
|
107
|
+
port: int = 8000,
|
|
108
|
+
similarity_threshold: float = 0.82,
|
|
109
|
+
) -> None:
|
|
110
|
+
if not _CHROMA_AVAILABLE:
|
|
111
|
+
raise ImportError(
|
|
112
|
+
"ChromaBackend requires 'chromadb'. "
|
|
113
|
+
"Install it with: pip install patternmem-rag[chroma]"
|
|
114
|
+
)
|
|
115
|
+
self._threshold = similarity_threshold
|
|
116
|
+
self._collection_name = collection_name
|
|
117
|
+
|
|
118
|
+
if host is not None:
|
|
119
|
+
# Connect to a running Chroma HTTP server
|
|
120
|
+
self._client = chromadb.HttpClient(host=host, port=port)
|
|
121
|
+
elif persist_directory is not None:
|
|
122
|
+
# File-backed persistent client
|
|
123
|
+
self._client = chromadb.PersistentClient(path=persist_directory)
|
|
124
|
+
else:
|
|
125
|
+
# Ephemeral in-memory client (tests / notebooks)
|
|
126
|
+
self._client = chromadb.EphemeralClient()
|
|
127
|
+
|
|
128
|
+
# Get-or-create the collection with cosine similarity space
|
|
129
|
+
self._collection = self._client.get_or_create_collection(
|
|
130
|
+
name=collection_name,
|
|
131
|
+
metadata={"hnsw:space": "cosine"},
|
|
132
|
+
)
|
|
133
|
+
self._lock = asyncio.Lock()
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------
|
|
136
|
+
# MemoryBackend implementation
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
async def write_pattern(self, pattern: FailurePattern) -> None:
|
|
140
|
+
async with self._lock:
|
|
141
|
+
await asyncio.to_thread(self._write_sync, pattern)
|
|
142
|
+
|
|
143
|
+
def _write_sync(self, pattern: FailurePattern) -> None:
|
|
144
|
+
"""Upsert pattern into the Chroma collection (idempotent on pattern.id)."""
|
|
145
|
+
self._collection.upsert(
|
|
146
|
+
ids=[pattern.id],
|
|
147
|
+
embeddings=[pattern.query_embedding],
|
|
148
|
+
metadatas=[_pattern_to_chroma(pattern)],
|
|
149
|
+
documents=[pattern.root_cause or " "], # Chroma requires non-empty docs
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
async def lookup_patterns(
|
|
153
|
+
self,
|
|
154
|
+
query_embedding: list[float],
|
|
155
|
+
top_k: int = 3,
|
|
156
|
+
) -> list[FailurePattern]:
|
|
157
|
+
# Fetch more candidates than top_k so the threshold filter has room to work
|
|
158
|
+
n_results = min(top_k * 4, max(1, self._collection.count()))
|
|
159
|
+
if n_results == 0:
|
|
160
|
+
return []
|
|
161
|
+
|
|
162
|
+
results = await asyncio.to_thread(
|
|
163
|
+
self._collection.query,
|
|
164
|
+
query_embeddings=[query_embedding],
|
|
165
|
+
n_results=n_results,
|
|
166
|
+
include=["embeddings", "metadatas", "distances"],
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
ids: list[str] = results["ids"][0]
|
|
170
|
+
metadatas: list[dict[str, Any]] = results["metadatas"][0]
|
|
171
|
+
distances: list[float] = results["distances"][0]
|
|
172
|
+
embeddings: list[list[float]] = results["embeddings"][0]
|
|
173
|
+
|
|
174
|
+
# Chroma cosine space returns distances in [0, 2]; convert to similarity
|
|
175
|
+
# similarity = 1 − (distance / 2) (for cosine distance in [0, 2])
|
|
176
|
+
# OR similarity = 1 − distance (for normalized cosine in [0, 1])
|
|
177
|
+
# ChromaDB cosine distance = 1 − cosine_similarity → similarity = 1 − dist
|
|
178
|
+
patterns: list[FailurePattern] = []
|
|
179
|
+
for pid, meta, dist, emb in zip(ids, metadatas, distances, embeddings):
|
|
180
|
+
similarity = 1.0 - float(dist)
|
|
181
|
+
if similarity >= self._threshold:
|
|
182
|
+
patterns.append(_chroma_to_pattern(pid, meta, list(emb)))
|
|
183
|
+
|
|
184
|
+
# Already sorted by distance (ascending) = similarity descending
|
|
185
|
+
return patterns[:top_k]
|
|
186
|
+
|
|
187
|
+
async def get_stats(self) -> dict[str, Any]:
|
|
188
|
+
count = await asyncio.to_thread(self._collection.count)
|
|
189
|
+
return {
|
|
190
|
+
"count": count,
|
|
191
|
+
"collection": self._collection_name,
|
|
192
|
+
"backend": "chroma",
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async def update_pattern(self, pattern_id: str, decay_weight: float) -> None:
|
|
196
|
+
async with self._lock:
|
|
197
|
+
await asyncio.to_thread(self._update_sync, pattern_id, decay_weight)
|
|
198
|
+
|
|
199
|
+
def _update_sync(self, pattern_id: str, decay_weight: float) -> None:
|
|
200
|
+
result = self._collection.get(ids=[pattern_id], include=["metadatas"])
|
|
201
|
+
if not result["ids"]:
|
|
202
|
+
raise KeyError(f"Pattern {pattern_id!r} not found in Chroma backend")
|
|
203
|
+
meta = result["metadatas"][0]
|
|
204
|
+
meta["decay_weight"] = decay_weight
|
|
205
|
+
self._collection.update(ids=[pattern_id], metadatas=[meta])
|
|
206
|
+
|
|
207
|
+
async def delete_pattern(self, pattern_id: str) -> None:
|
|
208
|
+
async with self._lock:
|
|
209
|
+
await asyncio.to_thread(self._delete_sync, pattern_id)
|
|
210
|
+
|
|
211
|
+
def _delete_sync(self, pattern_id: str) -> None:
|
|
212
|
+
result = self._collection.get(ids=[pattern_id])
|
|
213
|
+
if not result["ids"]:
|
|
214
|
+
raise KeyError(f"Pattern {pattern_id!r} not found in Chroma backend")
|
|
215
|
+
self._collection.delete(ids=[pattern_id])
|