graphmemory 1.2.1__tar.gz → 1.3.0__tar.gz

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.
Files changed (45) hide show
  1. {graphmemory-1.2.1 → graphmemory-1.3.0}/PKG-INFO +5 -4
  2. {graphmemory-1.2.1 → graphmemory-1.3.0}/README.md +4 -3
  3. {graphmemory-1.2.1 → graphmemory-1.3.0}/graphmemory/database.py +59 -6
  4. {graphmemory-1.2.1 → graphmemory-1.3.0}/pyproject.toml +1 -1
  5. {graphmemory-1.2.1 → graphmemory-1.3.0}/tests/tests.py +142 -0
  6. {graphmemory-1.2.1 → graphmemory-1.3.0}/.gitignore +0 -0
  7. {graphmemory-1.2.1 → graphmemory-1.3.0}/LICENSE +0 -0
  8. {graphmemory-1.2.1 → graphmemory-1.3.0}/examples/dspy_example_typed_pred.py +0 -0
  9. {graphmemory-1.2.1 → graphmemory-1.3.0}/examples/lexical_graph.py +0 -0
  10. {graphmemory-1.2.1 → graphmemory-1.3.0}/examples/openai_example.py +0 -0
  11. {graphmemory-1.2.1 → graphmemory-1.3.0}/examples/test_ingest.py +0 -0
  12. {graphmemory-1.2.1 → graphmemory-1.3.0}/graphmemory/__init__.py +0 -0
  13. {graphmemory-1.2.1 → graphmemory-1.3.0}/graphmemory/algorithms.py +0 -0
  14. {graphmemory-1.2.1 → graphmemory-1.3.0}/graphmemory/extraction.py +0 -0
  15. {graphmemory-1.2.1 → graphmemory-1.3.0}/graphmemory/models.py +0 -0
  16. {graphmemory-1.2.1 → graphmemory-1.3.0}/input/Genetic Programming1.txt +0 -0
  17. {graphmemory-1.2.1 → graphmemory-1.3.0}/input/Genetic Programming2.txt +0 -0
  18. {graphmemory-1.2.1 → graphmemory-1.3.0}/input/Genetic Programming3.txt +0 -0
  19. {graphmemory-1.2.1 → graphmemory-1.3.0}/input/Genetic Programming4.txt +0 -0
  20. {graphmemory-1.2.1 → graphmemory-1.3.0}/input/aimav4.txt +0 -0
  21. {graphmemory-1.2.1 → graphmemory-1.3.0}/input/reading_in_plannings.txt +0 -0
  22. {graphmemory-1.2.1 → graphmemory-1.3.0}/requirements.txt +0 -0
  23. {graphmemory-1.2.1 → graphmemory-1.3.0}/video/package-lock.json +0 -0
  24. {graphmemory-1.2.1 → graphmemory-1.3.0}/video/package.json +0 -0
  25. {graphmemory-1.2.1 → graphmemory-1.3.0}/video/public/banner.png +0 -0
  26. {graphmemory-1.2.1 → graphmemory-1.3.0}/video/src/GraphMemoryShowcase.tsx +0 -0
  27. {graphmemory-1.2.1 → graphmemory-1.3.0}/video/src/Root.tsx +0 -0
  28. {graphmemory-1.2.1 → graphmemory-1.3.0}/video/src/components/Background.tsx +0 -0
  29. {graphmemory-1.2.1 → graphmemory-1.3.0}/video/src/components/CodeBlock.tsx +0 -0
  30. {graphmemory-1.2.1 → graphmemory-1.3.0}/video/src/components/FeaturePill.tsx +0 -0
  31. {graphmemory-1.2.1 → graphmemory-1.3.0}/video/src/components/GraphViz.tsx +0 -0
  32. {graphmemory-1.2.1 → graphmemory-1.3.0}/video/src/components/SectionTitle.tsx +0 -0
  33. {graphmemory-1.2.1 → graphmemory-1.3.0}/video/src/index.ts +0 -0
  34. {graphmemory-1.2.1 → graphmemory-1.3.0}/video/src/scenes/AlgorithmsScene.tsx +0 -0
  35. {graphmemory-1.2.1 → graphmemory-1.3.0}/video/src/scenes/ExportScene.tsx +0 -0
  36. {graphmemory-1.2.1 → graphmemory-1.3.0}/video/src/scenes/ExtractionScene.tsx +0 -0
  37. {graphmemory-1.2.1 → graphmemory-1.3.0}/video/src/scenes/IntroScene.tsx +0 -0
  38. {graphmemory-1.2.1 → graphmemory-1.3.0}/video/src/scenes/MergeScene.tsx +0 -0
  39. {graphmemory-1.2.1 → graphmemory-1.3.0}/video/src/scenes/NodeEdgeScene.tsx +0 -0
  40. {graphmemory-1.2.1 → graphmemory-1.3.0}/video/src/scenes/OutroScene.tsx +0 -0
  41. {graphmemory-1.2.1 → graphmemory-1.3.0}/video/src/scenes/QueryBuilderScene.tsx +0 -0
  42. {graphmemory-1.2.1 → graphmemory-1.3.0}/video/src/scenes/RetrievalScene.tsx +0 -0
  43. {graphmemory-1.2.1 → graphmemory-1.3.0}/video/src/scenes/VectorSearchScene.tsx +0 -0
  44. {graphmemory-1.2.1 → graphmemory-1.3.0}/video/src/theme.ts +0 -0
  45. {graphmemory-1.2.1 → graphmemory-1.3.0}/video/tsconfig.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphmemory
3
- Version: 1.2.1
3
+ Version: 1.3.0
4
4
  Summary: Graph-based memory system using DuckDB
5
5
  Project-URL: Homepage, https://github.com/bradAGI/GraphMemory
6
6
  Project-URL: Repository, https://github.com/bradAGI/GraphMemory
@@ -224,7 +224,7 @@ All IDs are auto-generated UUIDs. All models are [Pydantic](https://docs.pydanti
224
224
 
225
225
  | Method | Description |
226
226
  |--------|-------------|
227
- | `GraphMemory(database=None, vector_length=3, distance_metric='l2')` | Initialize. `None` = in-memory. |
227
+ | `GraphMemory(database=None, vector_length=3, distance_metric='l2', hnsw_ef_construction=128, hnsw_ef_search=64, hnsw_m=16, auto_index=True)` | Initialize. `None` = in-memory. HNSW index auto-created. |
228
228
  | `close()` | Close connection (thread-safe, idempotent). |
229
229
  | `transaction()` | Context manager for atomic operations. |
230
230
 
@@ -262,7 +262,8 @@ All IDs are auto-generated UUIDs. All models are [Pydantic](https://docs.pydanti
262
262
  | `nearest_nodes(vector, limit) -> list[NearestNode]` | Vector similarity search. |
263
263
  | `search_nodes(query_text, limit=10) -> list[SearchResult]` | Full-text BM25 search. |
264
264
  | `hybrid_search(query_text, query_vector, ...) -> list[SearchResult]` | Combined text + vector search. |
265
- | `create_index()` | Create HNSW index for faster vector search. |
265
+ | `create_index(ef_construction=None, ef_search=None, m=None)` | Create/recreate HNSW index with tunable params. Auto-called on init. |
266
+ | `compact_index()` | Compact HNSW index to reclaim space after deletions. |
266
267
 
267
268
  ### Retrieval
268
269
 
@@ -295,7 +296,7 @@ See `examples/` for complete usage:
295
296
 
296
297
  ## Testing
297
298
 
298
- 265 tests covering all functionality.
299
+ 291 tests covering all functionality.
299
300
 
300
301
  ```sh
301
302
  python3 -m pytest tests/tests.py -v
@@ -197,7 +197,7 @@ All IDs are auto-generated UUIDs. All models are [Pydantic](https://docs.pydanti
197
197
 
198
198
  | Method | Description |
199
199
  |--------|-------------|
200
- | `GraphMemory(database=None, vector_length=3, distance_metric='l2')` | Initialize. `None` = in-memory. |
200
+ | `GraphMemory(database=None, vector_length=3, distance_metric='l2', hnsw_ef_construction=128, hnsw_ef_search=64, hnsw_m=16, auto_index=True)` | Initialize. `None` = in-memory. HNSW index auto-created. |
201
201
  | `close()` | Close connection (thread-safe, idempotent). |
202
202
  | `transaction()` | Context manager for atomic operations. |
203
203
 
@@ -235,7 +235,8 @@ All IDs are auto-generated UUIDs. All models are [Pydantic](https://docs.pydanti
235
235
  | `nearest_nodes(vector, limit) -> list[NearestNode]` | Vector similarity search. |
236
236
  | `search_nodes(query_text, limit=10) -> list[SearchResult]` | Full-text BM25 search. |
237
237
  | `hybrid_search(query_text, query_vector, ...) -> list[SearchResult]` | Combined text + vector search. |
238
- | `create_index()` | Create HNSW index for faster vector search. |
238
+ | `create_index(ef_construction=None, ef_search=None, m=None)` | Create/recreate HNSW index with tunable params. Auto-called on init. |
239
+ | `compact_index()` | Compact HNSW index to reclaim space after deletions. |
239
240
 
240
241
  ### Retrieval
241
242
 
@@ -268,7 +269,7 @@ See `examples/` for complete usage:
268
269
 
269
270
  ## Testing
270
271
 
271
- 265 tests covering all functionality.
272
+ 291 tests covering all functionality.
272
273
 
273
274
  ```sh
274
275
  python3 -m pytest tests/tests.py -v
@@ -86,7 +86,8 @@ class GraphMemory:
86
86
  'inner_product': {'function': 'array_negative_inner_product', 'hnsw_metric': 'ip'},
87
87
  }
88
88
 
89
- def __init__(self, database=None, vector_length=3, distance_metric='l2', max_retries=3, retry_base_delay=0.1):
89
+ def __init__(self, database=None, vector_length=3, distance_metric='l2', max_retries=3, retry_base_delay=0.1,
90
+ hnsw_ef_construction=128, hnsw_ef_search=64, hnsw_m=16, auto_index=True):
90
91
  if distance_metric not in self.DISTANCE_METRICS:
91
92
  raise ValueError(
92
93
  f"Invalid distance_metric '{distance_metric}'. "
@@ -97,9 +98,13 @@ class GraphMemory:
97
98
  self.distance_metric = distance_metric
98
99
  self.max_retries = max_retries
99
100
  self.retry_base_delay = retry_base_delay
101
+ self.hnsw_ef_construction = hnsw_ef_construction
102
+ self.hnsw_ef_search = hnsw_ef_search
103
+ self.hnsw_m = hnsw_m
100
104
  self._lock = threading.RLock()
101
105
  self._fts_initialized = False
102
106
  self._fts_dirty = True
107
+ self._hnsw_indexed = False
103
108
  self._closed = False
104
109
  self.conn = duckdb.connect(database=self.database)
105
110
  self._load_vss_extension()
@@ -116,6 +121,9 @@ class GraphMemory:
116
121
  self._create_tables()
117
122
  logger.info("Tables created or verified successfully.")
118
123
 
124
+ if auto_index:
125
+ self._ensure_hnsw_index()
126
+
119
127
  def cursor(self):
120
128
  """Return a new DuckDB cursor for individual operations.
121
129
 
@@ -150,6 +158,8 @@ class GraphMemory:
150
158
  self._configure_database()
151
159
  self._fts_initialized = False
152
160
  self._fts_dirty = True
161
+ self._hnsw_indexed = False
162
+ self._ensure_hnsw_index()
153
163
  logger.info("Reconnection successful.")
154
164
 
155
165
  def close(self):
@@ -191,6 +201,8 @@ class GraphMemory:
191
201
 
192
202
  def set_vector_length(self, vector_length):
193
203
  self.vector_length = vector_length
204
+ self._hnsw_indexed = False
205
+ self._ensure_hnsw_index()
194
206
  logger.info(f"Vector length set to: {self.vector_length}")
195
207
 
196
208
  def _create_tables(self):
@@ -303,6 +315,7 @@ class GraphMemory:
303
315
  cur.execute(
304
316
  "DELETE FROM nodes WHERE id = ?;", (str(node_id),))
305
317
  self._fts_dirty = True
318
+ self.compact_index()
306
319
  except duckdb.Error as e:
307
320
  logger.error(f"Error deleting node: {e}")
308
321
 
@@ -321,6 +334,7 @@ class GraphMemory:
321
334
  cur.execute(
322
335
  f"DELETE FROM nodes WHERE id IN ({placeholders});", id_strs)
323
336
  self._fts_dirty = True
337
+ self.compact_index()
324
338
  except duckdb.Error as e:
325
339
  logger.error(f"Error during bulk delete nodes: {e}")
326
340
 
@@ -920,15 +934,53 @@ class GraphMemory:
920
934
  logger.error(f"Error updating edge: {e}")
921
935
  return False
922
936
 
937
+ def _ensure_hnsw_index(self):
938
+ """Create HNSW index if not already present. Called automatically on init."""
939
+ if self._hnsw_indexed:
940
+ return
941
+ try:
942
+ nodes_exist = self.conn.execute(
943
+ "SELECT 1 FROM information_schema.tables WHERE table_name = 'nodes';"
944
+ ).fetchone()
945
+ if nodes_exist:
946
+ self.create_index()
947
+ except duckdb.Error:
948
+ pass
949
+
923
950
  @with_retry()
924
- def create_index(self):
951
+ def create_index(self, ef_construction: int | None = None, ef_search: int | None = None, m: int | None = None):
952
+ """Create or recreate the HNSW vector index.
953
+
954
+ Args:
955
+ ef_construction: Candidate vertices during build (default from init).
956
+ ef_search: Candidate vertices during search (default from init).
957
+ m: Max neighbors per vertex (default from init).
958
+ """
959
+ ef_c = ef_construction or self.hnsw_ef_construction
960
+ ef_s = ef_search or self.hnsw_ef_search
961
+ m_val = m or self.hnsw_m
962
+ hnsw_metric = self.DISTANCE_METRICS[self.distance_metric]['hnsw_metric']
925
963
  with self._lock:
926
964
  try:
927
- hnsw_metric = self.DISTANCE_METRICS[self.distance_metric]['hnsw_metric']
965
+ # Drop existing index first to allow metric/param changes
966
+ self.conn.execute("DROP INDEX IF EXISTS vss_idx;")
928
967
  self.conn.execute(
929
- f"CREATE INDEX IF NOT EXISTS vss_idx ON nodes USING HNSW(vector) WITH (metric = '{hnsw_metric}');")
968
+ f"CREATE INDEX vss_idx ON nodes USING HNSW(vector) "
969
+ f"WITH (metric = '{hnsw_metric}', ef_construction = {ef_c}, ef_search = {ef_s}, M = {m_val});"
970
+ )
971
+ self._hnsw_indexed = True
972
+ logger.info(f"HNSW index created (metric={hnsw_metric}, ef_construction={ef_c}, ef_search={ef_s}, M={m_val}).")
973
+ except duckdb.Error as e:
974
+ logger.error(f"Error creating HNSW index: {e}")
975
+
976
+ def compact_index(self):
977
+ """Compact the HNSW index to reclaim space after deletions."""
978
+ with self._lock:
979
+ try:
980
+ self.conn.execute("PRAGMA hnsw_compact_index('vss_idx');")
981
+ logger.info("HNSW index compacted.")
930
982
  except duckdb.Error as e:
931
- logger.error(f"Error creating index: {e}")
983
+ logger.error(f"Error compacting HNSW index: {e}")
932
984
 
933
985
  @with_retry()
934
986
  def nearest_nodes(self, vector: list[float], limit: int) -> list[NearestNode]:
@@ -1334,9 +1386,10 @@ class GraphMemory:
1334
1386
 
1335
1387
  # Collect vector similarity results
1336
1388
  vss_results = {}
1389
+ dist_func = self.DISTANCE_METRICS[self.distance_metric]['function']
1337
1390
  vss_query = f"""
1338
1391
  SELECT id, type, properties, vector,
1339
- array_distance(vector, CAST(? AS FLOAT[{self.vector_length}])) AS distance
1392
+ {dist_func}(vector, CAST(? AS FLOAT[{self.vector_length}])) AS distance
1340
1393
  FROM nodes
1341
1394
  WHERE vector IS NOT NULL
1342
1395
  ORDER BY distance;
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "graphmemory"
7
- version = "1.2.1"
7
+ version = "1.3.0"
8
8
  description = "Graph-based memory system using DuckDB"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -2729,5 +2729,147 @@ class TestFuzzyMatching(unittest.TestCase):
2729
2729
  self.assertEqual(remaining, 2)
2730
2730
 
2731
2731
 
2732
+ class TestHNSWIndex(unittest.TestCase):
2733
+
2734
+ def test_auto_index_on_init(self):
2735
+ db = GraphMemory(database=':memory:', vector_length=3)
2736
+ self.assertTrue(db._hnsw_indexed)
2737
+ db.close()
2738
+
2739
+ def test_auto_index_disabled(self):
2740
+ db = GraphMemory(database=':memory:', vector_length=3, auto_index=False)
2741
+ self.assertFalse(db._hnsw_indexed)
2742
+ db.close()
2743
+
2744
+ def test_create_index_with_custom_params(self):
2745
+ db = GraphMemory(database=':memory:', vector_length=3, auto_index=False)
2746
+ db.create_index(ef_construction=64, ef_search=32, m=8)
2747
+ self.assertTrue(db._hnsw_indexed)
2748
+ db.close()
2749
+
2750
+ def test_create_index_uses_configured_metric(self):
2751
+ for metric in ['l2', 'cosine', 'inner_product']:
2752
+ db = GraphMemory(database=':memory:', vector_length=3, distance_metric=metric)
2753
+ self.assertTrue(db._hnsw_indexed)
2754
+ db.close()
2755
+
2756
+ def test_create_index_idempotent_recreate(self):
2757
+ db = GraphMemory(database=':memory:', vector_length=3)
2758
+ db.create_index()
2759
+ db.create_index()
2760
+ self.assertTrue(db._hnsw_indexed)
2761
+ db.close()
2762
+
2763
+ def test_set_vector_length_rebuilds_index(self):
2764
+ db = GraphMemory(database=':memory:', vector_length=3)
2765
+ self.assertTrue(db._hnsw_indexed)
2766
+ db._hnsw_indexed = False
2767
+ db.set_vector_length(5)
2768
+ self.assertTrue(db._hnsw_indexed)
2769
+ self.assertEqual(db.vector_length, 5)
2770
+ db.close()
2771
+
2772
+ def test_compact_index_no_error(self):
2773
+ db = GraphMemory(database=':memory:', vector_length=3)
2774
+ node = Node(type="Test", properties={"name": "A"}, vector=[1.0, 0.0, 0.0])
2775
+ db.insert_node(node)
2776
+ db.delete_node(node.id)
2777
+ db.compact_index()
2778
+ db.close()
2779
+
2780
+ def test_reconnect_rebuilds_index(self):
2781
+ import tempfile
2782
+ path = tempfile.mktemp(suffix='.db')
2783
+ try:
2784
+ db = GraphMemory(database=path, vector_length=3)
2785
+ db._hnsw_indexed = False
2786
+ db._reconnect()
2787
+ self.assertTrue(db._hnsw_indexed)
2788
+ db.close()
2789
+ finally:
2790
+ if os.path.exists(path):
2791
+ os.unlink(path)
2792
+
2793
+ def test_hnsw_params_stored(self):
2794
+ db = GraphMemory(database=':memory:', vector_length=3,
2795
+ hnsw_ef_construction=256, hnsw_ef_search=128, hnsw_m=32)
2796
+ self.assertEqual(db.hnsw_ef_construction, 256)
2797
+ self.assertEqual(db.hnsw_ef_search, 128)
2798
+ self.assertEqual(db.hnsw_m, 32)
2799
+ db.close()
2800
+
2801
+
2802
+ class TestHybridSearchMetric(unittest.TestCase):
2803
+
2804
+ def setUp(self):
2805
+ self.db = GraphMemory(database=':memory:', vector_length=3, distance_metric='cosine')
2806
+ self.db.insert_node(Node(type="Doc", properties={"text": "machine learning"}, vector=[1.0, 0.0, 0.0]))
2807
+ self.db.insert_node(Node(type="Doc", properties={"text": "deep learning"}, vector=[0.9, 0.1, 0.0]))
2808
+ self.db.insert_node(Node(type="Doc", properties={"text": "cooking recipes"}, vector=[0.0, 0.0, 1.0]))
2809
+
2810
+ def tearDown(self):
2811
+ self.db.close()
2812
+
2813
+ def test_hybrid_search_uses_cosine_metric(self):
2814
+ results = self.db.hybrid_search(
2815
+ query_text="learning",
2816
+ query_vector=[1.0, 0.0, 0.0],
2817
+ limit=3
2818
+ )
2819
+ self.assertGreater(len(results), 0)
2820
+ # The learning docs should score higher than cooking
2821
+ names = [r.node.properties.get("text") for r in results]
2822
+ self.assertIn("machine learning", names[:2])
2823
+
2824
+ def test_hybrid_search_inner_product(self):
2825
+ db = GraphMemory(database=':memory:', vector_length=3, distance_metric='inner_product')
2826
+ db.insert_node(Node(type="Doc", properties={"text": "similar"}, vector=[1.0, 0.0, 0.0]))
2827
+ db.insert_node(Node(type="Doc", properties={"text": "different"}, vector=[0.0, 0.0, 1.0]))
2828
+ results = db.hybrid_search(
2829
+ query_text="similar",
2830
+ query_vector=[1.0, 0.0, 0.0],
2831
+ limit=2
2832
+ )
2833
+ self.assertGreater(len(results), 0)
2834
+ db.close()
2835
+
2836
+ def test_hybrid_search_l2_metric(self):
2837
+ db = GraphMemory(database=':memory:', vector_length=3, distance_metric='l2')
2838
+ db.insert_node(Node(type="Doc", properties={"text": "near"}, vector=[0.1, 0.0, 0.0]))
2839
+ db.insert_node(Node(type="Doc", properties={"text": "far"}, vector=[9.0, 9.0, 9.0]))
2840
+ results = db.hybrid_search(
2841
+ query_text="near",
2842
+ query_vector=[0.0, 0.0, 0.0],
2843
+ limit=2
2844
+ )
2845
+ self.assertGreater(len(results), 0)
2846
+ self.assertEqual(results[0].node.properties["text"], "near")
2847
+ db.close()
2848
+
2849
+
2850
+ class TestCompactAfterDelete(unittest.TestCase):
2851
+
2852
+ def setUp(self):
2853
+ self.db = GraphMemory(database=':memory:', vector_length=3)
2854
+
2855
+ def tearDown(self):
2856
+ self.db.close()
2857
+
2858
+ def test_delete_node_compacts(self):
2859
+ node = Node(type="Test", properties={"name": "A"}, vector=[1.0, 0.0, 0.0])
2860
+ self.db.insert_node(node)
2861
+ # Should not raise — compact_index called internally
2862
+ self.db.delete_node(node.id)
2863
+ self.assertEqual(len(self.db.nodes_to_json()), 0)
2864
+
2865
+ def test_bulk_delete_nodes_compacts(self):
2866
+ n1 = Node(type="Test", properties={"name": "A"}, vector=[1.0, 0.0, 0.0])
2867
+ n2 = Node(type="Test", properties={"name": "B"}, vector=[0.0, 1.0, 0.0])
2868
+ self.db.insert_node(n1)
2869
+ self.db.insert_node(n2)
2870
+ self.db.bulk_delete_nodes([n1.id, n2.id])
2871
+ self.assertEqual(len(self.db.nodes_to_json()), 0)
2872
+
2873
+
2732
2874
  if __name__ == '__main__':
2733
2875
  unittest.main()
File without changes
File without changes