aethergraph 0.1.0a3__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.
Files changed (113) hide show
  1. aethergraph/api/v1/artifacts.py +23 -4
  2. aethergraph/api/v1/schemas.py +7 -0
  3. aethergraph/api/v1/session.py +123 -4
  4. aethergraph/config/config.py +2 -0
  5. aethergraph/config/search.py +49 -0
  6. aethergraph/contracts/services/channel.py +18 -1
  7. aethergraph/contracts/services/execution.py +58 -0
  8. aethergraph/contracts/services/llm.py +26 -0
  9. aethergraph/contracts/services/memory.py +10 -4
  10. aethergraph/contracts/services/planning.py +53 -0
  11. aethergraph/contracts/storage/event_log.py +8 -0
  12. aethergraph/contracts/storage/search_backend.py +47 -0
  13. aethergraph/contracts/storage/vector_index.py +73 -0
  14. aethergraph/core/graph/action_spec.py +76 -0
  15. aethergraph/core/graph/graph_fn.py +75 -2
  16. aethergraph/core/graph/graphify.py +74 -2
  17. aethergraph/core/runtime/graph_runner.py +2 -1
  18. aethergraph/core/runtime/node_context.py +66 -3
  19. aethergraph/core/runtime/node_services.py +8 -0
  20. aethergraph/core/runtime/run_manager.py +263 -271
  21. aethergraph/core/runtime/run_types.py +54 -1
  22. aethergraph/core/runtime/runtime_env.py +35 -14
  23. aethergraph/core/runtime/runtime_services.py +308 -18
  24. aethergraph/plugins/agents/default_chat_agent.py +266 -74
  25. aethergraph/plugins/agents/default_chat_agent_v2.py +487 -0
  26. aethergraph/plugins/channel/adapters/webui.py +69 -21
  27. aethergraph/plugins/channel/routes/webui_routes.py +8 -48
  28. aethergraph/runtime/__init__.py +12 -0
  29. aethergraph/server/app_factory.py +3 -0
  30. aethergraph/server/ui_static/assets/index-CFktGdbW.js +4913 -0
  31. aethergraph/server/ui_static/assets/index-DcfkFlTA.css +1 -0
  32. aethergraph/server/ui_static/index.html +2 -2
  33. aethergraph/services/artifacts/facade.py +157 -21
  34. aethergraph/services/artifacts/types.py +35 -0
  35. aethergraph/services/artifacts/utils.py +42 -0
  36. aethergraph/services/channel/channel_bus.py +3 -1
  37. aethergraph/services/channel/event_hub copy.py +55 -0
  38. aethergraph/services/channel/event_hub.py +81 -0
  39. aethergraph/services/channel/factory.py +3 -2
  40. aethergraph/services/channel/session.py +709 -74
  41. aethergraph/services/container/default_container.py +69 -7
  42. aethergraph/services/execution/__init__.py +0 -0
  43. aethergraph/services/execution/local_python.py +118 -0
  44. aethergraph/services/indices/__init__.py +0 -0
  45. aethergraph/services/indices/global_indices.py +21 -0
  46. aethergraph/services/indices/scoped_indices.py +292 -0
  47. aethergraph/services/llm/generic_client.py +342 -46
  48. aethergraph/services/llm/generic_embed_client.py +359 -0
  49. aethergraph/services/llm/types.py +3 -1
  50. aethergraph/services/memory/distillers/llm_long_term.py +60 -109
  51. aethergraph/services/memory/distillers/llm_long_term_v1.py +180 -0
  52. aethergraph/services/memory/distillers/llm_meta_summary.py +57 -266
  53. aethergraph/services/memory/distillers/llm_meta_summary_v1.py +342 -0
  54. aethergraph/services/memory/distillers/long_term.py +48 -131
  55. aethergraph/services/memory/distillers/long_term_v1.py +170 -0
  56. aethergraph/services/memory/facade/chat.py +18 -8
  57. aethergraph/services/memory/facade/core.py +159 -19
  58. aethergraph/services/memory/facade/distillation.py +86 -31
  59. aethergraph/services/memory/facade/retrieval.py +100 -1
  60. aethergraph/services/memory/factory.py +4 -1
  61. aethergraph/services/planning/__init__.py +0 -0
  62. aethergraph/services/planning/action_catalog.py +271 -0
  63. aethergraph/services/planning/bindings.py +56 -0
  64. aethergraph/services/planning/dependency_index.py +65 -0
  65. aethergraph/services/planning/flow_validator.py +263 -0
  66. aethergraph/services/planning/graph_io_adapter.py +150 -0
  67. aethergraph/services/planning/input_parser.py +312 -0
  68. aethergraph/services/planning/missing_inputs.py +28 -0
  69. aethergraph/services/planning/node_planner.py +613 -0
  70. aethergraph/services/planning/orchestrator.py +112 -0
  71. aethergraph/services/planning/plan_executor.py +506 -0
  72. aethergraph/services/planning/plan_types.py +321 -0
  73. aethergraph/services/planning/planner.py +617 -0
  74. aethergraph/services/planning/planner_service.py +369 -0
  75. aethergraph/services/planning/planning_context_builder.py +43 -0
  76. aethergraph/services/planning/quick_actions.py +29 -0
  77. aethergraph/services/planning/routers/__init__.py +0 -0
  78. aethergraph/services/planning/routers/simple_router.py +26 -0
  79. aethergraph/services/rag/facade.py +0 -3
  80. aethergraph/services/scope/scope.py +30 -30
  81. aethergraph/services/scope/scope_factory.py +15 -7
  82. aethergraph/services/skills/__init__.py +0 -0
  83. aethergraph/services/skills/skill_registry.py +465 -0
  84. aethergraph/services/skills/skills.py +220 -0
  85. aethergraph/services/skills/utils.py +194 -0
  86. aethergraph/storage/artifacts/artifact_index_jsonl.py +16 -10
  87. aethergraph/storage/artifacts/artifact_index_sqlite.py +12 -2
  88. aethergraph/storage/docstore/sqlite_doc_sync.py +1 -1
  89. aethergraph/storage/memory/event_persist.py +42 -2
  90. aethergraph/storage/memory/fs_persist.py +32 -2
  91. aethergraph/storage/search_backend/__init__.py +0 -0
  92. aethergraph/storage/search_backend/generic_vector_backend.py +230 -0
  93. aethergraph/storage/search_backend/null_backend.py +34 -0
  94. aethergraph/storage/search_backend/sqlite_lexical_backend.py +387 -0
  95. aethergraph/storage/search_backend/utils.py +31 -0
  96. aethergraph/storage/search_factory.py +75 -0
  97. aethergraph/storage/vector_index/faiss_index.py +72 -4
  98. aethergraph/storage/vector_index/sqlite_index.py +521 -52
  99. aethergraph/storage/vector_index/sqlite_index_vanila.py +311 -0
  100. aethergraph/storage/vector_index/utils.py +22 -0
  101. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/METADATA +1 -1
  102. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/RECORD +107 -63
  103. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/WHEEL +1 -1
  104. aethergraph/plugins/agents/default_chat_agent copy.py +0 -90
  105. aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +0 -1
  106. aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +0 -400
  107. aethergraph/services/eventhub/event_hub.py +0 -76
  108. aethergraph/services/llm/generic_client copy.py +0 -691
  109. aethergraph/services/prompts/file_store.py +0 -41
  110. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/entry_points.txt +0 -0
  111. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/LICENSE +0 -0
  112. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/NOTICE +0 -0
  113. {aethergraph-0.1.0a3.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 TEXT,
22
- chunk_id TEXT,
23
- vec BLOB, -- np.float32 array bytes
24
- norm REAL,
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
- for stmt in SCHEMA.strip().split(";\n"):
67
+ cur = conn.cursor()
68
+ for stmt in SCHEMA.strip().split(";\n\n"):
35
69
  s = stmt.strip()
36
70
  if s:
37
- conn.execute(s)
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
- Uses brute-force cosine similarity per corpus.
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__(self, root: str):
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
- # Each call gets its own connection: thread-safe with to_thread.
57
- conn = sqlite3.connect(self.db_path, check_same_thread=False)
58
- return conn
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, json.dumps(meta, ensure_ascii=False)),
292
+ (corpus_id, cid, meta_json),
80
293
  )
81
294
  cur.execute(
82
- "REPLACE INTO embeddings(corpus_id,chunk_id,vec,norm) VALUES(?,?,?,?)",
83
- (corpus_id, cid, v.tobytes(), norm),
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
- qn = float(np.linalg.norm(q) + 1e-9)
633
+ where = where or {}
147
634
 
148
635
  def _search_sync() -> list[dict[str, Any]]:
149
- conn = self._connect()
150
- try:
151
- cur = conn.cursor()
152
- cur.execute(
153
- """
154
- SELECT e.chunk_id, e.vec, e.norm, c.meta_json
155
- FROM embeddings e
156
- JOIN chunks c
157
- ON e.corpus_id = c.corpus_id AND e.chunk_id = c.chunk_id
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 out
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)