lightrag-memgraph 0.3.0__tar.gz → 0.3.2__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 (20) hide show
  1. {lightrag_memgraph-0.3.0 → lightrag_memgraph-0.3.2}/PKG-INFO +10 -5
  2. {lightrag_memgraph-0.3.0 → lightrag_memgraph-0.3.2}/README.md +7 -1
  3. {lightrag_memgraph-0.3.0 → lightrag_memgraph-0.3.2}/pyproject.toml +10 -12
  4. {lightrag_memgraph-0.3.0 → lightrag_memgraph-0.3.2}/src/lightrag_memgraph/__init__.py +9 -3
  5. {lightrag_memgraph-0.3.0 → lightrag_memgraph-0.3.2}/src/lightrag_memgraph/_connection.py +34 -16
  6. {lightrag_memgraph-0.3.0 → lightrag_memgraph-0.3.2}/src/lightrag_memgraph/core.py +32 -2
  7. {lightrag_memgraph-0.3.0 → lightrag_memgraph-0.3.2}/src/lightrag_memgraph/docstatus_impl.py +53 -33
  8. {lightrag_memgraph-0.3.0 → lightrag_memgraph-0.3.2}/src/lightrag_memgraph/kv_impl.py +23 -15
  9. {lightrag_memgraph-0.3.0 → lightrag_memgraph-0.3.2}/src/lightrag_memgraph/vector_impl.py +45 -34
  10. {lightrag_memgraph-0.3.0 → lightrag_memgraph-0.3.2}/tests/test_core.py +48 -1
  11. {lightrag_memgraph-0.3.0 → lightrag_memgraph-0.3.2}/tests/test_integration_memgraph.py +8 -4
  12. {lightrag_memgraph-0.3.0 → lightrag_memgraph-0.3.2}/.gitignore +0 -0
  13. {lightrag_memgraph-0.3.0 → lightrag_memgraph-0.3.2}/LICENSE +0 -0
  14. {lightrag_memgraph-0.3.0 → lightrag_memgraph-0.3.2}/example.py +0 -0
  15. {lightrag_memgraph-0.3.0 → lightrag_memgraph-0.3.2}/src/lightrag_memgraph/embeddings.py +0 -0
  16. {lightrag_memgraph-0.3.0 → lightrag_memgraph-0.3.2}/src/lightrag_memgraph/registry.py +0 -0
  17. {lightrag_memgraph-0.3.0 → lightrag_memgraph-0.3.2}/tests/__init__.py +0 -0
  18. {lightrag_memgraph-0.3.0 → lightrag_memgraph-0.3.2}/tests/test_embeddings.py +0 -0
  19. {lightrag_memgraph-0.3.0 → lightrag_memgraph-0.3.2}/tests/test_import.py +0 -0
  20. {lightrag_memgraph-0.3.0 → lightrag_memgraph-0.3.2}/tests/test_storages.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: lightrag-memgraph
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: LightRAG integration with Memgraph
5
5
  Project-URL: Homepage, https://github.com/memgraph/ai-toolkit
6
6
  Project-URL: Source, https://github.com/memgraph/ai-toolkit/tree/main/integrations/lightrag-memgraph
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Requires-Python: >=3.10
20
20
  Requires-Dist: anthropic>=0.40.0
21
- Requires-Dist: lightrag-hku<1.6,>=1.5
21
+ Requires-Dist: lightrag-hku==1.5.4
22
22
  Requires-Dist: memgraph-toolbox>=0.1.11
23
23
  Requires-Dist: numpy>=1.22.0
24
24
  Requires-Dist: openai>=1.0.0
@@ -26,7 +26,6 @@ Requires-Dist: voyageai>=0.2.0
26
26
  Provides-Extra: dev
27
27
  Requires-Dist: black>=26.3.1; extra == 'dev'
28
28
  Requires-Dist: isort>=5.0.0; extra == 'dev'
29
- Requires-Dist: mypy>=1.0.0; extra == 'dev'
30
29
  Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
31
30
  Requires-Dist: pytest>=9.0.3; extra == 'dev'
32
31
  Description-Content-Type: text/markdown
@@ -85,6 +84,12 @@ asyncio.run(main())
85
84
 
86
85
  See `example.py` in this repo for a full run with sample texts and graph output.
87
86
 
87
+ `initialize(**lightrag_kwargs)` forwards anything to `LightRAG(...)`, and explicit
88
+ values override the Memgraph-storage defaults — e.g. `max_parallel_insert=8`, or
89
+ `addon_params={"entity_types_guidance": "..."}` to steer entity extraction toward a
90
+ specific vocabulary. [unstructured2graph](../../unstructured2graph/README.md#entity-typing--ontology)
91
+ produces that `addon_params` from an ontology file via `Ontology.addon_params()`.
92
+
88
93
  ## Storage / persistence
89
94
 
90
95
  By default the wrapper persists **LightRAG's entire working state** into
@@ -184,7 +189,7 @@ https://platform.claude.com/docs/en/about-claude/models.
184
189
  await wrapper.initialize(
185
190
  working_dir="./lightrag_storage",
186
191
  llm_model_func=anthropic_complete,
187
- llm_model_name="claude-3-5-sonnet-20241022", # or claude-3-haiku-20240307, etc.
192
+ llm_model_name="claude-sonnet-4-20250514", # any current Claude model id
188
193
  # embedding_func omitted: defaults to Memgraph's local sentence-transformer,
189
194
  # see Embeddings above. Anthropic itself has no embeddings API.
190
195
  )
@@ -52,6 +52,12 @@ asyncio.run(main())
52
52
 
53
53
  See `example.py` in this repo for a full run with sample texts and graph output.
54
54
 
55
+ `initialize(**lightrag_kwargs)` forwards anything to `LightRAG(...)`, and explicit
56
+ values override the Memgraph-storage defaults — e.g. `max_parallel_insert=8`, or
57
+ `addon_params={"entity_types_guidance": "..."}` to steer entity extraction toward a
58
+ specific vocabulary. [unstructured2graph](../../unstructured2graph/README.md#entity-typing--ontology)
59
+ produces that `addon_params` from an ontology file via `Ontology.addon_params()`.
60
+
55
61
  ## Storage / persistence
56
62
 
57
63
  By default the wrapper persists **LightRAG's entire working state** into
@@ -151,7 +157,7 @@ https://platform.claude.com/docs/en/about-claude/models.
151
157
  await wrapper.initialize(
152
158
  working_dir="./lightrag_storage",
153
159
  llm_model_func=anthropic_complete,
154
- llm_model_name="claude-3-5-sonnet-20241022", # or claude-3-haiku-20240307, etc.
160
+ llm_model_name="claude-sonnet-4-20250514", # any current Claude model id
155
161
  # embedding_func omitted: defaults to Memgraph's local sentence-transformer,
156
162
  # see Embeddings above. Anthropic itself has no embeddings API.
157
163
  )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lightrag-memgraph"
3
- version = "0.3.0"
3
+ version = "0.3.2"
4
4
  description = "LightRAG integration with Memgraph"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -22,10 +22,15 @@ classifiers = [
22
22
  ]
23
23
 
24
24
  dependencies = [
25
- # Capped below 1.6: 1.5.4 broke this integration's DocStatusStorage once
26
- # already (see registry.py's compat guard). Bump only after verifying
27
- # compatibility with the new release.
28
- "lightrag-hku>=1.5,<1.6",
25
+ # Pinned to exactly 1.5.4: 1.5.5 (2026-07-31) added new @abstractmethod
26
+ # requirements to DocStatusStorage (a "memory-bounded scheduling" API --
27
+ # get_docs_by_statuses_page/get_docs_by_ids/get_full_docs_by_ids/
28
+ # resolve_doc_source_strict) that MemgraphDocStatusStorage doesn't
29
+ # implement yet, so LightRAG() construction raises TypeError. See #250.
30
+ # Also still capped below 1.6 for the separate reason registry.py's
31
+ # compat guard documents (lightrag.kg internals this integration
32
+ # monkey-patches). Bump only after implementing the new interface.
33
+ "lightrag-hku==1.5.4",
29
34
  "memgraph-toolbox>=0.1.11",
30
35
  "numpy>=1.22.0",
31
36
  "anthropic>=0.40.0",
@@ -39,7 +44,6 @@ dev = [
39
44
  "pytest-asyncio>=0.21.0",
40
45
  "black>=26.3.1",
41
46
  "isort>=5.0.0",
42
- "mypy>=1.0.0",
43
47
  ]
44
48
 
45
49
  [project.urls]
@@ -66,9 +70,3 @@ target-version = ['py310']
66
70
  [tool.isort]
67
71
  profile = "black"
68
72
  line_length = 88
69
-
70
- [tool.mypy]
71
- python_version = "3.10"
72
- warn_return_any = true
73
- warn_unused_configs = true
74
- disallow_untyped_defs = true
@@ -25,7 +25,7 @@ def _patch_anthropic() -> None:
25
25
  import os
26
26
  from collections.abc import AsyncIterator
27
27
  from importlib.metadata import version as get_version
28
- from typing import Any
28
+ from typing import Any, cast
29
29
 
30
30
  import lightrag.llm.anthropic as _mod
31
31
  from anthropic import (
@@ -121,8 +121,14 @@ def _patch_anthropic() -> None:
121
121
  parts.append(content)
122
122
  return "".join(parts)
123
123
 
124
- _wrapped._lightrag_memgraph_patched = True # type: ignore[attr-defined]
125
- _mod.anthropic_complete_if_cache = _wrapped
124
+ # _wrapped is a plain function object; cast(..., x) is the identity function at runtime,
125
+ # so this sets/reads the real attribute -- it's just how we spell "let me stash a marker
126
+ # attribute on a function" past a type checker that doesn't model dynamic attributes on
127
+ # function objects. Likewise, _wrapped's signature (no image_inputs=) is intentionally
128
+ # narrower than anthropic_complete_if_cache's -- it's real dynamic monkey-patching of a
129
+ # third-party module attribute, not something a structural type can express.
130
+ cast("Any", _wrapped)._lightrag_memgraph_patched = True
131
+ _mod.anthropic_complete_if_cache = cast("Any", _wrapped)
126
132
  except Exception:
127
133
  pass
128
134
 
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
  import asyncio
14
14
  import os
15
15
  import re
16
- from typing import TYPE_CHECKING
16
+ from typing import TYPE_CHECKING, cast
17
17
 
18
18
  from lightrag.utils import logger
19
19
 
@@ -21,6 +21,7 @@ from memgraph_toolbox.api.memgraph import AsyncMemgraph, memgraph_env
21
21
 
22
22
  if TYPE_CHECKING:
23
23
  from neo4j import AsyncDriver, AsyncSession
24
+ from typing_extensions import LiteralString
24
25
 
25
26
  # Keyed by event-loop id so tests spinning up a fresh loop per test don't
26
27
  # reuse a driver bound to a closed loop.
@@ -71,6 +72,19 @@ def sanitize_index_name(value: str) -> str:
71
72
  return re.sub(r"[^A-Za-z0-9_]", "_", value)
72
73
 
73
74
 
75
+ def as_literal_query(text: str) -> LiteralString:
76
+ """Assert that a dynamically-built Cypher string is safe to pass to ``AsyncSession.run``.
77
+
78
+ neo4j types ``run(query=...)`` as ``LiteralString | Query`` to steer callers away from
79
+ interpolating untrusted input into query text -- ``LiteralString`` can only be produced
80
+ from literal syntax, so the type checker can't see that a runtime f-string is safe. Every
81
+ query built in this package only interpolates our own backtick-escaped identifiers
82
+ (``sanitize_label``/``sanitize_index_name``) or passes values as bound parameters (``$id``
83
+ etc.), never raw external input concatenated into query text, so the cast is safe here.
84
+ """
85
+ return cast("LiteralString", text)
86
+
87
+
74
88
  def resolve_workspace(workspace: str | None) -> str:
75
89
  """Resolve the effective workspace for a storage instance.
76
90
 
@@ -84,7 +98,7 @@ def resolve_workspace(workspace: str | None) -> str:
84
98
  async def create_index(session: AsyncSession, *, label: str, prop: str, workspace: str) -> None:
85
99
  """CREATE INDEX on :`label`(prop), swallowing "already exists" (Memgraph has no IF NOT EXISTS)."""
86
100
  try:
87
- await (await session.run(f"CREATE INDEX ON :`{label}`({prop})")).consume()
101
+ await (await session.run(as_literal_query(f"CREATE INDEX ON :`{label}`({prop})"))).consume()
88
102
  except Exception as e: # index may already exist, which is not an error
89
103
  logger.warning(f"[{workspace}] Index creation on :`{label}`({prop}) may have failed or already exists: {e}")
90
104
 
@@ -106,7 +120,7 @@ class MemgraphCrudMixin:
106
120
  async def is_empty(self) -> bool:
107
121
  driver = await get_driver()
108
122
  async with driver.session(database=get_database(), default_access_mode="READ") as session:
109
- result = await session.run(f"MATCH (n:`{self._label}`) RETURN n LIMIT 1")
123
+ result = await session.run(as_literal_query(f"MATCH (n:`{self._label}`) RETURN n LIMIT 1"))
110
124
  record = await result.single()
111
125
  await result.consume()
112
126
  return record is None
@@ -118,13 +132,15 @@ class MemgraphCrudMixin:
118
132
  driver = await get_driver()
119
133
  async with driver.session(database=get_database(), default_access_mode="READ") as session:
120
134
  result = await session.run(
121
- f"""
122
- UNWIND $keys AS k
123
- OPTIONAL MATCH (n:`{self._label}` {{id: k}})
124
- WITH k, n
125
- WHERE n IS NULL
126
- RETURN k
127
- """,
135
+ as_literal_query(
136
+ f"""
137
+ UNWIND $keys AS k
138
+ OPTIONAL MATCH (n:`{self._label}` {{id: k}})
139
+ WITH k, n
140
+ WHERE n IS NULL
141
+ RETURN k
142
+ """
143
+ ),
128
144
  keys=list(keys),
129
145
  )
130
146
  missing = {record["k"] async for record in result}
@@ -138,11 +154,13 @@ class MemgraphCrudMixin:
138
154
  async with driver.session(database=get_database()) as session:
139
155
  await (
140
156
  await session.run(
141
- f"""
142
- UNWIND $ids AS target_id
143
- MATCH (n:`{self._label}` {{id: target_id}})
144
- DETACH DELETE n
145
- """,
157
+ as_literal_query(
158
+ f"""
159
+ UNWIND $ids AS target_id
160
+ MATCH (n:`{self._label}` {{id: target_id}})
161
+ DETACH DELETE n
162
+ """
163
+ ),
146
164
  ids=list(ids),
147
165
  )
148
166
  ).consume()
@@ -151,7 +169,7 @@ class MemgraphCrudMixin:
151
169
  try:
152
170
  driver = await get_driver()
153
171
  async with driver.session(database=get_database()) as session:
154
- await (await session.run(f"MATCH (n:`{self._label}`) DETACH DELETE n")).consume()
172
+ await (await session.run(as_literal_query(f"MATCH (n:`{self._label}`) DETACH DELETE n"))).consume()
155
173
  logger.info(f"[{self.workspace}] Dropped Memgraph storage for {self.namespace}")
156
174
  return {"status": "success", "message": "data dropped"}
157
175
  except Exception as e:
@@ -2,7 +2,7 @@ import logging
2
2
  import os
3
3
 
4
4
  from lightrag import LightRAG
5
- from lightrag.kg.shared_storage import initialize_pipeline_status
5
+ from lightrag.kg.shared_storage import finalize_share_data, initialize_pipeline_status
6
6
  from lightrag.llm.openai import gpt_4o_mini_complete, openai_embed
7
7
  from lightrag.utils import logger, setup_logger
8
8
 
@@ -101,6 +101,20 @@ class MemgraphLightRAGWrapper:
101
101
  raise RuntimeError("LightRAG not initialized. Call initialize() first.")
102
102
  return self.rag
103
103
 
104
+ @property
105
+ def workspace(self) -> str:
106
+ """The Memgraph label LightRAG writes every extracted entity under.
107
+
108
+ Exposed directly (rather than making callers reach through
109
+ get_lightrag().chunk_entity_relation_graph) since it's the one piece
110
+ of LightRAG's internal state consumers outside this package actually
111
+ need -- e.g. unstructured2graph's LightRAGBackend.workspace_label.
112
+
113
+ Raises:
114
+ RuntimeError: if initialize() hasn't been called yet.
115
+ """
116
+ return self.get_lightrag().chunk_entity_relation_graph.workspace
117
+
104
118
  # https://github.com/HKUDS/LightRAG/blob/main/lightrag/lightrag.py
105
119
  async def ainsert(self, **kwargs) -> None:
106
120
  """
@@ -114,6 +128,22 @@ class MemgraphLightRAGWrapper:
114
128
  await self.rag.ainsert(**kwargs)
115
129
 
116
130
  async def afinalize(self) -> None:
131
+ """
132
+ Finalize storages and reset LightRAG's process-global shared-storage
133
+ state (locks, shared dicts). Without the latter reset, a second
134
+ LightRAG instance created later in the same process reuses asyncio
135
+ locks bound to this instance's (by-then-closed) event loop and raises
136
+ "bound to a different event loop" -- e.g. two real-LightRAG tests
137
+ run back-to-back under pytest-asyncio's function-scoped event loop.
138
+
139
+ The reset runs in a finally block: if finalize_storages() raises
140
+ (e.g. a transient network error), the shared-storage state would
141
+ otherwise stay registered as initialized and break every subsequent
142
+ LightRAG instance in this process, not just this one's cleanup.
143
+ """
117
144
  if self.rag is None:
118
145
  raise RuntimeError("LightRAG not initialized. Call initialize() first.")
119
- await self.rag.finalize_storages()
146
+ try:
147
+ await self.rag.finalize_storages()
148
+ finally:
149
+ finalize_share_data()
@@ -11,13 +11,14 @@ from __future__ import annotations
11
11
  import asyncio
12
12
  import json
13
13
  from dataclasses import dataclass
14
- from typing import Any, final
14
+ from typing import Any, cast, final
15
15
 
16
16
  from lightrag.base import DocProcessingStatus, DocStatus, DocStatusStorage
17
17
  from lightrag.utils import logger
18
18
 
19
19
  from ._connection import (
20
20
  MemgraphCrudMixin,
21
+ as_literal_query,
21
22
  close_driver,
22
23
  create_index,
23
24
  get_database,
@@ -74,7 +75,7 @@ class MemgraphDocStatusStorage(MemgraphCrudMixin, DocStatusStorage):
74
75
  driver = await get_driver()
75
76
  async with driver.session(database=get_database(), default_access_mode="READ") as session:
76
77
  result = await session.run(
77
- f"MATCH (n:`{self._label}` {{id: $id}}) RETURN n.data AS data",
78
+ as_literal_query(f"MATCH (n:`{self._label}` {{id: $id}}) RETURN n.data AS data"),
78
79
  id=id,
79
80
  )
80
81
  record = await result.single()
@@ -90,11 +91,13 @@ class MemgraphDocStatusStorage(MemgraphCrudMixin, DocStatusStorage):
90
91
  driver = await get_driver()
91
92
  async with driver.session(database=get_database(), default_access_mode="READ") as session:
92
93
  result = await session.run(
93
- f"""
94
- UNWIND $ids AS target_id
95
- OPTIONAL MATCH (n:`{self._label}` {{id: target_id}})
96
- RETURN target_id AS id, n.data AS data
97
- """,
94
+ as_literal_query(
95
+ f"""
96
+ UNWIND $ids AS target_id
97
+ OPTIONAL MATCH (n:`{self._label}` {{id: target_id}})
98
+ RETURN target_id AS id, n.data AS data
99
+ """
100
+ ),
98
101
  ids=list(ids),
99
102
  )
100
103
  found: dict[str, str] = {}
@@ -102,7 +105,10 @@ class MemgraphDocStatusStorage(MemgraphCrudMixin, DocStatusStorage):
102
105
  if record["data"] is not None:
103
106
  found[record["id"]] = record["data"]
104
107
  await result.consume()
105
- return [json.loads(found[i]) if i in found else None for i in ids]
108
+ # LightRAG's own BaseKVStorage.get_by_ids declares -> list[dict[str, Any]], but its
109
+ # reference JsonKVStorage impl (and this one, to match) legitimately puts None in the
110
+ # slot of any id that does not exist -- the upstream annotation itself is imprecise.
111
+ return cast("list[dict[str, Any]]", [json.loads(found[i]) if i in found else None for i in ids])
106
112
 
107
113
  async def upsert(self, data: dict[str, dict[str, Any]]) -> None:
108
114
  if not data:
@@ -123,11 +129,13 @@ class MemgraphDocStatusStorage(MemgraphCrudMixin, DocStatusStorage):
123
129
  async with driver.session(database=get_database()) as session:
124
130
  await (
125
131
  await session.run(
126
- f"""
127
- UNWIND $entries AS e
128
- MERGE (n:`{self._label}` {{id: e.id}})
129
- SET n.data = e.data, n += e.indexed
130
- """,
132
+ as_literal_query(
133
+ f"""
134
+ UNWIND $entries AS e
135
+ MERGE (n:`{self._label}` {{id: e.id}})
136
+ SET n.data = e.data, n += e.indexed
137
+ """
138
+ ),
131
139
  entries=entries,
132
140
  )
133
141
  ).consume()
@@ -141,7 +149,9 @@ class MemgraphDocStatusStorage(MemgraphCrudMixin, DocStatusStorage):
141
149
  counts = {status.value: 0 for status in DocStatus}
142
150
  driver = await get_driver()
143
151
  async with driver.session(database=get_database(), default_access_mode="READ") as session:
144
- result = await session.run(f"MATCH (n:`{self._label}`) RETURN n.status AS status, count(n) AS cnt")
152
+ result = await session.run(
153
+ as_literal_query(f"MATCH (n:`{self._label}`) RETURN n.status AS status, count(n) AS cnt")
154
+ )
145
155
  async for record in result:
146
156
  if record["status"] in counts:
147
157
  counts[record["status"]] = record["cnt"]
@@ -164,11 +174,13 @@ class MemgraphDocStatusStorage(MemgraphCrudMixin, DocStatusStorage):
164
174
  driver = await get_driver()
165
175
  async with driver.session(database=get_database(), default_access_mode="READ") as session:
166
176
  result = await session.run(
167
- f"""
168
- MATCH (n:`{self._label}`)
169
- WHERE n.status IN $statuses
170
- RETURN n.id AS id, n.data AS data
171
- """,
177
+ as_literal_query(
178
+ f"""
179
+ MATCH (n:`{self._label}`)
180
+ WHERE n.status IN $statuses
181
+ RETURN n.id AS id, n.data AS data
182
+ """
183
+ ),
172
184
  statuses=status_values,
173
185
  )
174
186
  records = [record async for record in result]
@@ -179,10 +191,12 @@ class MemgraphDocStatusStorage(MemgraphCrudMixin, DocStatusStorage):
179
191
  driver = await get_driver()
180
192
  async with driver.session(database=get_database(), default_access_mode="READ") as session:
181
193
  result = await session.run(
182
- f"""
183
- MATCH (n:`{self._label}` {{track_id: $track_id}})
184
- RETURN n.id AS id, n.data AS data
185
- """,
194
+ as_literal_query(
195
+ f"""
196
+ MATCH (n:`{self._label}` {{track_id: $track_id}})
197
+ RETURN n.id AS id, n.data AS data
198
+ """
199
+ ),
186
200
  track_id=track_id,
187
201
  )
188
202
  records = [record async for record in result]
@@ -237,7 +251,7 @@ class MemgraphDocStatusStorage(MemgraphCrudMixin, DocStatusStorage):
237
251
  async def _count() -> int:
238
252
  async with driver.session(database=get_database(), default_access_mode="READ") as session:
239
253
  result = await session.run(
240
- f"MATCH (n:`{self._label}`) {where} RETURN count(n) AS total",
254
+ as_literal_query(f"MATCH (n:`{self._label}`) {where} RETURN count(n) AS total"),
241
255
  **filter_params,
242
256
  )
243
257
  record = await result.single()
@@ -247,12 +261,14 @@ class MemgraphDocStatusStorage(MemgraphCrudMixin, DocStatusStorage):
247
261
  async def _page() -> list:
248
262
  async with driver.session(database=get_database(), default_access_mode="READ") as session:
249
263
  result = await session.run(
250
- f"""
251
- MATCH (n:`{self._label}`) {where}
252
- RETURN n.id AS id, n.data AS data
253
- ORDER BY {sort_expr} {order}
254
- SKIP $skip LIMIT $limit
255
- """,
264
+ as_literal_query(
265
+ f"""
266
+ MATCH (n:`{self._label}`) {where}
267
+ RETURN n.id AS id, n.data AS data
268
+ ORDER BY {sort_expr} {order}
269
+ SKIP $skip LIMIT $limit
270
+ """
271
+ ),
256
272
  **params,
257
273
  )
258
274
  records = [record async for record in result]
@@ -277,7 +293,7 @@ class MemgraphDocStatusStorage(MemgraphCrudMixin, DocStatusStorage):
277
293
  driver = await get_driver()
278
294
  async with driver.session(database=get_database(), default_access_mode="READ") as session:
279
295
  result = await session.run(
280
- f"MATCH (n:`{self._label}` {{file_path: $file_path}}) RETURN n.data AS data LIMIT 1",
296
+ as_literal_query(f"MATCH (n:`{self._label}` {{file_path: $file_path}}) RETURN n.data AS data LIMIT 1"),
281
297
  file_path=file_path,
282
298
  )
283
299
  record = await result.single()
@@ -293,7 +309,9 @@ class MemgraphDocStatusStorage(MemgraphCrudMixin, DocStatusStorage):
293
309
  driver = await get_driver()
294
310
  async with driver.session(database=get_database(), default_access_mode="READ") as session:
295
311
  result = await session.run(
296
- f"MATCH (n:`{self._label}` {{file_path: $basename}}) RETURN n.id AS id, n.data AS data LIMIT 1",
312
+ as_literal_query(
313
+ f"MATCH (n:`{self._label}` {{file_path: $basename}}) RETURN n.id AS id, n.data AS data LIMIT 1"
314
+ ),
297
315
  basename=basename,
298
316
  )
299
317
  record = await result.single()
@@ -309,7 +327,9 @@ class MemgraphDocStatusStorage(MemgraphCrudMixin, DocStatusStorage):
309
327
  driver = await get_driver()
310
328
  async with driver.session(database=get_database(), default_access_mode="READ") as session:
311
329
  result = await session.run(
312
- f"MATCH (n:`{self._label}` {{content_hash: $content_hash}}) RETURN n.id AS id, n.data AS data LIMIT 1",
330
+ as_literal_query(
331
+ f"MATCH (n:`{self._label}` {{content_hash: $content_hash}}) RETURN n.id AS id, n.data AS data LIMIT 1"
332
+ ),
313
333
  content_hash=content_hash,
314
334
  )
315
335
  record = await result.single()
@@ -11,13 +11,14 @@ from __future__ import annotations
11
11
  import json
12
12
  import time
13
13
  from dataclasses import dataclass
14
- from typing import Any, final
14
+ from typing import Any, cast, final
15
15
 
16
16
  from lightrag.base import BaseKVStorage
17
17
  from lightrag.utils import logger
18
18
 
19
19
  from ._connection import (
20
20
  MemgraphCrudMixin,
21
+ as_literal_query,
21
22
  close_driver,
22
23
  create_index,
23
24
  get_database,
@@ -66,7 +67,7 @@ class MemgraphKVStorage(MemgraphCrudMixin, BaseKVStorage):
66
67
  driver = await get_driver()
67
68
  async with driver.session(database=get_database(), default_access_mode="READ") as session:
68
69
  result = await session.run(
69
- f"MATCH (n:`{self._label}` {{id: $id}}) RETURN n.data AS data",
70
+ as_literal_query(f"MATCH (n:`{self._label}` {{id: $id}}) RETURN n.data AS data"),
70
71
  id=id,
71
72
  )
72
73
  record = await result.single()
@@ -80,11 +81,13 @@ class MemgraphKVStorage(MemgraphCrudMixin, BaseKVStorage):
80
81
  driver = await get_driver()
81
82
  async with driver.session(database=get_database(), default_access_mode="READ") as session:
82
83
  result = await session.run(
83
- f"""
84
- UNWIND $ids AS target_id
85
- OPTIONAL MATCH (n:`{self._label}` {{id: target_id}})
86
- RETURN target_id AS id, n.data AS data
87
- """,
84
+ as_literal_query(
85
+ f"""
86
+ UNWIND $ids AS target_id
87
+ OPTIONAL MATCH (n:`{self._label}` {{id: target_id}})
88
+ RETURN target_id AS id, n.data AS data
89
+ """
90
+ ),
88
91
  ids=list(ids),
89
92
  )
90
93
  found: dict[str, str] = {}
@@ -92,7 +95,10 @@ class MemgraphKVStorage(MemgraphCrudMixin, BaseKVStorage):
92
95
  if record["data"] is not None:
93
96
  found[record["id"]] = record["data"]
94
97
  await result.consume()
95
- return [self._decode(i, found.get(i)) for i in ids]
98
+ # LightRAG's own BaseKVStorage.get_by_ids declares -> list[dict[str, Any]], but its
99
+ # reference JsonKVStorage impl (and this one, to match) legitimately puts None in the
100
+ # slot of any id that doesn't exist -- the upstream annotation itself is imprecise.
101
+ return cast("list[dict[str, Any]]", [self._decode(i, found.get(i)) for i in ids])
96
102
 
97
103
  async def upsert(self, data: dict[str, dict[str, Any]]) -> None:
98
104
  if not data:
@@ -126,13 +132,15 @@ class MemgraphKVStorage(MemgraphCrudMixin, BaseKVStorage):
126
132
  async with driver.session(database=get_database()) as session:
127
133
  await (
128
134
  await session.run(
129
- f"""
130
- UNWIND $entries AS e
131
- MERGE (n:`{self._label}` {{id: e.id}})
132
- ON CREATE SET n.data = e.new_data, n.created_at = e.ts
133
- ON MATCH SET n.data = e.update_data
134
- SET n.updated_at = e.ts
135
- """,
135
+ as_literal_query(
136
+ f"""
137
+ UNWIND $entries AS e
138
+ MERGE (n:`{self._label}` {{id: e.id}})
139
+ ON CREATE SET n.data = e.new_data, n.created_at = e.ts
140
+ ON MATCH SET n.data = e.update_data
141
+ SET n.updated_at = e.ts
142
+ """
143
+ ),
136
144
  entries=entries,
137
145
  )
138
146
  ).consume()
@@ -19,6 +19,7 @@ from lightrag.base import BaseVectorStorage
19
19
  from lightrag.utils import compute_mdhash_id, logger
20
20
 
21
21
  from ._connection import (
22
+ as_literal_query,
22
23
  close_driver,
23
24
  create_index,
24
25
  get_database,
@@ -130,18 +131,20 @@ class MemgraphVectorStorage(BaseVectorStorage):
130
131
  await self._ensure_vector_index(session)
131
132
  await (
132
133
  await session.run(
133
- f"""
134
- UNWIND $entries AS e
135
- MERGE (n:`{self._label}` {{id: e.id}})
136
- ON CREATE SET n.created_at = e.ts
137
- SET n += e.props, n.embedding = e.embedding, n.updated_at = e.ts
138
- """,
134
+ as_literal_query(
135
+ f"""
136
+ UNWIND $entries AS e
137
+ MERGE (n:`{self._label}` {{id: e.id}})
138
+ ON CREATE SET n.created_at = e.ts
139
+ SET n += e.props, n.embedding = e.embedding, n.updated_at = e.ts
140
+ """
141
+ ),
139
142
  entries=entries,
140
143
  )
141
144
  ).consume()
142
145
  logger.debug(f"[{self.workspace}] Upserted {len(entries)} vectors to {self.namespace}")
143
146
 
144
- async def query(self, query: str, top_k: int, query_embedding: list[float] = None) -> list[dict[str, Any]]:
147
+ async def query(self, query: str, top_k: int, query_embedding: list[float] | None = None) -> list[dict[str, Any]]:
145
148
  """Vector-search this namespace and return up to ``top_k`` live results.
146
149
 
147
150
  By design, Memgraph's native vector index operates at ``READ_UNCOMMITTED``
@@ -176,15 +179,17 @@ class MemgraphVectorStorage(BaseVectorStorage):
176
179
  # dropping it.
177
180
  # Index name is a sanitized string literal (not accepted as a query param).
178
181
  result = await session.run(
179
- f"""
180
- CALL vector_search.search("{self._index_name}", $top_k, $embedding)
181
- YIELD node, similarity
182
- WITH collect({{node: node, similarity: similarity}}) AS cand_pairs
183
- UNWIND cand_pairs AS cp
184
- OPTIONAL MATCH (live:`{self._label}`) WHERE live = cp.node
185
- RETURN live.id AS id, cp.similarity AS similarity,
186
- CASE WHEN live IS NULL THEN NULL ELSE properties(live) END AS props
187
- """,
182
+ as_literal_query(
183
+ f"""
184
+ CALL vector_search.search("{self._index_name}", $top_k, $embedding)
185
+ YIELD node, similarity
186
+ WITH collect({{node: node, similarity: similarity}}) AS cand_pairs
187
+ UNWIND cand_pairs AS cp
188
+ OPTIONAL MATCH (live:`{self._label}`) WHERE live = cp.node
189
+ RETURN live.id AS id, cp.similarity AS similarity,
190
+ CASE WHEN live IS NULL THEN NULL ELSE properties(live) END AS props
191
+ """
192
+ ),
188
193
  top_k=int(top_k),
189
194
  embedding=embedding,
190
195
  )
@@ -225,7 +230,7 @@ class MemgraphVectorStorage(BaseVectorStorage):
225
230
  driver = await get_driver()
226
231
  async with driver.session(database=get_database(), default_access_mode="READ") as session:
227
232
  result = await session.run(
228
- f"MATCH (n:`{self._label}` {{id: $id}}) RETURN properties(n) AS props",
233
+ as_literal_query(f"MATCH (n:`{self._label}` {{id: $id}}) RETURN properties(n) AS props"),
229
234
  id=id,
230
235
  )
231
236
  record = await result.single()
@@ -243,11 +248,13 @@ class MemgraphVectorStorage(BaseVectorStorage):
243
248
  driver = await get_driver()
244
249
  async with driver.session(database=get_database(), default_access_mode="READ") as session:
245
250
  result = await session.run(
246
- f"""
247
- MATCH (n:`{self._label}`)
248
- WHERE n.id IN $ids
249
- RETURN n.id AS id, properties(n) AS props
250
- """,
251
+ as_literal_query(
252
+ f"""
253
+ MATCH (n:`{self._label}`)
254
+ WHERE n.id IN $ids
255
+ RETURN n.id AS id, properties(n) AS props
256
+ """
257
+ ),
251
258
  ids=list(ids),
252
259
  )
253
260
  records = [record async for record in result]
@@ -266,11 +273,13 @@ class MemgraphVectorStorage(BaseVectorStorage):
266
273
  driver = await get_driver()
267
274
  async with driver.session(database=get_database(), default_access_mode="READ") as session:
268
275
  result = await session.run(
269
- f"""
270
- MATCH (n:`{self._label}`)
271
- WHERE n.id IN $ids AND n.embedding IS NOT NULL
272
- RETURN n.id AS id, n.embedding AS embedding
273
- """,
276
+ as_literal_query(
277
+ f"""
278
+ MATCH (n:`{self._label}`)
279
+ WHERE n.id IN $ids AND n.embedding IS NOT NULL
280
+ RETURN n.id AS id, n.embedding AS embedding
281
+ """
282
+ ),
274
283
  ids=list(ids),
275
284
  )
276
285
  vectors = {record["id"]: list(record["embedding"]) async for record in result}
@@ -291,12 +300,14 @@ class MemgraphVectorStorage(BaseVectorStorage):
291
300
  async with driver.session(database=get_database()) as session:
292
301
  await (
293
302
  await session.run(
294
- f"""
295
- MATCH (n:`{self._label}`)
296
- {where_clause}
297
- SET n.embedding = NULL
298
- DETACH DELETE n
299
- """,
303
+ as_literal_query(
304
+ f"""
305
+ MATCH (n:`{self._label}`)
306
+ {where_clause}
307
+ SET n.embedding = NULL
308
+ DETACH DELETE n
309
+ """
310
+ ),
300
311
  **params,
301
312
  )
302
313
  ).consume()
@@ -323,7 +334,7 @@ class MemgraphVectorStorage(BaseVectorStorage):
323
334
  driver = await get_driver()
324
335
  async with driver.session(database=get_database()) as session:
325
336
  try:
326
- await (await session.run(f"DROP VECTOR INDEX {self._index_name}")).consume()
337
+ await (await session.run(as_literal_query(f"DROP VECTOR INDEX {self._index_name}"))).consume()
327
338
  except Exception as e:
328
339
  logger.debug(f"[{self.workspace}] Dropping vector index {self._index_name} skipped: {e}")
329
340
  self._index_ready = False
@@ -9,12 +9,13 @@ runs an insert/query).
9
9
  from __future__ import annotations
10
10
 
11
11
  import os
12
+ from unittest.mock import AsyncMock, MagicMock, patch
12
13
 
13
14
  import pytest
14
15
  from lightrag.llm.openai import gpt_4o_mini_complete, openai_embed
15
16
  from lightrag.utils import logger as lightrag_logger
16
17
 
17
- from lightrag_memgraph.core import _apply_lightrag_defaults, _bridge_lightrag_env_names
18
+ from lightrag_memgraph.core import MemgraphLightRAGWrapper, _apply_lightrag_defaults, _bridge_lightrag_env_names
18
19
  from lightrag_memgraph.embeddings import memgraph_sentence_embed
19
20
 
20
21
  _MEMGRAPH_ENV_NAMES = ("MEMGRAPH_URL", "MEMGRAPH_URI", "MEMGRAPH_USER", "MEMGRAPH_USERNAME")
@@ -136,3 +137,49 @@ def test_no_openai_key_required_with_non_openai_llm_and_embedding_default(monkey
136
137
  kwargs = {"llm_model_func": lambda *a, **kw: None}
137
138
  _apply_lightrag_defaults(kwargs) # must not raise
138
139
  assert kwargs["embedding_func"] is memgraph_sentence_embed
140
+
141
+
142
+ @pytest.mark.asyncio
143
+ async def test_afinalize_resets_shared_data_on_success():
144
+ wrapper = MemgraphLightRAGWrapper()
145
+ wrapper.rag = AsyncMock()
146
+
147
+ with patch("lightrag_memgraph.core.finalize_share_data") as mock_finalize_share_data:
148
+ await wrapper.afinalize()
149
+
150
+ wrapper.rag.finalize_storages.assert_awaited_once()
151
+ mock_finalize_share_data.assert_called_once()
152
+
153
+
154
+ @pytest.mark.asyncio
155
+ async def test_afinalize_resets_shared_data_even_if_finalize_storages_raises():
156
+ """A transient error in finalize_storages() must not leave the
157
+ process-global shared-storage state stuck as initialized -- that would
158
+ break every subsequent LightRAG instance in this process, not just this
159
+ one's cleanup."""
160
+ wrapper = MemgraphLightRAGWrapper()
161
+ wrapper.rag = AsyncMock()
162
+ wrapper.rag.finalize_storages.side_effect = RuntimeError("transient network error")
163
+
164
+ with (
165
+ patch("lightrag_memgraph.core.finalize_share_data") as mock_finalize_share_data,
166
+ pytest.raises(RuntimeError, match="transient network error"),
167
+ ):
168
+ await wrapper.afinalize()
169
+
170
+ mock_finalize_share_data.assert_called_once()
171
+
172
+
173
+ def test_workspace_reads_chunk_entity_relation_graph_workspace():
174
+ wrapper = MemgraphLightRAGWrapper()
175
+ wrapper.rag = MagicMock()
176
+ wrapper.rag.chunk_entity_relation_graph.workspace = "tenant-42"
177
+
178
+ assert wrapper.workspace == "tenant-42"
179
+
180
+
181
+ def test_workspace_raises_before_initialize():
182
+ wrapper = MemgraphLightRAGWrapper()
183
+
184
+ with pytest.raises(RuntimeError, match="not initialized"):
185
+ _ = wrapper.workspace
@@ -35,6 +35,7 @@ from lightrag.utils import EmbeddingFunc
35
35
 
36
36
  from lightrag_memgraph import MemgraphLightRAGWrapper
37
37
  from lightrag_memgraph._connection import (
38
+ as_literal_query,
38
39
  close_driver,
39
40
  get_database,
40
41
  get_driver,
@@ -493,7 +494,8 @@ async def test_vector_query_logs_and_excludes_stale_candidates(
493
494
  # DETACH DELETE leaves a stale entry in the native vector index.
494
495
  driver = await get_driver()
495
496
  async with driver.session(database=get_database()) as session:
496
- await (await session.run(f"MATCH (n:`{store._label}` {{id: 'v-cat'}}) DETACH DELETE n")).consume()
497
+ query = as_literal_query(f"MATCH (n:`{store._label}` {{id: 'v-cat'}}) DETACH DELETE n")
498
+ await (await session.run(query)).consume()
497
499
 
498
500
  # lightrag's logger has propagate=False, so caplog.at_level (which only
499
501
  # relies on root-logger propagation) never sees its records; attach the
@@ -612,9 +614,11 @@ async def test_wrapper_defaults_embedding_func_to_working_memgraph_sentence_embe
612
614
  # underlying callable, unwrapping the EmbeddingFunc nesting), so identity
613
615
  # doesn't survive the round trip -- the declared model/dim and, most
614
616
  # importantly, actual behaviour do.
615
- assert rag.embedding_func.model_name == memgraph_sentence_embed.model_name
616
- assert rag.embedding_func.embedding_dim == DEFAULT_EMBEDDING_DIM
617
- vectors = await rag.embedding_func(["hello world", "graph databases are fast"])
617
+ embedding_func = rag.embedding_func
618
+ assert embedding_func is not None
619
+ assert embedding_func.model_name == memgraph_sentence_embed.model_name
620
+ assert embedding_func.embedding_dim == DEFAULT_EMBEDDING_DIM
621
+ vectors = await embedding_func(["hello world", "graph databases are fast"])
618
622
  assert vectors.shape == (2, DEFAULT_EMBEDDING_DIM)
619
623
  finally:
620
624
  if wrapper.rag is not None: