reasongraph 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- reasongraph/__init__.py +21 -0
- reasongraph/_embeddings.py +67 -0
- reasongraph/_extraction.py +157 -0
- reasongraph/_types.py +24 -0
- reasongraph/backends/__init__.py +5 -0
- reasongraph/backends/_base.py +72 -0
- reasongraph/backends/_postgres.py +235 -0
- reasongraph/backends/_sqlite.py +352 -0
- reasongraph/datasets/__init__.py +3 -0
- reasongraph/datasets/_registry.py +28 -0
- reasongraph/datasets/analysis_patterns.json +203 -0
- reasongraph/datasets/causal.json +83 -0
- reasongraph/datasets/financial.json +120 -0
- reasongraph/datasets/medical.json +85 -0
- reasongraph/datasets/syllogisms.json +86 -0
- reasongraph/datasets/taxonomy.json +87 -0
- reasongraph/graph.py +366 -0
- reasongraph/py.typed +0 -0
- reasongraph-0.2.0.dist-info/METADATA +230 -0
- reasongraph-0.2.0.dist-info/RECORD +22 -0
- reasongraph-0.2.0.dist-info/WHEEL +4 -0
- reasongraph-0.2.0.dist-info/licenses/LICENSE +21 -0
reasongraph/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
2
|
+
|
|
3
|
+
import os as _os
|
|
4
|
+
|
|
5
|
+
# Suppress noisy transformers/tqdm output (load reports, progress bars, pipeline hints)
|
|
6
|
+
# unless the user has explicitly configured verbosity.
|
|
7
|
+
if "TRANSFORMERS_VERBOSITY" not in _os.environ:
|
|
8
|
+
_os.environ["TRANSFORMERS_VERBOSITY"] = "error"
|
|
9
|
+
if "TQDM_DISABLE" not in _os.environ:
|
|
10
|
+
_os.environ["TQDM_DISABLE"] = "1"
|
|
11
|
+
|
|
12
|
+
from reasongraph._types import Node, Edge
|
|
13
|
+
from reasongraph._extraction import NERExtractor, GLiNER2Extractor
|
|
14
|
+
from reasongraph.graph import ReasonGraph
|
|
15
|
+
from reasongraph.datasets import load_dataset
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ReasonGraph", "Node", "Edge",
|
|
19
|
+
"NERExtractor", "GLiNER2Extractor",
|
|
20
|
+
"load_dataset",
|
|
21
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from sentence_transformers import SentenceTransformer, CrossEncoder
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EmbeddingManager:
|
|
7
|
+
"""Manages embedding generation and cross-encoder reranking."""
|
|
8
|
+
|
|
9
|
+
DEFAULT_EMBED_MODEL = "all-MiniLM-L12-v2"
|
|
10
|
+
DEFAULT_RERANK_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
embed_model: str | SentenceTransformer | None = None,
|
|
15
|
+
rerank_model: str | CrossEncoder | None = None,
|
|
16
|
+
) -> None:
|
|
17
|
+
if isinstance(embed_model, SentenceTransformer):
|
|
18
|
+
self._embed = embed_model
|
|
19
|
+
else:
|
|
20
|
+
self._embed = SentenceTransformer(embed_model or self.DEFAULT_EMBED_MODEL)
|
|
21
|
+
|
|
22
|
+
self._rerank: CrossEncoder | None = None
|
|
23
|
+
self._rerank_name = rerank_model
|
|
24
|
+
|
|
25
|
+
def encode(self, text: str) -> list[float]:
|
|
26
|
+
"""Encode a single text string to a float vector."""
|
|
27
|
+
return self._embed.encode(text).tolist()
|
|
28
|
+
|
|
29
|
+
def encode_batch(self, texts: list[str]) -> list[list[float]]:
|
|
30
|
+
"""Encode multiple texts at once."""
|
|
31
|
+
return self._embed.encode(texts).tolist()
|
|
32
|
+
|
|
33
|
+
def rerank(
|
|
34
|
+
self,
|
|
35
|
+
query: str,
|
|
36
|
+
results: list[dict[str, str]],
|
|
37
|
+
top_k: int,
|
|
38
|
+
) -> list[dict[str, str]]:
|
|
39
|
+
"""Rerank results using a cross-encoder. Lazy-loads the model on first call."""
|
|
40
|
+
if not results:
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
# Deduplicate by content
|
|
44
|
+
seen = {}
|
|
45
|
+
for r in results:
|
|
46
|
+
if r["content"] not in seen:
|
|
47
|
+
seen[r["content"]] = r
|
|
48
|
+
unique = list(seen.values())
|
|
49
|
+
|
|
50
|
+
if len(unique) <= 1:
|
|
51
|
+
return unique[:top_k]
|
|
52
|
+
|
|
53
|
+
if self._rerank is None:
|
|
54
|
+
model_name = (
|
|
55
|
+
self._rerank_name
|
|
56
|
+
if isinstance(self._rerank_name, str)
|
|
57
|
+
else self.DEFAULT_RERANK_MODEL
|
|
58
|
+
)
|
|
59
|
+
if isinstance(self._rerank_name, CrossEncoder):
|
|
60
|
+
self._rerank = self._rerank_name
|
|
61
|
+
else:
|
|
62
|
+
self._rerank = CrossEncoder(model_name)
|
|
63
|
+
|
|
64
|
+
pairs = [(query, r["content"]) for r in unique]
|
|
65
|
+
scores = self._rerank.predict(pairs)
|
|
66
|
+
ranked = [r for _, r in sorted(zip(scores, unique), reverse=True)]
|
|
67
|
+
return ranked[:top_k]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NERExtractor:
|
|
7
|
+
"""Named entity extractor using a HuggingFace token classification model.
|
|
8
|
+
|
|
9
|
+
Default model is dslim/bert-base-NER which recognizes PER, ORG, LOC, MISC.
|
|
10
|
+
Lazy-loads the pipeline on first call.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
DEFAULT_MODEL = "dslim/bert-base-NER"
|
|
14
|
+
|
|
15
|
+
def __init__(self, model: str | None = None) -> None:
|
|
16
|
+
self._model_name = model or self.DEFAULT_MODEL
|
|
17
|
+
self._pipeline = None
|
|
18
|
+
|
|
19
|
+
def _load(self):
|
|
20
|
+
if self._pipeline is None:
|
|
21
|
+
from transformers import pipeline
|
|
22
|
+
self._pipeline = pipeline(
|
|
23
|
+
"ner",
|
|
24
|
+
model=self._model_name,
|
|
25
|
+
aggregation_strategy="simple",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def __call__(self, text: str) -> list[str]:
|
|
29
|
+
"""Extract entities from text. Returns deduplicated entity strings."""
|
|
30
|
+
self._load()
|
|
31
|
+
results = self._pipeline(text)
|
|
32
|
+
seen = set()
|
|
33
|
+
entities = []
|
|
34
|
+
for ent in results:
|
|
35
|
+
word = ent["word"].strip()
|
|
36
|
+
# Filter out WordPiece artifacts (##subword tokens) and single chars
|
|
37
|
+
if not word or word.startswith("##") or len(word) <= 1:
|
|
38
|
+
continue
|
|
39
|
+
if word not in seen:
|
|
40
|
+
seen.add(word)
|
|
41
|
+
entities.append(word)
|
|
42
|
+
return entities
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class GLiNER2Extractor:
|
|
46
|
+
"""Entity and relation extractor using GLiNER2.
|
|
47
|
+
|
|
48
|
+
Single model that handles both NER and causal relation extraction.
|
|
49
|
+
Lazy-loads the model on first call.
|
|
50
|
+
|
|
51
|
+
Default model: fastino/gliner2-large-v1 (340M params, DeBERTa-v3-large).
|
|
52
|
+
Requires: pip install reasongraph[gliner2]
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
DEFAULT_MODEL = "fastino/gliner2-large-v1"
|
|
56
|
+
|
|
57
|
+
DEFAULT_ENTITY_TYPES = ["person", "organization", "location", "event"]
|
|
58
|
+
DEFAULT_RELATION_TYPES = ["causes", "leads_to", "results_in"]
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
model: str | None = None,
|
|
63
|
+
entity_types: list[str] | None = None,
|
|
64
|
+
relation_types: list[str] | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
self._model_name = model or self.DEFAULT_MODEL
|
|
67
|
+
self.entity_types = entity_types or self.DEFAULT_ENTITY_TYPES
|
|
68
|
+
self.relation_types = relation_types or self.DEFAULT_RELATION_TYPES
|
|
69
|
+
self._model = None
|
|
70
|
+
|
|
71
|
+
def _load(self):
|
|
72
|
+
if self._model is None:
|
|
73
|
+
try:
|
|
74
|
+
from gliner2 import GLiNER2
|
|
75
|
+
except ImportError:
|
|
76
|
+
raise ImportError(
|
|
77
|
+
"GLiNER2 not installed. Install with: pip install reasongraph[gliner2]"
|
|
78
|
+
)
|
|
79
|
+
self._model = GLiNER2.from_pretrained(self._model_name)
|
|
80
|
+
|
|
81
|
+
def __call__(self, text: str) -> list[str]:
|
|
82
|
+
"""Extract entities from text (compatible with ExtractorFn).
|
|
83
|
+
|
|
84
|
+
Returns deduplicated entity strings.
|
|
85
|
+
"""
|
|
86
|
+
self._load()
|
|
87
|
+
raw = self._model.extract_entities(text, self.entity_types)
|
|
88
|
+
# GLiNER2 returns {"entities": {"person": [...], "location": [...]}}
|
|
89
|
+
entities_by_type = raw.get("entities", raw)
|
|
90
|
+
|
|
91
|
+
seen = set()
|
|
92
|
+
entities = []
|
|
93
|
+
for type_key, ents in entities_by_type.items():
|
|
94
|
+
if not isinstance(ents, list):
|
|
95
|
+
continue
|
|
96
|
+
for ent in ents:
|
|
97
|
+
word = ent.strip() if isinstance(ent, str) else ""
|
|
98
|
+
if word and word not in seen:
|
|
99
|
+
seen.add(word)
|
|
100
|
+
entities.append(word)
|
|
101
|
+
return entities
|
|
102
|
+
|
|
103
|
+
def extract_causal(self, texts: list[str]) -> list[dict]:
|
|
104
|
+
"""Extract cause-effect relations from texts (compatible with CausalExtractorFn).
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
texts: List of text strings to analyze.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
List of dicts (one per text) with keys:
|
|
111
|
+
- 'text': original text
|
|
112
|
+
- 'causal': bool
|
|
113
|
+
- 'relations': list of {'cause': str, 'effect': str}
|
|
114
|
+
"""
|
|
115
|
+
self._load()
|
|
116
|
+
results = []
|
|
117
|
+
for text in texts:
|
|
118
|
+
raw = self._model.extract_relations(text, self.relation_types)
|
|
119
|
+
# GLiNER2 returns {"relation_extraction": {"causes": [("a","b")], ...}}
|
|
120
|
+
# or directly {"causes": [("a","b")], ...}
|
|
121
|
+
rel_data = raw.get("relation_extraction", raw)
|
|
122
|
+
|
|
123
|
+
relations = []
|
|
124
|
+
seen_pairs = set()
|
|
125
|
+
for rel_type, pairs in rel_data.items():
|
|
126
|
+
if not isinstance(pairs, list):
|
|
127
|
+
continue
|
|
128
|
+
for pair in pairs:
|
|
129
|
+
cause, effect = None, None
|
|
130
|
+
if isinstance(pair, (list, tuple)) and len(pair) >= 2:
|
|
131
|
+
cause, effect = str(pair[0]).strip(), str(pair[1]).strip()
|
|
132
|
+
elif isinstance(pair, dict):
|
|
133
|
+
h = pair.get("head", {})
|
|
134
|
+
t = pair.get("tail", {})
|
|
135
|
+
cause = (h.get("text", "") if isinstance(h, dict) else str(h)).strip()
|
|
136
|
+
effect = (t.get("text", "") if isinstance(t, dict) else str(t)).strip()
|
|
137
|
+
if not cause or not effect or cause == effect:
|
|
138
|
+
continue
|
|
139
|
+
pair_key = (cause, effect)
|
|
140
|
+
if pair_key in seen_pairs:
|
|
141
|
+
continue
|
|
142
|
+
seen_pairs.add(pair_key)
|
|
143
|
+
relations.append({"cause": cause, "effect": effect})
|
|
144
|
+
|
|
145
|
+
results.append({
|
|
146
|
+
"text": text,
|
|
147
|
+
"causal": len(relations) > 0,
|
|
148
|
+
"relations": relations,
|
|
149
|
+
})
|
|
150
|
+
return results
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# Type alias for any entity extractor callable: text -> list of entity strings
|
|
154
|
+
ExtractorFn = Callable[[str], list[str]]
|
|
155
|
+
|
|
156
|
+
# Type alias for causal extractor: list[str] -> list[dict]
|
|
157
|
+
CausalExtractorFn = Callable[[list[str]], list[dict]]
|
reasongraph/_types.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Node:
|
|
9
|
+
"""A node in the reason graph."""
|
|
10
|
+
|
|
11
|
+
content: str
|
|
12
|
+
type: str = "text"
|
|
13
|
+
embedding: list[float] | None = None
|
|
14
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
15
|
+
last_accessed: datetime = field(default_factory=datetime.now)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Edge:
|
|
20
|
+
"""A directed edge between two nodes."""
|
|
21
|
+
|
|
22
|
+
from_content: str
|
|
23
|
+
to_content: str
|
|
24
|
+
last_accessed: datetime = field(default_factory=datetime.now)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from reasongraph._types import Node, Edge
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Backend(ABC):
|
|
9
|
+
"""Abstract base class for graph storage backends."""
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def initialize(self) -> None:
|
|
13
|
+
"""Create tables/schema if they don't exist."""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
async def close(self) -> None:
|
|
17
|
+
"""Release resources (connections, pools, etc.)."""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
async def insert_nodes(self, nodes: list[Node]) -> None:
|
|
21
|
+
"""Insert or upsert a batch of nodes (must have embeddings set)."""
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
async def insert_edges(self, edges: list[Edge]) -> None:
|
|
25
|
+
"""Insert a batch of edges, ignoring duplicates."""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
async def knn_search(
|
|
29
|
+
self, embedding: list[float], top_k: int
|
|
30
|
+
) -> list[dict[str, str]]:
|
|
31
|
+
"""Return the top_k closest nodes as dicts with 'content' and 'type' keys."""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
async def get_neighbors(self, content: str) -> list[dict[str, str]]:
|
|
35
|
+
"""Return all direct neighbors (both directions) as dicts with 'content' and 'type'."""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
async def delete_stale_nodes(self, days: int) -> int:
|
|
39
|
+
"""Delete nodes not accessed within the given number of days. Return count deleted."""
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
async def get_all_nodes(self) -> list[Node]:
|
|
43
|
+
"""Return every node in the graph."""
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
async def hybrid_search(
|
|
47
|
+
self, embedding: list[float], query_text: str, top_k: int,
|
|
48
|
+
rrf_k: int = 60, keyword_only: bool = False,
|
|
49
|
+
) -> list[dict[str, str]]:
|
|
50
|
+
"""Combined embedding + trigram search using Reciprocal Rank Fusion.
|
|
51
|
+
|
|
52
|
+
Each node is ranked independently by cosine similarity and by trigram
|
|
53
|
+
similarity. The final score is:
|
|
54
|
+
rrf_score(d) = 1/(rrf_k + rank_emb(d)) + 1/(rrf_k + rank_kw(d))
|
|
55
|
+
|
|
56
|
+
When keyword_only=True, nodes are ranked by trigram similarity alone
|
|
57
|
+
(no embedding component).
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
embedding: Query embedding vector.
|
|
61
|
+
query_text: Raw query string for trigram matching.
|
|
62
|
+
top_k: Number of results to return.
|
|
63
|
+
rrf_k: RRF smoothing constant (default 60).
|
|
64
|
+
keyword_only: If True, rank by trigram similarity only.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Top-k nodes as dicts with 'content' and 'type' keys.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
async def get_all_edges(self) -> list[Edge]:
|
|
72
|
+
"""Return every edge in the graph."""
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
|
|
5
|
+
from reasongraph._types import Node, Edge
|
|
6
|
+
from reasongraph.backends._base import Backend
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from psycopg_pool import AsyncConnectionPool
|
|
10
|
+
from pgvector.psycopg import register_vector_async
|
|
11
|
+
|
|
12
|
+
_HAS_POSTGRES = True
|
|
13
|
+
except ImportError:
|
|
14
|
+
_HAS_POSTGRES = False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PostgresBackend(Backend):
|
|
18
|
+
"""PostgreSQL + pgvector backend for scalable vector search.
|
|
19
|
+
|
|
20
|
+
Requires: pip install reasongraph[postgres]
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, database_url: str) -> None:
|
|
24
|
+
if not _HAS_POSTGRES:
|
|
25
|
+
raise ImportError(
|
|
26
|
+
"PostgreSQL dependencies not installed. "
|
|
27
|
+
"Install with: pip install reasongraph[postgres]"
|
|
28
|
+
)
|
|
29
|
+
self.database_url = database_url
|
|
30
|
+
self._pool: AsyncConnectionPool | None = None
|
|
31
|
+
|
|
32
|
+
async def _get_pool(self) -> AsyncConnectionPool:
|
|
33
|
+
if self._pool is None:
|
|
34
|
+
raise RuntimeError("Backend not initialized. Call initialize() first.")
|
|
35
|
+
return self._pool
|
|
36
|
+
|
|
37
|
+
async def initialize(self) -> None:
|
|
38
|
+
self._pool = AsyncConnectionPool(self.database_url, open=False)
|
|
39
|
+
await self._pool.open()
|
|
40
|
+
|
|
41
|
+
async with self._pool.connection() as conn:
|
|
42
|
+
await conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
|
43
|
+
await register_vector_async(conn)
|
|
44
|
+
|
|
45
|
+
await conn.execute("""
|
|
46
|
+
CREATE TABLE IF NOT EXISTS nodes (
|
|
47
|
+
content TEXT PRIMARY KEY,
|
|
48
|
+
embedding VECTOR(384) NOT NULL,
|
|
49
|
+
created_at TIMESTAMP DEFAULT NOW(),
|
|
50
|
+
last_accessed TIMESTAMP DEFAULT NOW(),
|
|
51
|
+
type TEXT NOT NULL CHECK (type IN ('text', 'entity'))
|
|
52
|
+
)
|
|
53
|
+
""")
|
|
54
|
+
|
|
55
|
+
await conn.execute("""
|
|
56
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
57
|
+
from_content TEXT NOT NULL REFERENCES nodes(content) ON DELETE CASCADE,
|
|
58
|
+
to_content TEXT NOT NULL REFERENCES nodes(content) ON DELETE CASCADE,
|
|
59
|
+
last_accessed TIMESTAMP DEFAULT NOW(),
|
|
60
|
+
UNIQUE(from_content, to_content)
|
|
61
|
+
)
|
|
62
|
+
""")
|
|
63
|
+
|
|
64
|
+
await conn.execute(
|
|
65
|
+
"CREATE INDEX IF NOT EXISTS edges_from_idx ON edges (from_content)"
|
|
66
|
+
)
|
|
67
|
+
await conn.execute(
|
|
68
|
+
"CREATE INDEX IF NOT EXISTS edges_to_idx ON edges (to_content)"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
async def close(self) -> None:
|
|
72
|
+
if self._pool is not None:
|
|
73
|
+
await self._pool.close()
|
|
74
|
+
self._pool = None
|
|
75
|
+
|
|
76
|
+
async def insert_nodes(self, nodes: list[Node]) -> None:
|
|
77
|
+
pool = await self._get_pool()
|
|
78
|
+
now = datetime.now()
|
|
79
|
+
async with pool.connection() as conn:
|
|
80
|
+
async with conn.cursor() as cur:
|
|
81
|
+
data = [
|
|
82
|
+
(node.content, node.embedding, node.created_at, now, node.type)
|
|
83
|
+
for node in nodes
|
|
84
|
+
]
|
|
85
|
+
await cur.executemany(
|
|
86
|
+
"""
|
|
87
|
+
INSERT INTO nodes (content, embedding, created_at, last_accessed, type)
|
|
88
|
+
VALUES (%s, %s, %s, %s, %s)
|
|
89
|
+
ON CONFLICT (content) DO UPDATE SET last_accessed = NOW()
|
|
90
|
+
""",
|
|
91
|
+
data,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
async def insert_edges(self, edges: list[Edge]) -> None:
|
|
95
|
+
pool = await self._get_pool()
|
|
96
|
+
async with pool.connection() as conn:
|
|
97
|
+
async with conn.cursor() as cur:
|
|
98
|
+
data = [(e.from_content, e.to_content) for e in edges]
|
|
99
|
+
await cur.executemany(
|
|
100
|
+
"""
|
|
101
|
+
INSERT INTO edges (from_content, to_content)
|
|
102
|
+
VALUES (%s, %s)
|
|
103
|
+
ON CONFLICT DO NOTHING
|
|
104
|
+
""",
|
|
105
|
+
data,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
async def knn_search(
|
|
109
|
+
self, embedding: list[float], top_k: int
|
|
110
|
+
) -> list[dict[str, str]]:
|
|
111
|
+
pool = await self._get_pool()
|
|
112
|
+
vec_str = f"[{', '.join(map(str, embedding))}]"
|
|
113
|
+
async with pool.connection() as conn:
|
|
114
|
+
async with conn.cursor() as cur:
|
|
115
|
+
await cur.execute(
|
|
116
|
+
f"""
|
|
117
|
+
SELECT content, type
|
|
118
|
+
FROM nodes
|
|
119
|
+
ORDER BY embedding <=> '{vec_str}'
|
|
120
|
+
LIMIT {top_k}
|
|
121
|
+
"""
|
|
122
|
+
)
|
|
123
|
+
rows = await cur.fetchall()
|
|
124
|
+
return [{"content": row[0], "type": row[1]} for row in rows]
|
|
125
|
+
|
|
126
|
+
async def hybrid_search(
|
|
127
|
+
self, embedding: list[float], query_text: str, top_k: int,
|
|
128
|
+
rrf_k: int = 60, keyword_only: bool = False,
|
|
129
|
+
) -> list[dict[str, str]]:
|
|
130
|
+
pool = await self._get_pool()
|
|
131
|
+
vec_str = f"[{', '.join(map(str, embedding))}]"
|
|
132
|
+
async with pool.connection() as conn:
|
|
133
|
+
await conn.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
|
|
134
|
+
async with conn.cursor() as cur:
|
|
135
|
+
if keyword_only:
|
|
136
|
+
await cur.execute(
|
|
137
|
+
"""
|
|
138
|
+
SELECT content, type
|
|
139
|
+
FROM nodes
|
|
140
|
+
ORDER BY similarity(content, %s) DESC
|
|
141
|
+
LIMIT %s
|
|
142
|
+
""",
|
|
143
|
+
(query_text, top_k),
|
|
144
|
+
)
|
|
145
|
+
return [
|
|
146
|
+
{"content": row[0], "type": row[1]}
|
|
147
|
+
for row in await cur.fetchall()
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
# RRF entirely in SQL using window functions
|
|
151
|
+
await cur.execute(
|
|
152
|
+
f"""
|
|
153
|
+
WITH emb_ranked AS (
|
|
154
|
+
SELECT content, type,
|
|
155
|
+
ROW_NUMBER() OVER (
|
|
156
|
+
ORDER BY embedding <=> '{vec_str}'
|
|
157
|
+
) AS rank
|
|
158
|
+
FROM nodes
|
|
159
|
+
),
|
|
160
|
+
kw_ranked AS (
|
|
161
|
+
SELECT content,
|
|
162
|
+
ROW_NUMBER() OVER (
|
|
163
|
+
ORDER BY similarity(content, %s) DESC
|
|
164
|
+
) AS rank
|
|
165
|
+
FROM nodes
|
|
166
|
+
)
|
|
167
|
+
SELECT e.content, e.type
|
|
168
|
+
FROM emb_ranked e
|
|
169
|
+
JOIN kw_ranked k ON e.content = k.content
|
|
170
|
+
ORDER BY 1.0 / (%s + e.rank) + 1.0 / (%s + k.rank) DESC
|
|
171
|
+
LIMIT %s
|
|
172
|
+
""",
|
|
173
|
+
(query_text, rrf_k, rrf_k, top_k),
|
|
174
|
+
)
|
|
175
|
+
return [
|
|
176
|
+
{"content": row[0], "type": row[1]}
|
|
177
|
+
for row in await cur.fetchall()
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
async def get_neighbors(self, content: str) -> list[dict[str, str]]:
|
|
181
|
+
pool = await self._get_pool()
|
|
182
|
+
async with pool.connection() as conn:
|
|
183
|
+
async with conn.cursor() as cur:
|
|
184
|
+
await cur.execute(
|
|
185
|
+
"""
|
|
186
|
+
SELECT DISTINCT n.content, n.type FROM nodes n
|
|
187
|
+
INNER JOIN edges e ON (e.to_content = n.content AND e.from_content = %s)
|
|
188
|
+
OR (e.from_content = n.content AND e.to_content = %s)
|
|
189
|
+
""",
|
|
190
|
+
(content, content),
|
|
191
|
+
)
|
|
192
|
+
return [
|
|
193
|
+
{"content": row[0], "type": row[1]}
|
|
194
|
+
for row in await cur.fetchall()
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
async def delete_stale_nodes(self, days: int) -> int:
|
|
198
|
+
pool = await self._get_pool()
|
|
199
|
+
cutoff = datetime.now() - timedelta(days=days)
|
|
200
|
+
async with pool.connection() as conn:
|
|
201
|
+
async with conn.cursor() as cur:
|
|
202
|
+
await cur.execute(
|
|
203
|
+
"DELETE FROM nodes WHERE last_accessed < %s", (cutoff,)
|
|
204
|
+
)
|
|
205
|
+
return cur.rowcount
|
|
206
|
+
|
|
207
|
+
async def get_all_nodes(self) -> list[Node]:
|
|
208
|
+
pool = await self._get_pool()
|
|
209
|
+
async with pool.connection() as conn:
|
|
210
|
+
async with conn.cursor() as cur:
|
|
211
|
+
await cur.execute(
|
|
212
|
+
"SELECT content, type, embedding, created_at, last_accessed FROM nodes"
|
|
213
|
+
)
|
|
214
|
+
nodes = []
|
|
215
|
+
for content, node_type, emb, created_at, last_accessed in await cur.fetchall():
|
|
216
|
+
nodes.append(Node(
|
|
217
|
+
content=content,
|
|
218
|
+
type=node_type,
|
|
219
|
+
embedding=list(emb) if emb else None,
|
|
220
|
+
created_at=created_at,
|
|
221
|
+
last_accessed=last_accessed,
|
|
222
|
+
))
|
|
223
|
+
return nodes
|
|
224
|
+
|
|
225
|
+
async def get_all_edges(self) -> list[Edge]:
|
|
226
|
+
pool = await self._get_pool()
|
|
227
|
+
async with pool.connection() as conn:
|
|
228
|
+
async with conn.cursor() as cur:
|
|
229
|
+
await cur.execute(
|
|
230
|
+
"SELECT from_content, to_content, last_accessed FROM edges"
|
|
231
|
+
)
|
|
232
|
+
return [
|
|
233
|
+
Edge(from_content=row[0], to_content=row[1], last_accessed=row[2])
|
|
234
|
+
for row in await cur.fetchall()
|
|
235
|
+
]
|