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.
- lightrag_agensgraph-0.1.0/PKG-INFO +60 -0
- lightrag_agensgraph-0.1.0/README.md +38 -0
- lightrag_agensgraph-0.1.0/lightrag_agensgraph/__init__.py +6 -0
- lightrag_agensgraph-0.1.0/lightrag_agensgraph/kg/__init__.py +0 -0
- lightrag_agensgraph-0.1.0/lightrag_agensgraph/kg/agensgraph_impl.py +1075 -0
- lightrag_agensgraph-0.1.0/pyproject.toml +21 -0
|
@@ -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"
|
|
File without changes
|
|
@@ -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"
|