lightrag-agensgraph 0.1.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.
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: lightrag-agensgraph
3
+ Version: 0.1.0
4
+ Summary: Lightrag graph store implementation for agensgraph
5
+ Keywords: lightrag,agensgraph,graph store,integration
6
+ Author: Muhammad Taha Naveed
7
+ Author-email: skaisw@skaiworldwide.com
8
+ Requires-Python: >=3.9,<4.0
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.9
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Dist: lightrag-hku (>=1.3.9)
17
+ Requires-Dist: psycopg[binary,pool] (>=3.1.0)
18
+ Project-URL: Homepage, https://github.com/skaiworldwide-oss/agensgraph-ai/tree/main/lightrag
19
+ Project-URL: Repository, https://github.com/skaiworldwide-oss/agensgraph-ai/tree/main/lightrag
20
+ Description-Content-Type: text/markdown
21
+
22
+ # LightRag Knowledge Graph Integration: AgensGraph
23
+
24
+ This plugin adds support for storing and querying knowledge graphs in [AgensGraph](https://github.com/skaiworldwide-oss/agensgraph) with [LightRAG](https://lightrag.github.io/).
25
+
26
+ ## Build
27
+
28
+ You can use the following commands to build the plugin from source.
29
+
30
+ ```bash
31
+ pip install poetry
32
+ poetry install
33
+ poetry build
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```
39
+ pip install lightrag-hku lightrag_agensgraph-0.1.0-py3-none-any.whl
40
+ ```
41
+
42
+ ```python
43
+ from lightrag import LightRag
44
+ import lighrag_agensgraph
45
+
46
+ os.environ["AGENSGRAPH_DB"] = ""
47
+ os.environ["AGENSGRAPH_USER"] = ""
48
+ os.environ["AGENSGRAPH_PASSWORD"] = ""
49
+ os.environ["AGENSGRAPH_HOST"] = ""
50
+ os.environ["AGENSGRAPH_PORT"] = ""
51
+ os.environ["AGENSGRAPH_GRAPHNAME"] = ""
52
+
53
+ rag = LightRAG(
54
+ graph_storage="AgensgraphStorage",
55
+ ...
56
+ )
57
+ ```
58
+
59
+ See [examples](./examples/) and [tests](./tests/) for more details on how to use the plugin with LightRAG.
60
+
@@ -0,0 +1,38 @@
1
+ # LightRag Knowledge Graph Integration: AgensGraph
2
+
3
+ This plugin adds support for storing and querying knowledge graphs in [AgensGraph](https://github.com/skaiworldwide-oss/agensgraph) with [LightRAG](https://lightrag.github.io/).
4
+
5
+ ## Build
6
+
7
+ You can use the following commands to build the plugin from source.
8
+
9
+ ```bash
10
+ pip install poetry
11
+ poetry install
12
+ poetry build
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```
18
+ pip install lightrag-hku lightrag_agensgraph-0.1.0-py3-none-any.whl
19
+ ```
20
+
21
+ ```python
22
+ from lightrag import LightRag
23
+ import lighrag_agensgraph
24
+
25
+ os.environ["AGENSGRAPH_DB"] = ""
26
+ os.environ["AGENSGRAPH_USER"] = ""
27
+ os.environ["AGENSGRAPH_PASSWORD"] = ""
28
+ os.environ["AGENSGRAPH_HOST"] = ""
29
+ os.environ["AGENSGRAPH_PORT"] = ""
30
+ os.environ["AGENSGRAPH_GRAPHNAME"] = ""
31
+
32
+ rag = LightRAG(
33
+ graph_storage="AgensgraphStorage",
34
+ ...
35
+ )
36
+ ```
37
+
38
+ See [examples](./examples/) and [tests](./tests/) for more details on how to use the plugin with LightRAG.
@@ -0,0 +1,6 @@
1
+ import sys, types
2
+ from lightrag_agensgraph.kg.agensgraph_impl import AgensgraphStorage
3
+ import lightrag.kg
4
+
5
+ lightrag.kg.STORAGE_IMPLEMENTATIONS["GRAPH_STORAGE"]["implementations"].append("AgensgraphStorage")
6
+ lightrag.kg.STORAGES["AgensgraphStorage"] = "lightrag_agensgraph.kg.agensgraph_impl"
@@ -0,0 +1,1075 @@
1
+ import asyncio
2
+ import inspect
3
+ import re, json
4
+ import os
5
+ import sys
6
+ from contextlib import asynccontextmanager
7
+ from dataclasses import dataclass
8
+ from typing import Any, Dict, List, NamedTuple, Optional, Union, final
9
+ import pipmaster as pm
10
+ from lightrag.types import KnowledgeGraph, KnowledgeGraphNode, KnowledgeGraphEdge
11
+ from typing import Any, List, Dict, Optional, Tuple, NamedTuple, Pattern
12
+
13
+ from tenacity import (
14
+ retry,
15
+ retry_if_exception_type,
16
+ stop_after_attempt,
17
+ wait_exponential,
18
+ )
19
+
20
+ from lightrag.utils import logger
21
+ from lightrag.base import BaseGraphStorage
22
+ try:
23
+ from lightrag.constants import GRAPH_FIELD_SEP
24
+ except ImportError:
25
+ from lightrag.prompt import GRAPH_FIELD_SEP
26
+
27
+ if sys.platform.startswith("win"):
28
+ import asyncio.windows_events
29
+
30
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
31
+
32
+ import psycopg
33
+ from psycopg import sql
34
+ from psycopg.types.json import Jsonb
35
+ from psycopg.rows import namedtuple_row
36
+ from psycopg_pool import AsyncConnectionPool, PoolTimeout
37
+
38
+ class AgensgraphQueryException(Exception):
39
+ """Exception for the Agensgraph queries."""
40
+
41
+ def __init__(self, exception: Union[str, Dict]) -> None:
42
+ if isinstance(exception, dict):
43
+ self.message = exception["message"] if "message" in exception else "unknown"
44
+ self.details = exception["details"] if "details" in exception else "unknown"
45
+ else:
46
+ self.message = exception
47
+ self.details = "unknown"
48
+
49
+ def get_message(self) -> str:
50
+ return self.message
51
+
52
+ def get_details(self) -> Any:
53
+ return self.details
54
+
55
+
56
+ @final
57
+ @dataclass
58
+ class AgensgraphStorage(BaseGraphStorage):
59
+ vertex_regex: Pattern = re.compile(r"(\w+)\[(\d+\.\d+)\](\{.*\})")
60
+ edge_regex: Pattern = re.compile(r"(\w+)\[(\d+\.\d+)\]\[(\d+\.\d+),\s*(\d+\.\d+)\](\{.*\})")
61
+
62
+ @staticmethod
63
+ def load_nx_graph(file_name):
64
+ print("no preloading of graph with Agensgraph in production")
65
+
66
+ def __init__(self, namespace, global_config, embedding_func):
67
+ super().__init__(
68
+ namespace=namespace,
69
+ global_config=global_config,
70
+ embedding_func=embedding_func,
71
+ )
72
+ self._driver = None
73
+ self._driver_lock = asyncio.Lock()
74
+ DB = os.environ["AGENSGRAPH_DB"]
75
+ USER = os.environ["AGENSGRAPH_USER"]
76
+ PASSWORD = os.environ["AGENSGRAPH_PASSWORD"]
77
+ HOST = os.environ.get("AGENSGRAPH_HOST", "localhost")
78
+ PORT = os.environ.get("AGENSGRAPH_PORT", "5432")
79
+ self.graph_name = namespace or os.environ.get("AGENSGRAPH_GRAPHNAME", "lightrag")
80
+
81
+ connection_string = f"dbname='{DB}' user='{USER}' password='{PASSWORD}' host='{HOST}' port={PORT}"
82
+
83
+ self._driver = AsyncConnectionPool(connection_string, open=False)
84
+
85
+ return None
86
+
87
+ async def initialize(self):
88
+ """
89
+ Initialize the Agensgraph storage by creating a connection pool.
90
+ """
91
+ if self._driver is None:
92
+ raise AgensgraphQueryException("Agensgraph driver is not initialized")
93
+
94
+ # create graph and set graph_path
95
+ async with self._driver_lock:
96
+ try:
97
+ await self._driver.open()
98
+ except psycopg.errors.InvalidSchemaName as e:
99
+ raise AgensgraphQueryException(
100
+ f"Failed to open connection to Agensgraph: {str(e)}"
101
+ ) from e
102
+
103
+ async with self._get_pool_connection() as conn:
104
+ async with conn.cursor() as curs:
105
+ try:
106
+ await curs.execute(sql.SQL("CREATE GRAPH IF NOT EXISTS {}").format(sql.Identifier(self.graph_name)))
107
+ await curs.execute(sql.SQL("SET graph_path = {}").format(sql.Identifier(self.graph_name)))
108
+ await curs.execute('CREATE VLABEL IF NOT EXISTS base')
109
+ await curs.execute('CREATE ELABEL IF NOT EXISTS "DIRECTED"')
110
+ await curs.execute('CREATE PROPERTY INDEX IF NOT EXISTS base_entity_idx ON base (entity_id)')
111
+ await conn.commit()
112
+ except (
113
+ psycopg.errors.InvalidSchemaName,
114
+ psycopg.errors.UniqueViolation,
115
+ ):
116
+ await conn.rollback()
117
+ logger.warning(
118
+ f"Graph {self.graph_name} already exists or could not be created."
119
+ )
120
+ except psycopg.Error as e:
121
+ await conn.rollback()
122
+ raise AgensgraphQueryException(
123
+ f"Error initializing graph {self.graph_name}: {str(e)}"
124
+ ) from e
125
+
126
+ logger.info(f"Agensgraph storage initialized for graph: {self.graph_name}")
127
+
128
+ async def finalize(self):
129
+ """Close the Agensgraph driver and release all resources"""
130
+ if self._driver:
131
+ await self._driver.close()
132
+ self._driver = None
133
+
134
+ async def __aexit__(self, exc_type, exc, tb):
135
+ await self.finalize()
136
+
137
+ async def index_done_callback(self) -> None:
138
+ # Agensgraph handles persistence automatically
139
+ pass
140
+
141
+ async def has_node(self, node_id: str) -> bool:
142
+ """
143
+ Check if a node with the given label exists in the database
144
+
145
+ Args:
146
+ node_id: Label of the node to check
147
+
148
+ Returns:
149
+ bool: True if node exists, False otherwise
150
+
151
+ Raises:
152
+ Exception: If there is an error executing the query
153
+ """
154
+ query = """
155
+ MATCH (n:base {entity_id: %(node_id)s})
156
+ RETURN true AS node_exists LIMIT 1
157
+ """
158
+ single_result = (await self._query(query, {"node_id": Jsonb(node_id)}))[0]
159
+ logger.debug(
160
+ "{%s}:query:{%s}:result:{%s}",
161
+ inspect.currentframe().f_code.co_name,
162
+ query,
163
+ single_result["node_exists"],
164
+ )
165
+
166
+ return single_result["node_exists"]
167
+
168
+ async def has_edge(self, source_node_id: str, target_node_id: str) -> bool:
169
+ """
170
+ Check if an edge exists between two nodes
171
+
172
+ Args:
173
+ source_node_id: Label of the source node
174
+ target_node_id: Label of the target node
175
+
176
+ Returns:
177
+ bool: True if edge exists, False otherwise
178
+
179
+ Raises:
180
+ Exception: If there is an error executing the query
181
+ """
182
+ query = """
183
+ MATCH (a:base {entity_id: %(source_node_id)s})-[r]-(b:base {entity_id: %(target_node_id)s})
184
+ RETURN true AS "edgeExists" LIMIT 1
185
+ """
186
+ single_result = (await self._query(query, {
187
+ "source_node_id": Jsonb(source_node_id),
188
+ "target_node_id": Jsonb(target_node_id),
189
+ }))[0]
190
+ logger.debug(
191
+ "{%s}:query:{%s}:result:{%s}",
192
+ inspect.currentframe().f_code.co_name,
193
+ query,
194
+ single_result["edgeExists"],
195
+ )
196
+ return single_result["edgeExists"]
197
+
198
+ async def get_node(self, node_id: str) -> dict[str, str] | None:
199
+ """Get node by its label identifier, return only node properties
200
+
201
+ Args:
202
+ node_id: The node label to look up
203
+
204
+ Returns:
205
+ dict: Node properties if found
206
+ None: If node not found
207
+
208
+ Raises:
209
+ Exception: If there is an error executing the query
210
+ """
211
+ query = """
212
+ MATCH (n:base {entity_id: %(node_id)s})
213
+ RETURN n
214
+ """
215
+ records = await self._query(query, {"node_id": Jsonb(node_id)})
216
+ if records:
217
+ # warn if there are multiple records returned
218
+ if len(records) > 1:
219
+ logger.warning(
220
+ "Multiple nodes found for entity_id '%s'. Returning first result.",
221
+ node_id,
222
+ )
223
+ node_dict = records[0]["n"]
224
+ logger.debug(
225
+ "{%s}: query: {%s}, result: {%s}",
226
+ inspect.currentframe().f_code.co_name,
227
+ query,
228
+ node_dict,
229
+ )
230
+ # Return the node properties as a dictionary
231
+ return node_dict
232
+
233
+ return None
234
+
235
+ async def get_nodes_batch(self, node_ids: list[str]) -> dict[str, dict]:
236
+ """
237
+ Retrieve multiple nodes in one query using UNWIND.
238
+
239
+ Args:
240
+ node_ids: List of node entity IDs to fetch.
241
+
242
+ Returns:
243
+ A dictionary mapping each node_id to its node data (or None if not found).
244
+ """
245
+ query = """
246
+ UNWIND %(node_ids)s AS id
247
+ MATCH (n:base {entity_id: id})
248
+ RETURN n.entity_id AS entity_id, n
249
+ """
250
+ records = await self._query(query, {"node_ids": Jsonb(node_ids)})
251
+ nodes = {}
252
+ if records:
253
+ for record in records:
254
+ entity_id = record["entity_id"]
255
+ node_dict = record["n"]
256
+ logger.debug(
257
+ "{%s}: query: {%s}, result: {%s}",
258
+ inspect.currentframe().f_code.co_name,
259
+ query,
260
+ node_dict,
261
+ )
262
+ # Return a dictionary with entity_id as key
263
+ nodes[entity_id] = node_dict
264
+ return nodes
265
+ return None
266
+
267
+ async def node_degree(self, node_id: str) -> int:
268
+ """Get the degree (number of relationships) of a node with the given label.
269
+ If multiple nodes have the same label, returns the degree of the first node.
270
+ If no node is found, returns 0.
271
+
272
+ Args:
273
+ node_id: The label of the node
274
+
275
+ Returns:
276
+ int: The number of relationships the node has, or 0 if no node found
277
+
278
+ Raises:
279
+ Exception: If there is an error executing the query
280
+ """
281
+ query = """
282
+ MATCH (n:base {entity_id: %(node_id)s})
283
+ OPTIONAL MATCH (n)-[r]-()
284
+ RETURN COUNT(r) AS degree
285
+ """
286
+ record = (await self._query(query, {"node_id": Jsonb(node_id)}))[0]
287
+ if record:
288
+ edge_count = int(record["degree"])
289
+ logger.debug(
290
+ "{%s}:query:{%s}:result:{%s}",
291
+ inspect.currentframe().f_code.co_name,
292
+ query,
293
+ edge_count,
294
+ )
295
+ return edge_count
296
+ else:
297
+ logger.warning(f"No node found with label '{self.escape_str(node_id)}'")
298
+ return 0
299
+
300
+ async def node_degrees_batch(self, node_ids: list[str]) -> dict[str, int]:
301
+ """
302
+ Retrieve the degree for multiple nodes in a single query using UNWIND.
303
+
304
+ Args:
305
+ node_ids: List of node labels (entity_id values) to look up.
306
+
307
+ Returns:
308
+ A dictionary mapping each node_id to its degree (number of relationships).
309
+ If a node is not found, its degree will be set to 0.
310
+ """
311
+ query = """
312
+ UNWIND %(node_ids)s AS id
313
+ MATCH (n:base {entity_id: id})
314
+ OPTIONAL MATCH (n)-[r]-()
315
+ RETURN n.entity_id AS entity_id, count(r) AS degree
316
+ """
317
+ records = (await self._query(query, {"node_ids": Jsonb(node_ids)}))
318
+
319
+ if records:
320
+ degrees = {}
321
+ for record in records:
322
+ entity_id = record["entity_id"]
323
+ degree = int(record["degree"])
324
+ degrees[entity_id] = degree
325
+ logger.debug(
326
+ "{%s}: query: {%s}, result: {%s}",
327
+ inspect.currentframe().f_code.co_name,
328
+ query,
329
+ degrees,
330
+ )
331
+ # For any node_id that did not return a record, set degree to 0.
332
+ for nid in node_ids:
333
+ if nid not in degrees:
334
+ logger.warning(f"No node found with label '{nid}'")
335
+ degrees[nid] = 0
336
+ return degrees
337
+ else:
338
+ logger.warning("No nodes found for the provided labels.")
339
+ return {nid: 0 for nid in node_ids}
340
+
341
+ async def edge_degree(self, src_id: str, tgt_id: str) -> int:
342
+ """Get the total degree (sum of relationships) of two nodes.
343
+
344
+ Args:
345
+ src_id: Label of the source node
346
+ tgt_id: Label of the target node
347
+
348
+ Returns:
349
+ int: Sum of the degrees of both nodes
350
+ """
351
+ src_degree = await self.node_degree(src_id)
352
+ trg_degree = await self.node_degree(tgt_id)
353
+
354
+ # Convert None to 0 for addition
355
+ src_degree = 0 if src_degree is None else src_degree
356
+ trg_degree = 0 if trg_degree is None else trg_degree
357
+
358
+ degrees = int(src_degree) + int(trg_degree)
359
+ logger.debug(
360
+ "{%s}:query:src_Degree+trg_degree:result:{%s}",
361
+ inspect.currentframe().f_code.co_name,
362
+ degrees,
363
+ )
364
+ return degrees
365
+
366
+ async def edge_degrees_batch(
367
+ self, edge_pairs: list[tuple[str, str]]
368
+ ) -> dict[tuple[str, str], int]:
369
+ """
370
+ Calculate the combined degree for each edge (sum of the source and target node degrees)
371
+ in batch using the already implemented node_degrees_batch.
372
+
373
+ Args:
374
+ edge_pairs: List of (src, tgt) tuples.
375
+
376
+ Returns:
377
+ A dictionary mapping each (src, tgt) tuple to the sum of their degrees.
378
+ """
379
+ # Collect unique node IDs from all edge pairs.
380
+ unique_node_ids = {src for src, _ in edge_pairs}
381
+ unique_node_ids.update({tgt for _, tgt in edge_pairs})
382
+
383
+ # Get degrees for all nodes in one go.
384
+ degrees = await self.node_degrees_batch(list(unique_node_ids))
385
+
386
+ # Sum up degrees for each edge pair.
387
+ edge_degrees = {}
388
+ for src, tgt in edge_pairs:
389
+ edge_degrees[(src, tgt)] = degrees.get(src, 0) + degrees.get(tgt, 0)
390
+
391
+ logger.debug(
392
+ "{%s}:query:edge_degrees_batch:result:{%s}",
393
+ inspect.currentframe().f_code.co_name,
394
+ edge_degrees,
395
+ )
396
+ return edge_degrees
397
+
398
+ async def get_edge(
399
+ self, source_node_id: str, target_node_id: str
400
+ ) -> dict[str, str] | None:
401
+ """Get edge properties between two nodes.
402
+
403
+ Args:
404
+ source_node_id: Label of the source node
405
+ target_node_id: Label of the target node
406
+
407
+ Returns:
408
+ dict: Edge properties if found, default properties if not found or on error
409
+
410
+ Raises:
411
+ Exception: If there is an error executing the query
412
+ """
413
+ query = """
414
+ MATCH (start:base {entity_id: %(source_node_id)s})-[r]-("end":base {entity_id: %(target_node_id)s})
415
+ RETURN properties(r) as edge_properties
416
+ """
417
+ records = await self._query(query, {
418
+ "source_node_id": Jsonb(source_node_id),
419
+ "target_node_id": Jsonb(target_node_id),
420
+ })
421
+
422
+ if records:
423
+ if len(records) > 1:
424
+ logger.warning(
425
+ "Multiple edges found between '%s' and '%s'. Returning first result.",
426
+ self.escape_str(source_node_id),
427
+ self.escape_str(target_node_id),
428
+ )
429
+ edge_result = records[0]["edge_properties"]
430
+
431
+ required_keys = {
432
+ "weight": 0.0,
433
+ "source_id": None,
434
+ "description": None,
435
+ "keywords": None,
436
+ }
437
+ for key, default_value in required_keys.items():
438
+ if key not in edge_result:
439
+ edge_result[key] = default_value
440
+ logger.warning(
441
+ f"Edge between {self.escape_str(source_node_id)} and {self.escape_str(target_node_id)} "
442
+ f"missing {key}, using default: {default_value}"
443
+ )
444
+ logger.debug(
445
+ "{%s}:query:{%s}:result:{%s}",
446
+ inspect.currentframe().f_code.co_name,
447
+ query,
448
+ edge_result,
449
+ )
450
+ return edge_result
451
+ else:
452
+ logger.warning(
453
+ "No edge found between '%s' and '%s'. Returning default properties.",
454
+ self.escape_str(source_node_id),
455
+ self.escape_str(target_node_id),
456
+ )
457
+ # Return None when no edge found
458
+ return None
459
+
460
+ async def get_edges_batch(
461
+ self, pairs: list[dict[str, str]]
462
+ ) -> dict[tuple[str, str], dict]:
463
+ """
464
+ Retrieve edge properties for multiple (src, tgt) pairs in one query.
465
+
466
+ Args:
467
+ pairs: List of dictionaries, e.g. [{"src": "node1", "tgt": "node2"}, ...]
468
+
469
+ Returns:
470
+ A dictionary mapping (src, tgt) tuples to their edge properties.
471
+ """
472
+ query = """
473
+ UNWIND %(pairs)s AS pair
474
+ MATCH (start:base {entity_id: pair.src})-[r:"DIRECTED"]-("end":base {entity_id: pair.tgt})
475
+ RETURN pair.src AS src_id, pair.tgt AS tgt_id, collect(properties(r)) AS edges
476
+ """
477
+ records = await self._query(query, {"pairs": Jsonb(pairs)})
478
+ edges_dict = {}
479
+ if records:
480
+ for record in records:
481
+ src = record["src_id"]
482
+ tgt = record["tgt_id"]
483
+ edges = record["edges"]
484
+ if edges and len(edges) > 0:
485
+ edge_props = edges[0] # choose the first if multiple exist
486
+ # Ensure required keys exist with defaults
487
+ for key, default in {
488
+ "weight": 0.0,
489
+ "source_id": None,
490
+ "description": None,
491
+ "keywords": None,
492
+ }.items():
493
+ if key not in edge_props:
494
+ edge_props[key] = default
495
+ edges_dict[(src, tgt)] = edge_props
496
+ else:
497
+ edges_dict[(src, tgt)] = {
498
+ "weight": 0.0,
499
+ "source_id": None,
500
+ "description": None,
501
+ "keywords": None,
502
+ }
503
+ logger.debug(
504
+ "{%s}:query:{%s}:result:{%s}",
505
+ inspect.currentframe().f_code.co_name,
506
+ query,
507
+ edges_dict,
508
+ )
509
+ return edges_dict
510
+ else:
511
+ logger.warning("No edges found for the provided pairs.")
512
+ return edges_dict
513
+
514
+ async def get_node_edges(self, source_node_id: str) -> list[tuple[str, str]] | None:
515
+ """Retrieves all edges (relationships) for a particular node identified by its label.
516
+
517
+ Args:
518
+ source_node_id: Label of the node to get edges for
519
+
520
+ Returns:
521
+ list[tuple[str, str]]: List of (source_label, target_label) tuples representing edges
522
+ None: If no edges found
523
+
524
+ Raises:
525
+ Exception: If there is an error executing the query
526
+ """
527
+ query = """
528
+ MATCH (n:base {entity_id: %(source_node_id)s})
529
+ OPTIONAL MATCH (n)-[r]-(connected:base)
530
+ WHERE connected.entity_id IS NOT NULL
531
+ RETURN n, r, connected
532
+ """
533
+ results = await self._query(query, {"source_node_id": Jsonb(source_node_id)})
534
+ if results:
535
+ edges = []
536
+ for record in results:
537
+ source_node = record["n"] if record["n"] else None
538
+ connected_node = record["connected"] if record["connected"] else None
539
+
540
+ if not source_node or not connected_node:
541
+ continue
542
+
543
+ source_label = (
544
+ source_node.get("entity_id")
545
+ if source_node.get("entity_id")
546
+ else None
547
+ )
548
+ target_label = (
549
+ connected_node.get("entity_id")
550
+ if connected_node.get("entity_id")
551
+ else None
552
+ )
553
+
554
+ if source_label and target_label:
555
+ edges.append((source_label, target_label))
556
+ else:
557
+ logger.warning(f"No edges found for node with label '{source_node_id}'")
558
+ return None
559
+
560
+ logger.debug(
561
+ "{%s}:query:{%s}:result:{%s}",
562
+ inspect.currentframe().f_code.co_name,
563
+ query,
564
+ edges,
565
+ )
566
+ return edges
567
+
568
+ async def get_nodes_edges_batch(
569
+ self, node_ids: list[str]
570
+ ) -> dict[str, list[tuple[str, str]]]:
571
+ """
572
+ Batch retrieve edges for multiple nodes in one query using UNWIND.
573
+ For each node, returns both outgoing and incoming edges to properly represent
574
+ the undirected graph nature.
575
+
576
+ Args:
577
+ node_ids: List of node IDs (entity_id) for which to retrieve edges.
578
+
579
+ Returns:
580
+ A dictionary mapping each node ID to its list of edge tuples (source, target).
581
+ For each node, the list includes both:
582
+ - Outgoing edges: (queried_node, connected_node)
583
+ - Incoming edges: (connected_node, queried_node)
584
+ """
585
+ # Query to get both outgoing and incoming edges
586
+ query = """
587
+ UNWIND %(node_ids)s AS id
588
+ MATCH (n:base {entity_id: id})
589
+ OPTIONAL MATCH (n)-[r]-(connected:base)
590
+ RETURN id AS queried_id, n.entity_id AS node_entity_id,
591
+ connected.entity_id AS connected_entity_id,
592
+ startNode(r).entity_id AS start_entity_id
593
+ """
594
+ records = await self._query(query, {"node_ids": Jsonb(node_ids)})
595
+
596
+ # Initialize the dictionary with empty lists for each node ID
597
+ edges_dict = {node_id: [] for node_id in node_ids}
598
+
599
+ if records:
600
+ for record in records:
601
+ queried_id = record["queried_id"]
602
+ node_entity_id = record["node_entity_id"]
603
+ connected_entity_id = record["connected_entity_id"]
604
+ start_entity_id = record["start_entity_id"]
605
+
606
+ # Skip if either node is None
607
+ if not node_entity_id or not connected_entity_id:
608
+ continue
609
+
610
+ # Determine the actual direction of the edge
611
+ # If the start node is the queried node, it's an outgoing edge
612
+ # Otherwise, it's an incoming edge
613
+ if start_entity_id == node_entity_id:
614
+ # Outgoing edge: (queried_node -> connected_node)
615
+ edges_dict[queried_id].append((node_entity_id, connected_entity_id))
616
+ else:
617
+ # Incoming edge: (connected_node -> queried_node)
618
+ edges_dict[queried_id].append((connected_entity_id, node_entity_id))
619
+ else:
620
+ logger.warning("No edges found for the provided node IDs.")
621
+ # If no edges found, return empty lists for each node ID
622
+ return edges_dict
623
+
624
+ logger.debug(
625
+ "{%s}:query:{%s}:result:{%s}",
626
+ inspect.currentframe().f_code.co_name,
627
+ query,
628
+ edges_dict,
629
+ )
630
+ return edges_dict
631
+
632
+ async def get_nodes_by_chunk_ids(self, chunk_ids: list[str]) -> list[dict]:
633
+ query = """
634
+ UNWIND %(chunk_ids)s AS chunk_id
635
+ MATCH (n:base)
636
+ WHERE n.source_id IS NOT NULL AND chunk_id <@ split(n.source_id, {GRAPH_FIELD_SEP})::jsonb
637
+ RETURN DISTINCT n
638
+ """
639
+
640
+ results = await self._query(sql.SQL(query).format(
641
+ GRAPH_FIELD_SEP=sql.Literal(GRAPH_FIELD_SEP)
642
+ ), {"chunk_ids": Jsonb(chunk_ids)})
643
+ nodes = []
644
+ for record in results:
645
+ node_dict = record["n"]
646
+ # Add node id (entity_id) to the dictionary for easier access
647
+ node_dict["id"] = node_dict.get("entity_id")
648
+ nodes.append(node_dict)
649
+ return nodes
650
+
651
+ async def get_edges_by_chunk_ids(self, chunk_ids: list[str]) -> list[dict]:
652
+ query = """
653
+ UNWIND %(chunk_ids)s AS chunk_id
654
+ MATCH (a:base)-[r]-(b:base)
655
+ WHERE r.source_id IS NOT NULL AND chunk_id <@ split(r.source_id, {GRAPH_FIELD_SEP})::jsonb
656
+ RETURN DISTINCT a.entity_id AS source, b.entity_id AS target, properties(r) AS properties
657
+ """
658
+
659
+ results = await self._query(sql.SQL(query).format(
660
+ GRAPH_FIELD_SEP=sql.Literal(GRAPH_FIELD_SEP)
661
+ ), {"chunk_ids": Jsonb(chunk_ids)})
662
+ edges = []
663
+ for record in results:
664
+ edge_properties = record["properties"]
665
+ edge_properties["source"] = record["source"]
666
+ edge_properties["target"] = record["target"]
667
+ edges.append(edge_properties)
668
+ return edges
669
+
670
+ @retry(
671
+ stop=stop_after_attempt(3),
672
+ wait=wait_exponential(multiplier=1, min=4, max=10),
673
+ retry=retry_if_exception_type((AgensgraphQueryException,)),
674
+ )
675
+ async def upsert_node(self, node_id: str, node_data: dict[str, str]) -> None:
676
+ """
677
+ Upsert a node in the Agensgraph database.
678
+
679
+ Args:
680
+ node_id: The unique identifier for the node (used as label)
681
+ node_data: Dictionary of node properties
682
+ """
683
+ query = """
684
+ MERGE (n:base {entity_id: %(node_id)s})
685
+ SET n += %(node_data)s
686
+ """
687
+ try:
688
+ await self._query(query, {
689
+ "node_id": Jsonb(node_id),
690
+ "node_data": Jsonb(node_data),
691
+ })
692
+ logger.debug(
693
+ "Upserted node with node_id '{%s}' and properties: {%s}",
694
+ node_id,
695
+ node_data,
696
+ )
697
+ except Exception as e:
698
+ logger.error("Error during upsert: {%s}", e)
699
+ raise
700
+
701
+ @retry(
702
+ stop=stop_after_attempt(3),
703
+ wait=wait_exponential(multiplier=1, min=4, max=10),
704
+ retry=retry_if_exception_type((AgensgraphQueryException,)),
705
+ )
706
+ async def upsert_edge(
707
+ self, source_node_id: str, target_node_id: str, edge_data: dict[str, str]
708
+ ) -> None:
709
+ """
710
+ Upsert an edge and its properties between two nodes identified by their labels.
711
+ Ensures both source and target nodes exist and are unique before creating the edge.
712
+ Uses entity_id property to uniquely identify nodes.
713
+
714
+ Args:
715
+ source_node_id (str): Label of the source node (used as identifier)
716
+ target_node_id (str): Label of the target node (used as identifier)
717
+ edge_data (dict): Dictionary of properties to set on the edge
718
+ """
719
+ query = """
720
+ MATCH (source:base {entity_id: %(source_node_id)s})
721
+ WITH source
722
+ MATCH (target:base {entity_id: %(target_node_id)s})
723
+ MERGE (source)-[r:"DIRECTED"]-(target)
724
+ SET r += %(edge_data)s
725
+ RETURN r, source, target
726
+ """
727
+ try:
728
+ await self._query(query, {
729
+ "source_node_id": Jsonb(source_node_id),
730
+ "target_node_id": Jsonb(target_node_id),
731
+ "edge_data": Jsonb(edge_data),
732
+ })
733
+ logger.debug(
734
+ "Upserted edge from '{%s}' to '{%s}' with properties: {%s}",
735
+ source_node_id,
736
+ target_node_id,
737
+ edge_data,
738
+ )
739
+ except Exception as e:
740
+ logger.error("Error during edge upsert: {%s}", e)
741
+ raise
742
+
743
+ async def get_knowledge_graph(
744
+ self, node_label: str, max_depth: int = 3, max_nodes: int = 1000
745
+ ) -> KnowledgeGraph:
746
+ """
747
+ Retrieve a connected subgraph of nodes where the label includes the specified `node_label`.
748
+
749
+ Args:
750
+ node_label: Label of the starting node, * means all nodes
751
+ max_depth: Maximum depth of the subgraph, Defaults to 3
752
+ max_nodes: Maximum nodes to return by BFS, Defaults to 1000
753
+
754
+ Returns:
755
+ KnowledgeGraph object containing nodes and edges, with an is_truncated flag
756
+ indicating whether the graph was truncated due to max_nodes limit
757
+ """
758
+ from collections import deque
759
+
760
+ result = KnowledgeGraph()
761
+ visited_nodes = set()
762
+ visited_edges = set()
763
+ visited_edge_pairs = set()
764
+ queue = deque()
765
+
766
+ # Step 1: Get starting nodes
767
+ if node_label == "*":
768
+ query = """
769
+ MATCH (n:base)
770
+ RETURN DISTINCT id(n) AS node_id, n
771
+ LIMIT %(max_nodes)s
772
+ """
773
+ node_results = await self._query(query, {"max_nodes": max_nodes})
774
+ else:
775
+ query = """
776
+ MATCH (n:base {entity_id: %(node_label)s})
777
+ RETURN id(n) AS node_id, n
778
+ """
779
+ node_results = await self._query(query, {"node_label": Jsonb(node_label)})
780
+
781
+ for record in node_results:
782
+ node_data = record["n"]
783
+ if not node_data.get("entity_id"):
784
+ continue
785
+ start_node = KnowledgeGraphNode(
786
+ id=str(node_data["entity_id"]),
787
+ labels=[node_data["entity_id"]],
788
+ properties=node_data,
789
+ )
790
+ queue.append((start_node, None, 0))
791
+
792
+ # Step 2: BFS traversal
793
+ while queue and len(visited_nodes) < max_nodes:
794
+ current_node, current_edge, current_depth = queue.popleft()
795
+
796
+ if current_node.id in visited_nodes or current_depth > max_depth:
797
+ continue
798
+
799
+ result.nodes.append(current_node)
800
+ visited_nodes.add(current_node.id)
801
+
802
+ if current_edge and current_edge.id not in visited_edges:
803
+ result.edges.append(current_edge)
804
+ visited_edges.add(current_edge.id)
805
+
806
+ if len(visited_nodes) >= max_nodes:
807
+ result.is_truncated = True
808
+ break
809
+
810
+ # Step 3: Query neighbors
811
+ query = """
812
+ MATCH (a:base {entity_id: %(current_node_id)s})-[r]-(b:base)
813
+ RETURN type(r) as rel_type, properties(r) as r, b, id(r) AS edge_id, id(b) AS target_id
814
+ """
815
+ records = await self._query(query, {"current_node_id": Jsonb(current_node.id)})
816
+
817
+ for record in records:
818
+ rel_type = record["rel_type"]
819
+ rel = record["r"]
820
+ b_node = record["b"]
821
+ edge_id = str(record["edge_id"])
822
+ target_id = b_node.get("entity_id")
823
+
824
+ if not target_id:
825
+ continue
826
+
827
+ target_node = KnowledgeGraphNode(
828
+ id=str(target_id),
829
+ labels=[target_id],
830
+ properties=b_node,
831
+ )
832
+
833
+ target_edge = KnowledgeGraphEdge(
834
+ id=edge_id,
835
+ type=rel_type,
836
+ source=current_node.id,
837
+ target=target_id,
838
+ properties=rel,
839
+ )
840
+
841
+ sorted_pair = tuple(sorted([current_node.id, target_id]))
842
+ if sorted_pair not in visited_edge_pairs:
843
+ if (
844
+ target_id in visited_nodes or
845
+ (target_id not in visited_nodes and current_depth < max_depth)
846
+ ):
847
+ result.edges.append(target_edge)
848
+ visited_edges.add(edge_id)
849
+ visited_edge_pairs.add(sorted_pair)
850
+
851
+ if target_id not in visited_nodes and current_depth < max_depth:
852
+ queue.append((target_node, None, current_depth + 1))
853
+
854
+ return result
855
+
856
+ async def get_all_labels(self) -> list[str]:
857
+ """Get all node labels in the database
858
+
859
+ Returns:
860
+ ["label1", "label2", ...] # Alphabetically sorted label list
861
+ """
862
+ query = """
863
+ MATCH (n:base)
864
+ WHERE n.entity_id IS NOT NULL
865
+ WITH DISTINCT n.entity_id AS label
866
+ ORDER BY label
867
+ RETURN collect(label) AS node_labels
868
+ """
869
+ results = await self._query(query)
870
+
871
+ if not results:
872
+ logger.warning("No labels found in the graph.")
873
+ return []
874
+
875
+ labels = results[0]["node_labels"]
876
+ logger.debug(
877
+ "{%s}:query:{%s}:result:{%s}",
878
+ inspect.currentframe().f_code.co_name,
879
+ query,
880
+ labels,
881
+ )
882
+ return labels
883
+
884
+ async def delete_node(self, node_id: str) -> None:
885
+ """Delete a node with the specified label
886
+
887
+ Args:
888
+ node_id: The label of the node to delete
889
+ """
890
+ query = """
891
+ MATCH (n:base {entity_id: %(node_id)s})
892
+ DETACH DELETE n
893
+ """
894
+ try:
895
+ await self._query(query, {"node_id": Jsonb(node_id)})
896
+ logger.debug(f"Deleted node with label '{self.escape_str(node_id)}'")
897
+ except Exception as e:
898
+ logger.error(f"Error during node deletion: {str(e)}")
899
+ raise
900
+
901
+ @retry(
902
+ stop=stop_after_attempt(3),
903
+ wait=wait_exponential(multiplier=1, min=4, max=10),
904
+ retry=retry_if_exception_type((AgensgraphQueryException,)),
905
+ )
906
+ async def remove_nodes(self, nodes: list[str]):
907
+ """Delete multiple nodes
908
+
909
+ Args:
910
+ nodes: List of node labels to be deleted
911
+ """
912
+ for node in nodes:
913
+ await self.delete_node(node)
914
+
915
+ @retry(
916
+ stop=stop_after_attempt(3),
917
+ wait=wait_exponential(multiplier=1, min=4, max=10),
918
+ retry=retry_if_exception_type((AgensgraphQueryException,)),
919
+ )
920
+ async def remove_edges(self, edges: list[tuple[str, str]]):
921
+ """Delete multiple edges
922
+
923
+ Args:
924
+ edges: List of edges to be deleted, each edge is a (source, target) tuple
925
+ """
926
+ for source, target in edges:
927
+ query = """
928
+ MATCH (source:base {entity_id: %(source)s})-[r]-(target:base {entity_id: %(target)s})
929
+ DELETE r
930
+ """
931
+ try:
932
+ await self._query(query, {
933
+ "source": Jsonb(source),
934
+ "target": Jsonb(target),
935
+ })
936
+ logger.debug(
937
+ f"Deleted edge from '{self.escape_str(source)}' to '{self.escape_str(target)}'"
938
+ )
939
+ except Exception as e:
940
+ logger.error(f"Error during edge deletion: {str(e)}")
941
+ raise
942
+
943
+ async def drop(self) -> dict[str, str]:
944
+ """Drop the storage by removing all nodes and relationships in the graph.
945
+
946
+ Returns:
947
+ dict[str, str]: Status of the operation with keys 'status' and 'message'
948
+ """
949
+ try:
950
+ query = """
951
+ MATCH (n)
952
+ DETACH DELETE n
953
+ """
954
+ await self._query(query)
955
+ logger.info(f"Successfully dropped all data from graph {self.graph_name}")
956
+ return {"status": "success", "message": "graph data dropped"}
957
+ except Exception as e:
958
+ logger.error(f"Error dropping graph {self.graph_name}: {e}")
959
+ return {"status": "error", "message": str(e)}
960
+
961
+ @staticmethod
962
+ def _record_to_dict(record: NamedTuple) -> Dict[str, Any]:
963
+ """
964
+ Convert a record returned from an agensgraph query to a dictionary
965
+
966
+ Args:
967
+ record (): a record from an agensgraph query result
968
+
969
+ Returns:
970
+ Dict[str, Any]: a dictionary representation of the record where
971
+ the dictionary key is the field name and the value is the
972
+ value converted to a python type
973
+ """
974
+ # result holder
975
+ d = {}
976
+
977
+ # prebuild a mapping of vertex_id to vertex mappings to be used
978
+ # later to build edges
979
+ vertices = {}
980
+ for k in record._fields:
981
+ v = getattr(record, k)
982
+
983
+ # records comes back label[id]{properties} which must be parsed
984
+ if isinstance(v, str):
985
+ vertex = AgensgraphStorage.vertex_regex.match(v)
986
+ if vertex:
987
+ label, vertex_id, properties = vertex.groups()
988
+ properties = json.loads(properties)
989
+ vertices[str(vertex_id)] = properties
990
+
991
+ # iterate returned fields and parse appropriately
992
+ for k in record._fields:
993
+ v = getattr(record, k)
994
+
995
+ if isinstance(v, str):
996
+ vertex = AgensgraphStorage.vertex_regex.match(v)
997
+ edge = AgensgraphStorage.edge_regex.match(v)
998
+
999
+ if vertex:
1000
+ d[k] = json.loads(vertex.group(3))
1001
+ elif edge:
1002
+ elabel, edge_id, start_id, end_id, properties = edge.groups()
1003
+ d[k] = (
1004
+ vertices.get(start_id, {}),
1005
+ elabel,
1006
+ vertices.get(end_id, {}),
1007
+ )
1008
+ else:
1009
+ d[k] = v
1010
+
1011
+ else:
1012
+ d[k] = v
1013
+
1014
+ return d
1015
+
1016
+ @staticmethod
1017
+ def escape_str(val: str) -> str:
1018
+ return val.replace("'", "''").replace("\\", "\\\\").replace('"', '\\"')
1019
+
1020
+ async def _query(self, query: str, params: Dict = {}) -> List[Dict[str, Any]]:
1021
+ """
1022
+ Query the graph by taking a cypher query, converting it to an
1023
+ age compatible query, executing it and converting the result
1024
+
1025
+ Args:
1026
+ query (str): a cypher query to be executed
1027
+ params (dict): parameters for the query
1028
+
1029
+ Returns:
1030
+ List[Dict[str, Any]]: a list of dictionaries containing the result set
1031
+ """
1032
+ await self._driver.open()
1033
+
1034
+ # execute the query, rolling back on an error
1035
+ async with self._get_pool_connection() as conn:
1036
+ async with conn.cursor(row_factory=namedtuple_row) as curs:
1037
+ try:
1038
+ await curs.execute(sql.SQL("SET graph_path = {}").format(sql.Identifier(self.graph_name)))
1039
+ await curs.execute(query, params)
1040
+ await conn.commit()
1041
+ except psycopg.Error as e:
1042
+ await conn.rollback()
1043
+ raise AgensgraphQueryException(
1044
+ {
1045
+ "message": f"Error executing graph query: {query}",
1046
+ "detail": str(e),
1047
+ }
1048
+ ) from e
1049
+ try:
1050
+ data = await curs.fetchall()
1051
+ except psycopg.ProgrammingError:
1052
+ data = [] # Handle queries that don’t return data
1053
+ if data is None:
1054
+ result = []
1055
+ # decode records
1056
+ else:
1057
+ result = [AgensgraphStorage._record_to_dict(d) for d in data]
1058
+
1059
+ return result
1060
+
1061
+ @asynccontextmanager
1062
+ async def _get_pool_connection(self, timeout: Optional[float] = None):
1063
+ """Workaround for a psycopg_pool bug"""
1064
+
1065
+ try:
1066
+ connection = await self._driver.getconn(timeout=timeout)
1067
+ except PoolTimeout:
1068
+ await self._driver._add_connection(None) # workaround...
1069
+ connection = await self._driver.getconn(timeout=timeout)
1070
+
1071
+ try:
1072
+ async with connection:
1073
+ yield connection
1074
+ finally:
1075
+ await self._driver.putconn(connection)
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ build-backend = "poetry.core.masonry.api"
3
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
4
+
5
+ [tool.poetry]
6
+ name = "lightrag-agensgraph"
7
+ version = "0.1.0"
8
+ description = "Lightrag graph store implementation for agensgraph"
9
+ authors = ["Muhammad Taha Naveed <skaisw@skaiworldwide.com>"]
10
+ readme = "README.md"
11
+ homepage = "https://github.com/skaiworldwide-oss/agensgraph-ai/tree/main/lightrag"
12
+ repository = "https://github.com/skaiworldwide-oss/agensgraph-ai/tree/main/lightrag"
13
+ keywords = ["lightrag", "agensgraph", "graph store", "integration"]
14
+
15
+ [tool.poetry.dependencies]
16
+ python = ">=3.9,<4.0"
17
+ psycopg = { version = ">=3.1.0", extras = ["binary", "pool"] }
18
+ lightrag-hku = ">=1.3.9"
19
+
20
+ [[tool.poetry.packages]]
21
+ include = "lightrag_agensgraph"