aethergraph 0.1.0a2__py3-none-any.whl → 0.1.0a4__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.
- aethergraph/__main__.py +3 -0
- aethergraph/api/v1/artifacts.py +23 -4
- aethergraph/api/v1/schemas.py +7 -0
- aethergraph/api/v1/session.py +123 -4
- aethergraph/config/config.py +2 -0
- aethergraph/config/search.py +49 -0
- aethergraph/contracts/services/channel.py +18 -1
- aethergraph/contracts/services/execution.py +58 -0
- aethergraph/contracts/services/llm.py +26 -0
- aethergraph/contracts/services/memory.py +10 -4
- aethergraph/contracts/services/planning.py +53 -0
- aethergraph/contracts/storage/event_log.py +8 -0
- aethergraph/contracts/storage/search_backend.py +47 -0
- aethergraph/contracts/storage/vector_index.py +73 -0
- aethergraph/core/graph/action_spec.py +76 -0
- aethergraph/core/graph/graph_fn.py +75 -2
- aethergraph/core/graph/graphify.py +74 -2
- aethergraph/core/runtime/graph_runner.py +2 -1
- aethergraph/core/runtime/node_context.py +66 -3
- aethergraph/core/runtime/node_services.py +8 -0
- aethergraph/core/runtime/run_manager.py +263 -271
- aethergraph/core/runtime/run_types.py +54 -1
- aethergraph/core/runtime/runtime_env.py +35 -14
- aethergraph/core/runtime/runtime_services.py +308 -18
- aethergraph/plugins/agents/default_chat_agent.py +266 -74
- aethergraph/plugins/agents/default_chat_agent_v2.py +487 -0
- aethergraph/plugins/channel/adapters/webui.py +69 -21
- aethergraph/plugins/channel/routes/webui_routes.py +8 -48
- aethergraph/runtime/__init__.py +12 -0
- aethergraph/server/app_factory.py +10 -1
- aethergraph/server/ui_static/assets/index-CFktGdbW.js +4913 -0
- aethergraph/server/ui_static/assets/index-DcfkFlTA.css +1 -0
- aethergraph/server/ui_static/index.html +2 -2
- aethergraph/services/artifacts/facade.py +157 -21
- aethergraph/services/artifacts/types.py +35 -0
- aethergraph/services/artifacts/utils.py +42 -0
- aethergraph/services/channel/channel_bus.py +3 -1
- aethergraph/services/channel/event_hub copy.py +55 -0
- aethergraph/services/channel/event_hub.py +81 -0
- aethergraph/services/channel/factory.py +3 -2
- aethergraph/services/channel/session.py +709 -74
- aethergraph/services/container/default_container.py +69 -7
- aethergraph/services/execution/__init__.py +0 -0
- aethergraph/services/execution/local_python.py +118 -0
- aethergraph/services/indices/__init__.py +0 -0
- aethergraph/services/indices/global_indices.py +21 -0
- aethergraph/services/indices/scoped_indices.py +292 -0
- aethergraph/services/llm/generic_client.py +342 -46
- aethergraph/services/llm/generic_embed_client.py +359 -0
- aethergraph/services/llm/types.py +3 -1
- aethergraph/services/memory/distillers/llm_long_term.py +60 -109
- aethergraph/services/memory/distillers/llm_long_term_v1.py +180 -0
- aethergraph/services/memory/distillers/llm_meta_summary.py +57 -266
- aethergraph/services/memory/distillers/llm_meta_summary_v1.py +342 -0
- aethergraph/services/memory/distillers/long_term.py +48 -131
- aethergraph/services/memory/distillers/long_term_v1.py +170 -0
- aethergraph/services/memory/facade/chat.py +18 -8
- aethergraph/services/memory/facade/core.py +159 -19
- aethergraph/services/memory/facade/distillation.py +86 -31
- aethergraph/services/memory/facade/retrieval.py +100 -1
- aethergraph/services/memory/factory.py +4 -1
- aethergraph/services/planning/__init__.py +0 -0
- aethergraph/services/planning/action_catalog.py +271 -0
- aethergraph/services/planning/bindings.py +56 -0
- aethergraph/services/planning/dependency_index.py +65 -0
- aethergraph/services/planning/flow_validator.py +263 -0
- aethergraph/services/planning/graph_io_adapter.py +150 -0
- aethergraph/services/planning/input_parser.py +312 -0
- aethergraph/services/planning/missing_inputs.py +28 -0
- aethergraph/services/planning/node_planner.py +613 -0
- aethergraph/services/planning/orchestrator.py +112 -0
- aethergraph/services/planning/plan_executor.py +506 -0
- aethergraph/services/planning/plan_types.py +321 -0
- aethergraph/services/planning/planner.py +617 -0
- aethergraph/services/planning/planner_service.py +369 -0
- aethergraph/services/planning/planning_context_builder.py +43 -0
- aethergraph/services/planning/quick_actions.py +29 -0
- aethergraph/services/planning/routers/__init__.py +0 -0
- aethergraph/services/planning/routers/simple_router.py +26 -0
- aethergraph/services/rag/facade.py +0 -3
- aethergraph/services/scope/scope.py +30 -30
- aethergraph/services/scope/scope_factory.py +15 -7
- aethergraph/services/skills/__init__.py +0 -0
- aethergraph/services/skills/skill_registry.py +465 -0
- aethergraph/services/skills/skills.py +220 -0
- aethergraph/services/skills/utils.py +194 -0
- aethergraph/storage/artifacts/artifact_index_jsonl.py +16 -10
- aethergraph/storage/artifacts/artifact_index_sqlite.py +12 -2
- aethergraph/storage/docstore/sqlite_doc_sync.py +1 -1
- aethergraph/storage/memory/event_persist.py +42 -2
- aethergraph/storage/memory/fs_persist.py +32 -2
- aethergraph/storage/search_backend/__init__.py +0 -0
- aethergraph/storage/search_backend/generic_vector_backend.py +230 -0
- aethergraph/storage/search_backend/null_backend.py +34 -0
- aethergraph/storage/search_backend/sqlite_lexical_backend.py +387 -0
- aethergraph/storage/search_backend/utils.py +31 -0
- aethergraph/storage/search_factory.py +75 -0
- aethergraph/storage/vector_index/faiss_index.py +72 -4
- aethergraph/storage/vector_index/sqlite_index.py +521 -52
- aethergraph/storage/vector_index/sqlite_index_vanila.py +311 -0
- aethergraph/storage/vector_index/utils.py +22 -0
- {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/METADATA +1 -1
- {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/RECORD +108 -64
- {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/WHEEL +1 -1
- aethergraph/plugins/agents/default_chat_agent copy.py +0 -90
- aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +0 -1
- aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +0 -400
- aethergraph/services/eventhub/event_hub.py +0 -76
- aethergraph/services/llm/generic_client copy.py +0 -691
- aethergraph/services/prompts/file_store.py +0 -41
- {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/entry_points.txt +0 -0
- {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/LICENSE +0 -0
- {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/NOTICE +0 -0
- {aethergraph-0.1.0a2.dist-info → aethergraph-0.1.0a4.dist-info}/top_level.txt +0 -0
|
@@ -3,13 +3,21 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import json
|
|
5
5
|
from pathlib import Path
|
|
6
|
+
import pickle
|
|
6
7
|
import sqlite3
|
|
8
|
+
import threading
|
|
7
9
|
from typing import Any
|
|
8
10
|
|
|
9
11
|
import numpy as np
|
|
10
12
|
|
|
11
13
|
from aethergraph.contracts.storage.vector_index import VectorIndex
|
|
12
14
|
|
|
15
|
+
try:
|
|
16
|
+
import faiss # type: ignore
|
|
17
|
+
except Exception:
|
|
18
|
+
faiss = None
|
|
19
|
+
|
|
20
|
+
|
|
13
21
|
SCHEMA = """
|
|
14
22
|
CREATE TABLE IF NOT EXISTS chunks (
|
|
15
23
|
corpus_id TEXT,
|
|
@@ -17,45 +25,234 @@ CREATE TABLE IF NOT EXISTS chunks (
|
|
|
17
25
|
meta_json TEXT,
|
|
18
26
|
PRIMARY KEY (corpus_id, chunk_id)
|
|
19
27
|
);
|
|
28
|
+
|
|
20
29
|
CREATE TABLE IF NOT EXISTS embeddings (
|
|
21
|
-
corpus_id
|
|
22
|
-
chunk_id
|
|
23
|
-
vec
|
|
24
|
-
norm
|
|
30
|
+
corpus_id TEXT,
|
|
31
|
+
chunk_id TEXT,
|
|
32
|
+
vec BLOB, -- np.float32 array bytes
|
|
33
|
+
norm REAL,
|
|
34
|
+
-- promoted / hot fields
|
|
35
|
+
scope_id TEXT,
|
|
36
|
+
user_id TEXT,
|
|
37
|
+
org_id TEXT,
|
|
38
|
+
client_id TEXT,
|
|
39
|
+
session_id TEXT,
|
|
40
|
+
run_id TEXT,
|
|
41
|
+
graph_id TEXT,
|
|
42
|
+
node_id TEXT,
|
|
43
|
+
kind TEXT,
|
|
44
|
+
source TEXT,
|
|
45
|
+
created_at_ts REAL,
|
|
25
46
|
PRIMARY KEY (corpus_id, chunk_id)
|
|
26
47
|
);
|
|
48
|
+
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_emb_corpus_scope_time
|
|
50
|
+
ON embeddings(corpus_id, scope_id, created_at_ts DESC);
|
|
51
|
+
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_emb_corpus_user_time
|
|
53
|
+
ON embeddings(corpus_id, user_id, created_at_ts DESC);
|
|
54
|
+
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_emb_corpus_org_time
|
|
56
|
+
ON embeddings(corpus_id, org_id, created_at_ts DESC);
|
|
57
|
+
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_emb_corpus_kind_time
|
|
59
|
+
ON embeddings(corpus_id, kind, created_at_ts DESC);
|
|
27
60
|
"""
|
|
28
61
|
|
|
29
62
|
|
|
30
63
|
def _ensure_db(path: str) -> None:
|
|
31
64
|
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
-
conn = sqlite3.connect(path)
|
|
65
|
+
conn = sqlite3.connect(path, check_same_thread=False)
|
|
33
66
|
try:
|
|
34
|
-
|
|
67
|
+
cur = conn.cursor()
|
|
68
|
+
for stmt in SCHEMA.strip().split(";\n\n"):
|
|
35
69
|
s = stmt.strip()
|
|
36
70
|
if s:
|
|
37
|
-
|
|
71
|
+
cur.execute(s)
|
|
38
72
|
conn.commit()
|
|
39
73
|
finally:
|
|
40
74
|
conn.close()
|
|
41
75
|
|
|
42
76
|
|
|
77
|
+
def _l2_normalize_rows(x: np.ndarray, eps: float = 1e-12) -> np.ndarray:
|
|
78
|
+
# x: (n, d)
|
|
79
|
+
norms = np.linalg.norm(x, axis=1, keepdims=True)
|
|
80
|
+
norms = np.maximum(norms, eps)
|
|
81
|
+
return x / norms
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _l2_normalize_vec(x: np.ndarray, eps: float = 1e-12) -> np.ndarray:
|
|
85
|
+
n = float(np.linalg.norm(x))
|
|
86
|
+
if n < eps:
|
|
87
|
+
return x
|
|
88
|
+
return x / n
|
|
89
|
+
|
|
90
|
+
|
|
43
91
|
class SQLiteVectorIndex(VectorIndex):
|
|
44
92
|
"""
|
|
45
93
|
Simple SQLite-backed vector index.
|
|
46
|
-
|
|
94
|
+
|
|
95
|
+
Baseline path uses brute-force cosine similarity over SQL-limited candidates. :contentReference[oaicite:1]{index=1}
|
|
96
|
+
|
|
97
|
+
Optional FAISS acceleration:
|
|
98
|
+
- If faiss is installed and enabled, maintains a per-corpus FAISS HNSW index on disk.
|
|
99
|
+
- Index is marked dirty on add/delete and rebuilt lazily on next search. NOTE: this can be slow for large corpora.
|
|
100
|
+
- This is a local index for small to medium workloads; for distributed or large-scale use cases, consider other backends.
|
|
101
|
+
|
|
102
|
+
Promoted fields you *may* pass in meta: :contentReference[oaicite:2]{index=2}
|
|
103
|
+
- scope_id, user_id, org_id, client_id, session_id
|
|
104
|
+
- run_id, graph_id, node_id
|
|
105
|
+
- kind, source
|
|
106
|
+
- created_at_ts (float UNIX timestamp)
|
|
47
107
|
"""
|
|
48
108
|
|
|
49
|
-
def __init__(
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
root: str,
|
|
112
|
+
*,
|
|
113
|
+
use_faiss_if_available: bool = True,
|
|
114
|
+
faiss_m: int = 32, # HNSW M
|
|
115
|
+
faiss_ef_search: int = 64, # query-time accuracy/speed knob
|
|
116
|
+
faiss_ef_construction: int = 200, # build-time accuracy/speed knob
|
|
117
|
+
faiss_probe_factor: int = 20, # fetch k * factor candidates then post-filter
|
|
118
|
+
faiss_probe_min: int = 200,
|
|
119
|
+
faiss_probe_max: int = 5000,
|
|
120
|
+
brute_force_candidate_limit: int = 5000,
|
|
121
|
+
):
|
|
50
122
|
self.root = Path(root)
|
|
51
123
|
self.root.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
|
|
52
125
|
self.db_path = str(self.root / "index.sqlite")
|
|
53
126
|
_ensure_db(self.db_path)
|
|
54
127
|
|
|
128
|
+
# --- FAISS config ---
|
|
129
|
+
self._faiss_enabled = bool(use_faiss_if_available and faiss is not None)
|
|
130
|
+
self._faiss_dir = self.root / "faiss"
|
|
131
|
+
self._faiss_dir.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
|
|
133
|
+
self._faiss_m = int(faiss_m)
|
|
134
|
+
self._faiss_ef_search = int(faiss_ef_search)
|
|
135
|
+
self._faiss_ef_construction = int(faiss_ef_construction)
|
|
136
|
+
self._faiss_probe_factor = int(faiss_probe_factor)
|
|
137
|
+
self._faiss_probe_min = int(faiss_probe_min)
|
|
138
|
+
self._faiss_probe_max = int(faiss_probe_max)
|
|
139
|
+
|
|
140
|
+
self._brute_force_candidate_limit = int(brute_force_candidate_limit)
|
|
141
|
+
|
|
142
|
+
self._faiss_lock = threading.RLock()
|
|
143
|
+
self._faiss_cache: dict[str, tuple[Any, list[str], int]] = {}
|
|
144
|
+
# cache: corpus_id -> (faiss_index, id_to_chunk_id, dim)
|
|
145
|
+
self._faiss_dirty: set[str] = set()
|
|
146
|
+
|
|
55
147
|
def _connect(self) -> sqlite3.Connection:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
148
|
+
return sqlite3.connect(self.db_path, check_same_thread=False)
|
|
149
|
+
|
|
150
|
+
def _faiss_paths(self, corpus_id: str) -> tuple[Path, Path]:
|
|
151
|
+
safe = corpus_id.replace("/", "_")
|
|
152
|
+
return (self._faiss_dir / f"{safe}.index", self._faiss_dir / f"{safe}.meta.pkl")
|
|
153
|
+
|
|
154
|
+
def _mark_dirty(self, corpus_id: str) -> None:
|
|
155
|
+
if not self._faiss_enabled:
|
|
156
|
+
return
|
|
157
|
+
with self._faiss_lock:
|
|
158
|
+
self._faiss_dirty.add(corpus_id)
|
|
159
|
+
self._faiss_cache.pop(corpus_id, None)
|
|
160
|
+
|
|
161
|
+
def _build_faiss_index_from_db(self, corpus_id: str) -> tuple[Any, list[str], int]:
|
|
162
|
+
"""
|
|
163
|
+
Build an HNSW cosine index for all vectors in a corpus.
|
|
164
|
+
|
|
165
|
+
We normalize vectors and use inner product (IP) => cosine similarity.
|
|
166
|
+
"""
|
|
167
|
+
if not self._faiss_enabled:
|
|
168
|
+
raise RuntimeError("FAISS is not enabled/available.")
|
|
169
|
+
|
|
170
|
+
conn = self._connect()
|
|
171
|
+
try:
|
|
172
|
+
cur = conn.cursor()
|
|
173
|
+
cur.execute("SELECT chunk_id, vec FROM embeddings WHERE corpus_id=?", (corpus_id,))
|
|
174
|
+
rows = cur.fetchall()
|
|
175
|
+
finally:
|
|
176
|
+
conn.close()
|
|
177
|
+
|
|
178
|
+
if not rows:
|
|
179
|
+
# empty corpus index
|
|
180
|
+
dim = 0
|
|
181
|
+
index = None
|
|
182
|
+
return index, [], dim
|
|
183
|
+
|
|
184
|
+
# Infer dim from first vector
|
|
185
|
+
first_vec = np.frombuffer(rows[0][1], dtype=np.float32)
|
|
186
|
+
dim = int(first_vec.shape[0])
|
|
187
|
+
|
|
188
|
+
# Base HNSW (IP metric), wrapped with ID map so ids are stable ints
|
|
189
|
+
base = faiss.IndexHNSWFlat(dim, self._faiss_m, faiss.METRIC_INNER_PRODUCT)
|
|
190
|
+
base.hnsw.efConstruction = self._faiss_ef_construction
|
|
191
|
+
base.hnsw.efSearch = self._faiss_ef_search
|
|
192
|
+
index = faiss.IndexIDMap2(base)
|
|
193
|
+
|
|
194
|
+
id_to_chunk: list[str] = []
|
|
195
|
+
next_id = 0
|
|
196
|
+
|
|
197
|
+
# Add in batches to keep memory reasonable
|
|
198
|
+
batch_size = 2048
|
|
199
|
+
for i in range(0, len(rows), batch_size):
|
|
200
|
+
batch = rows[i : i + batch_size]
|
|
201
|
+
chunk_ids = [r[0] for r in batch]
|
|
202
|
+
mats = [np.frombuffer(r[1], dtype=np.float32) for r in batch]
|
|
203
|
+
x = np.stack(mats, axis=0).astype(np.float32, copy=False)
|
|
204
|
+
x = _l2_normalize_rows(x)
|
|
205
|
+
|
|
206
|
+
ids = np.arange(next_id, next_id + len(chunk_ids), dtype=np.int64)
|
|
207
|
+
index.add_with_ids(x, ids)
|
|
208
|
+
|
|
209
|
+
id_to_chunk.extend(chunk_ids)
|
|
210
|
+
next_id += len(chunk_ids)
|
|
211
|
+
|
|
212
|
+
return index, id_to_chunk, dim
|
|
213
|
+
|
|
214
|
+
def _ensure_faiss_ready(self, corpus_id: str) -> tuple[Any, list[str], int]:
|
|
215
|
+
if not self._faiss_enabled:
|
|
216
|
+
raise RuntimeError("FAISS is not enabled/available.")
|
|
217
|
+
|
|
218
|
+
with self._faiss_lock:
|
|
219
|
+
cached = self._faiss_cache.get(corpus_id)
|
|
220
|
+
if cached is not None and corpus_id not in self._faiss_dirty:
|
|
221
|
+
return cached
|
|
222
|
+
|
|
223
|
+
index_path, meta_path = self._faiss_paths(corpus_id)
|
|
224
|
+
|
|
225
|
+
# If not dirty and files exist, load from disk
|
|
226
|
+
if corpus_id not in self._faiss_dirty and index_path.exists() and meta_path.exists():
|
|
227
|
+
index = faiss.read_index(str(index_path))
|
|
228
|
+
with meta_path.open("rb") as f:
|
|
229
|
+
meta = pickle.load(f)
|
|
230
|
+
id_to_chunk = meta["id_to_chunk"]
|
|
231
|
+
dim = int(meta["dim"])
|
|
232
|
+
# Ensure query-time params applied
|
|
233
|
+
try:
|
|
234
|
+
index.index.hnsw.efSearch = self._faiss_ef_search # type: ignore[attr-defined]
|
|
235
|
+
except Exception:
|
|
236
|
+
import logging
|
|
237
|
+
|
|
238
|
+
logger = logging.getLogger(__name__)
|
|
239
|
+
logger.warning("Failed to set faiss efSearch parameter.")
|
|
240
|
+
|
|
241
|
+
self._faiss_cache[corpus_id] = (index, id_to_chunk, dim)
|
|
242
|
+
return index, id_to_chunk, dim
|
|
243
|
+
|
|
244
|
+
# Otherwise rebuild from DB
|
|
245
|
+
index, id_to_chunk, dim = self._build_faiss_index_from_db(corpus_id)
|
|
246
|
+
|
|
247
|
+
# Persist (if non-empty)
|
|
248
|
+
if index is not None:
|
|
249
|
+
faiss.write_index(index, str(index_path))
|
|
250
|
+
with meta_path.open("wb") as f:
|
|
251
|
+
pickle.dump({"id_to_chunk": id_to_chunk, "dim": dim}, f)
|
|
252
|
+
|
|
253
|
+
self._faiss_dirty.discard(corpus_id)
|
|
254
|
+
self._faiss_cache[corpus_id] = (index, id_to_chunk, dim)
|
|
255
|
+
return index, id_to_chunk, dim
|
|
59
256
|
|
|
60
257
|
async def add(
|
|
61
258
|
self,
|
|
@@ -74,18 +271,72 @@ class SQLiteVectorIndex(VectorIndex):
|
|
|
74
271
|
for cid, vec, meta in zip(chunk_ids, vectors, metas, strict=True):
|
|
75
272
|
v = np.asarray(vec, dtype=np.float32)
|
|
76
273
|
norm = float(np.linalg.norm(v) + 1e-9)
|
|
274
|
+
|
|
275
|
+
meta_json = json.dumps(meta, ensure_ascii=False)
|
|
276
|
+
|
|
277
|
+
# promoted, optional
|
|
278
|
+
scope_id = meta.get("scope_id")
|
|
279
|
+
user_id = meta.get("user_id")
|
|
280
|
+
org_id = meta.get("org_id")
|
|
281
|
+
client_id = meta.get("client_id")
|
|
282
|
+
session_id = meta.get("session_id")
|
|
283
|
+
run_id = meta.get("run_id")
|
|
284
|
+
graph_id = meta.get("graph_id")
|
|
285
|
+
node_id = meta.get("node_id")
|
|
286
|
+
kind = meta.get("kind")
|
|
287
|
+
source = meta.get("source")
|
|
288
|
+
created_at_ts = meta.get("created_at_ts")
|
|
289
|
+
|
|
77
290
|
cur.execute(
|
|
78
291
|
"REPLACE INTO chunks(corpus_id,chunk_id,meta_json) VALUES(?,?,?)",
|
|
79
|
-
(corpus_id, cid,
|
|
292
|
+
(corpus_id, cid, meta_json),
|
|
80
293
|
)
|
|
81
294
|
cur.execute(
|
|
82
|
-
"
|
|
83
|
-
|
|
295
|
+
"""
|
|
296
|
+
REPLACE INTO embeddings(
|
|
297
|
+
corpus_id,
|
|
298
|
+
chunk_id,
|
|
299
|
+
vec,
|
|
300
|
+
norm,
|
|
301
|
+
scope_id,
|
|
302
|
+
user_id,
|
|
303
|
+
org_id,
|
|
304
|
+
client_id,
|
|
305
|
+
session_id,
|
|
306
|
+
run_id,
|
|
307
|
+
graph_id,
|
|
308
|
+
node_id,
|
|
309
|
+
kind,
|
|
310
|
+
source,
|
|
311
|
+
created_at_ts
|
|
312
|
+
)
|
|
313
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
314
|
+
""",
|
|
315
|
+
(
|
|
316
|
+
corpus_id,
|
|
317
|
+
cid,
|
|
318
|
+
v.tobytes(),
|
|
319
|
+
norm,
|
|
320
|
+
scope_id,
|
|
321
|
+
user_id,
|
|
322
|
+
org_id,
|
|
323
|
+
client_id,
|
|
324
|
+
session_id,
|
|
325
|
+
run_id,
|
|
326
|
+
graph_id,
|
|
327
|
+
node_id,
|
|
328
|
+
kind,
|
|
329
|
+
source,
|
|
330
|
+
created_at_ts,
|
|
331
|
+
),
|
|
84
332
|
)
|
|
85
333
|
conn.commit()
|
|
86
334
|
finally:
|
|
87
335
|
conn.close()
|
|
88
336
|
|
|
337
|
+
# Mark FAISS corpus index dirty (lazy rebuild on next search)
|
|
338
|
+
self._mark_dirty(corpus_id)
|
|
339
|
+
|
|
89
340
|
await asyncio.to_thread(_add_sync)
|
|
90
341
|
|
|
91
342
|
async def delete(self, corpus_id: str, chunk_ids: list[str] | None = None) -> None:
|
|
@@ -110,6 +361,9 @@ class SQLiteVectorIndex(VectorIndex):
|
|
|
110
361
|
finally:
|
|
111
362
|
conn.close()
|
|
112
363
|
|
|
364
|
+
# Mark FAISS corpus index dirty (lazy rebuild on next search)
|
|
365
|
+
self._mark_dirty(corpus_id)
|
|
366
|
+
|
|
113
367
|
await asyncio.to_thread(_delete_sync)
|
|
114
368
|
|
|
115
369
|
async def list_chunks(self, corpus_id: str) -> list[str]:
|
|
@@ -136,52 +390,267 @@ class SQLiteVectorIndex(VectorIndex):
|
|
|
136
390
|
|
|
137
391
|
return await asyncio.to_thread(_list_sync)
|
|
138
392
|
|
|
393
|
+
def _passes_where(self, where: dict[str, Any], row: dict[str, Any]) -> bool:
|
|
394
|
+
# where only applies to promoted fields in your SQL path :contentReference[oaicite:3]{index=3}
|
|
395
|
+
# Here, we enforce the same semantics post-hoc.
|
|
396
|
+
for k, v in where.items():
|
|
397
|
+
if v is None:
|
|
398
|
+
continue
|
|
399
|
+
if row.get(k) != v:
|
|
400
|
+
return False
|
|
401
|
+
return True
|
|
402
|
+
|
|
403
|
+
def _fetch_rows_for_chunk_ids(
|
|
404
|
+
self,
|
|
405
|
+
conn: sqlite3.Connection,
|
|
406
|
+
corpus_id: str,
|
|
407
|
+
chunk_ids: list[str],
|
|
408
|
+
) -> dict[str, tuple[str, float | None]]:
|
|
409
|
+
"""
|
|
410
|
+
Returns {chunk_id: (meta_json, created_at_ts)} for given chunk_ids.
|
|
411
|
+
Batched to avoid SQLite parameter limits.
|
|
412
|
+
"""
|
|
413
|
+
out: dict[str, tuple[str, float | None]] = {}
|
|
414
|
+
cur = conn.cursor()
|
|
415
|
+
|
|
416
|
+
# SQLite default param limit is often 999, keep headroom
|
|
417
|
+
batch_size = 900
|
|
418
|
+
for i in range(0, len(chunk_ids), batch_size):
|
|
419
|
+
b = chunk_ids[i : i + batch_size]
|
|
420
|
+
placeholders = ",".join("?" for _ in b)
|
|
421
|
+
sql = f"""
|
|
422
|
+
SELECT e.chunk_id, c.meta_json, e.created_at_ts
|
|
423
|
+
FROM embeddings e
|
|
424
|
+
JOIN chunks c
|
|
425
|
+
ON e.corpus_id = c.corpus_id AND e.chunk_id = c.chunk_id
|
|
426
|
+
WHERE e.corpus_id = ?
|
|
427
|
+
AND e.chunk_id IN ({placeholders})
|
|
428
|
+
"""
|
|
429
|
+
cur.execute(sql, [corpus_id, *b])
|
|
430
|
+
for cid, meta_json, created_at_ts in cur.fetchall():
|
|
431
|
+
out[str(cid)] = (str(meta_json), created_at_ts)
|
|
432
|
+
return out
|
|
433
|
+
|
|
434
|
+
def _search_bruteforce_sync(
|
|
435
|
+
self,
|
|
436
|
+
corpus_id: str,
|
|
437
|
+
q: np.ndarray,
|
|
438
|
+
k: int,
|
|
439
|
+
where: dict[str, Any],
|
|
440
|
+
max_candidates: int | None,
|
|
441
|
+
created_at_min: float | None,
|
|
442
|
+
created_at_max: float | None,
|
|
443
|
+
) -> list[dict[str, Any]]:
|
|
444
|
+
# This is your original SQL-candidate path (kept as a fallback). :contentReference[oaicite:4]{index=4}
|
|
445
|
+
qn = float(np.linalg.norm(q) + 1e-9)
|
|
446
|
+
|
|
447
|
+
conn = self._connect()
|
|
448
|
+
try:
|
|
449
|
+
cur = conn.cursor()
|
|
450
|
+
|
|
451
|
+
sql = """
|
|
452
|
+
SELECT e.chunk_id, e.vec, e.norm, c.meta_json
|
|
453
|
+
FROM embeddings e
|
|
454
|
+
JOIN chunks c
|
|
455
|
+
ON e.corpus_id = c.corpus_id AND e.chunk_id = c.chunk_id
|
|
456
|
+
WHERE e.corpus_id=?
|
|
457
|
+
"""
|
|
458
|
+
params: list[Any] = [corpus_id]
|
|
459
|
+
|
|
460
|
+
promoted_cols = {
|
|
461
|
+
"scope_id",
|
|
462
|
+
"user_id",
|
|
463
|
+
"org_id",
|
|
464
|
+
"client_id",
|
|
465
|
+
"session_id",
|
|
466
|
+
"run_id",
|
|
467
|
+
"graph_id",
|
|
468
|
+
"node_id",
|
|
469
|
+
"kind",
|
|
470
|
+
"source",
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
for key, val in where.items():
|
|
474
|
+
if val is None:
|
|
475
|
+
continue
|
|
476
|
+
if key in promoted_cols:
|
|
477
|
+
sql += f" AND e.{key} = ?"
|
|
478
|
+
params.append(val)
|
|
479
|
+
|
|
480
|
+
if created_at_min is not None:
|
|
481
|
+
sql += " AND e.created_at_ts >= ?"
|
|
482
|
+
params.append(created_at_min)
|
|
483
|
+
if created_at_max is not None:
|
|
484
|
+
sql += " AND e.created_at_ts <= ?"
|
|
485
|
+
params.append(created_at_max)
|
|
486
|
+
|
|
487
|
+
candidate_limit = max_candidates or self._brute_force_candidate_limit
|
|
488
|
+
sql += " ORDER BY e.created_at_ts DESC"
|
|
489
|
+
sql += " LIMIT ?"
|
|
490
|
+
params.append(candidate_limit)
|
|
491
|
+
|
|
492
|
+
cur.execute(sql, params)
|
|
493
|
+
rows = cur.fetchall()
|
|
494
|
+
finally:
|
|
495
|
+
conn.close()
|
|
496
|
+
|
|
497
|
+
# Minor speedup: avoid json.loads until after top-k
|
|
498
|
+
scored: list[tuple[float, str, str]] = []
|
|
499
|
+
for chunk_id, vec_bytes, norm, meta_json in rows:
|
|
500
|
+
v = np.frombuffer(vec_bytes, dtype=np.float32)
|
|
501
|
+
score = float(np.dot(q, v) / (qn * (norm or 1e-9)))
|
|
502
|
+
scored.append((score, str(chunk_id), str(meta_json)))
|
|
503
|
+
|
|
504
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
505
|
+
top = scored[:k]
|
|
506
|
+
|
|
507
|
+
out: list[dict[str, Any]] = []
|
|
508
|
+
for score, chunk_id, meta_json in top:
|
|
509
|
+
out.append({"chunk_id": chunk_id, "score": score, "meta": json.loads(meta_json)})
|
|
510
|
+
return out
|
|
511
|
+
|
|
512
|
+
def _search_faiss_sync(
|
|
513
|
+
self,
|
|
514
|
+
corpus_id: str,
|
|
515
|
+
q: np.ndarray,
|
|
516
|
+
k: int,
|
|
517
|
+
where: dict[str, Any],
|
|
518
|
+
created_at_min: float | None,
|
|
519
|
+
created_at_max: float | None,
|
|
520
|
+
max_candidates: int | None,
|
|
521
|
+
) -> list[dict[str, Any]]:
|
|
522
|
+
# If max_candidates is set very small, caller probably expects strict recency-bounded behavior.
|
|
523
|
+
# In that case, keep the old semantics.
|
|
524
|
+
if max_candidates is not None and max_candidates <= self._brute_force_candidate_limit:
|
|
525
|
+
return self._search_bruteforce_sync(
|
|
526
|
+
corpus_id=corpus_id,
|
|
527
|
+
q=q,
|
|
528
|
+
k=k,
|
|
529
|
+
where=where,
|
|
530
|
+
max_candidates=max_candidates,
|
|
531
|
+
created_at_min=created_at_min,
|
|
532
|
+
created_at_max=created_at_max,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
index, id_to_chunk, dim = self._ensure_faiss_ready(corpus_id)
|
|
536
|
+
if index is None or dim <= 0 or not id_to_chunk:
|
|
537
|
+
return []
|
|
538
|
+
|
|
539
|
+
if q.shape[0] != dim:
|
|
540
|
+
# Dim mismatch: fall back to brute-force rather than throwing.
|
|
541
|
+
return self._search_bruteforce_sync(
|
|
542
|
+
corpus_id=corpus_id,
|
|
543
|
+
q=q,
|
|
544
|
+
k=k,
|
|
545
|
+
where=where,
|
|
546
|
+
max_candidates=max_candidates,
|
|
547
|
+
created_at_min=created_at_min,
|
|
548
|
+
created_at_max=created_at_max,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
qn = _l2_normalize_vec(q.astype(np.float32, copy=False))
|
|
552
|
+
qn = qn.reshape(1, -1)
|
|
553
|
+
|
|
554
|
+
# Probe progressively deeper until we have k results that pass filters
|
|
555
|
+
probe = max(self._faiss_probe_min, k * self._faiss_probe_factor)
|
|
556
|
+
probe = min(probe, self._faiss_probe_max)
|
|
557
|
+
|
|
558
|
+
conn = self._connect()
|
|
559
|
+
try:
|
|
560
|
+
while True:
|
|
561
|
+
scores, ids = index.search(qn, probe)
|
|
562
|
+
ids0 = ids[0]
|
|
563
|
+
scores0 = scores[0]
|
|
564
|
+
|
|
565
|
+
# Map to chunk_ids in rank order
|
|
566
|
+
ranked_chunk_ids: list[str] = []
|
|
567
|
+
ranked_scores: list[float] = []
|
|
568
|
+
for fid, sc in zip(ids0, scores0, strict=False):
|
|
569
|
+
if fid < 0:
|
|
570
|
+
continue
|
|
571
|
+
if fid >= len(id_to_chunk):
|
|
572
|
+
continue
|
|
573
|
+
ranked_chunk_ids.append(id_to_chunk[int(fid)])
|
|
574
|
+
ranked_scores.append(float(sc))
|
|
575
|
+
|
|
576
|
+
if not ranked_chunk_ids:
|
|
577
|
+
return []
|
|
578
|
+
|
|
579
|
+
# Fetch metas/timestamps for these candidates in batch
|
|
580
|
+
row_map = self._fetch_rows_for_chunk_ids(conn, corpus_id, ranked_chunk_ids)
|
|
581
|
+
|
|
582
|
+
out: list[dict[str, Any]] = []
|
|
583
|
+
for cid, sc in zip(ranked_chunk_ids, ranked_scores, strict=True):
|
|
584
|
+
tup = row_map.get(cid)
|
|
585
|
+
if tup is None:
|
|
586
|
+
continue
|
|
587
|
+
meta_json, created_at_ts = tup
|
|
588
|
+
meta = json.loads(meta_json)
|
|
589
|
+
|
|
590
|
+
# Apply where/time filters post-hoc
|
|
591
|
+
if where and not self._passes_where(where, meta):
|
|
592
|
+
continue
|
|
593
|
+
if created_at_min is not None: # noqa: SIM102
|
|
594
|
+
if created_at_ts is None or float(created_at_ts) < float(created_at_min):
|
|
595
|
+
continue
|
|
596
|
+
if created_at_max is not None: # noqa: SIM102
|
|
597
|
+
if created_at_ts is None or float(created_at_ts) > float(created_at_max):
|
|
598
|
+
continue
|
|
599
|
+
|
|
600
|
+
out.append({"chunk_id": cid, "score": sc, "meta": meta})
|
|
601
|
+
if len(out) >= k:
|
|
602
|
+
return out
|
|
603
|
+
|
|
604
|
+
# Not enough after filtering -> probe deeper or fall back
|
|
605
|
+
if probe >= self._faiss_probe_max:
|
|
606
|
+
# If filters are too tight, FAISS post-filtering may not find enough.
|
|
607
|
+
# Fall back to SQL-candidate brute-force which is exact under filters.
|
|
608
|
+
return self._search_bruteforce_sync(
|
|
609
|
+
corpus_id=corpus_id,
|
|
610
|
+
q=q,
|
|
611
|
+
k=k,
|
|
612
|
+
where=where,
|
|
613
|
+
max_candidates=max_candidates,
|
|
614
|
+
created_at_min=created_at_min,
|
|
615
|
+
created_at_max=created_at_max,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
probe = min(self._faiss_probe_max, probe * 2)
|
|
619
|
+
finally:
|
|
620
|
+
conn.close()
|
|
621
|
+
|
|
139
622
|
async def search(
|
|
140
623
|
self,
|
|
141
624
|
corpus_id: str,
|
|
142
625
|
query_vec: list[float],
|
|
143
626
|
k: int,
|
|
627
|
+
where: dict[str, Any] | None = None,
|
|
628
|
+
max_candidates: int | None = None,
|
|
629
|
+
created_at_min: float | None = None,
|
|
630
|
+
created_at_max: float | None = None,
|
|
144
631
|
) -> list[dict[str, Any]]:
|
|
145
632
|
q = np.asarray(query_vec, dtype=np.float32)
|
|
146
|
-
|
|
633
|
+
where = where or {}
|
|
147
634
|
|
|
148
635
|
def _search_sync() -> list[dict[str, Any]]:
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
WHERE e.corpus_id=?
|
|
159
|
-
""",
|
|
160
|
-
(corpus_id,),
|
|
161
|
-
)
|
|
162
|
-
rows = cur.fetchall()
|
|
163
|
-
finally:
|
|
164
|
-
conn.close()
|
|
165
|
-
|
|
166
|
-
scored: list[tuple[float, str, dict[str, Any]]] = []
|
|
167
|
-
for chunk_id, vec_bytes, norm, meta_json in rows:
|
|
168
|
-
v = np.frombuffer(vec_bytes, dtype=np.float32)
|
|
169
|
-
score = float(np.dot(q, v) / (qn * norm))
|
|
170
|
-
meta = json.loads(meta_json)
|
|
171
|
-
scored.append((score, chunk_id, meta))
|
|
172
|
-
|
|
173
|
-
scored.sort(key=lambda x: x[0], reverse=True)
|
|
174
|
-
top = scored[:k]
|
|
175
|
-
|
|
176
|
-
out: list[dict[str, Any]] = []
|
|
177
|
-
for score, chunk_id, meta in top:
|
|
178
|
-
out.append(
|
|
179
|
-
{
|
|
180
|
-
"chunk_id": chunk_id,
|
|
181
|
-
"score": score,
|
|
182
|
-
"meta": meta,
|
|
183
|
-
}
|
|
636
|
+
if self._faiss_enabled:
|
|
637
|
+
return self._search_faiss_sync(
|
|
638
|
+
corpus_id=corpus_id,
|
|
639
|
+
q=q,
|
|
640
|
+
k=k,
|
|
641
|
+
where=where,
|
|
642
|
+
created_at_min=created_at_min,
|
|
643
|
+
created_at_max=created_at_max,
|
|
644
|
+
max_candidates=max_candidates,
|
|
184
645
|
)
|
|
185
|
-
return
|
|
646
|
+
return self._search_bruteforce_sync(
|
|
647
|
+
corpus_id=corpus_id,
|
|
648
|
+
q=q,
|
|
649
|
+
k=k,
|
|
650
|
+
where=where,
|
|
651
|
+
max_candidates=max_candidates,
|
|
652
|
+
created_at_min=created_at_min,
|
|
653
|
+
created_at_max=created_at_max,
|
|
654
|
+
)
|
|
186
655
|
|
|
187
656
|
return await asyncio.to_thread(_search_sync)
|