longparser 0.1.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.
@@ -0,0 +1,443 @@
1
+ """Pluggable vector store adapters for LongParser.
2
+
3
+ Supports: Chroma, FAISS, Qdrant.
4
+ Each adapter follows the BaseVectorStore interface.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ import tempfile
13
+ from abc import ABC, abstractmethod
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class BaseVectorStore(ABC):
21
+ """Abstract vector store — all adapters implement this interface."""
22
+
23
+ @abstractmethod
24
+ def add(
25
+ self,
26
+ ids: list[str],
27
+ embeddings: list[list[float]],
28
+ metadatas: list[dict],
29
+ documents: list[str],
30
+ ) -> None:
31
+ """Add vectors with metadata."""
32
+ ...
33
+
34
+ @abstractmethod
35
+ def search(
36
+ self,
37
+ query_embedding: list[float],
38
+ top_k: int = 5,
39
+ filters: Optional[dict] = None,
40
+ ) -> list[dict]:
41
+ """Search for similar vectors. Returns list of {id, score, metadata, document}."""
42
+ ...
43
+
44
+ @abstractmethod
45
+ def delete_by_job(self, job_id: str, tenant_id: str = "") -> None:
46
+ """Delete all vectors for a job (idempotent)."""
47
+ ...
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Chroma
52
+ # ---------------------------------------------------------------------------
53
+
54
+ class ChromaStore(BaseVectorStore):
55
+ """ChromaDB vector store adapter."""
56
+
57
+ def __init__(
58
+ self,
59
+ collection_name: str = "longparser",
60
+ persist_directory: str = "./chroma_data",
61
+ index_fingerprint: str = "",
62
+ ):
63
+ try:
64
+ import chromadb
65
+ except ImportError:
66
+ raise ImportError(
67
+ "chromadb is required. Install: pip install clean_rag[chroma]"
68
+ )
69
+
70
+ # Securely isolate vector spaces based on model config
71
+ if index_fingerprint:
72
+ collection_name = f"{collection_name}_{index_fingerprint}"
73
+
74
+ self.client = chromadb.PersistentClient(path=persist_directory)
75
+ self.collection = self.client.get_or_create_collection(
76
+ name=collection_name,
77
+ metadata={"hnsw:space": "cosine"},
78
+ )
79
+ logger.info(f"ChromaStore: collection={collection_name}")
80
+
81
+ def add(self, ids, embeddings, metadatas, documents) -> None:
82
+ # Chroma metadata must be flat (no nested lists/dicts)
83
+ flat_metas = []
84
+ for m in metadatas:
85
+ flat = {}
86
+ for k, v in m.items():
87
+ if isinstance(v, list):
88
+ flat[k] = json.dumps(v)
89
+ else:
90
+ flat[k] = v
91
+ flat_metas.append(flat)
92
+
93
+ self.collection.upsert(
94
+ ids=ids,
95
+ embeddings=embeddings,
96
+ metadatas=flat_metas,
97
+ documents=documents,
98
+ )
99
+
100
+ def search(self, query_embedding, top_k=5, filters=None) -> list[dict]:
101
+ where = None
102
+ if filters:
103
+ # Build Chroma where clause
104
+ conditions = []
105
+ for k, v in filters.items():
106
+ conditions.append({k: {"$eq": v}})
107
+ if len(conditions) == 1:
108
+ where = conditions[0]
109
+ elif conditions:
110
+ where = {"$and": conditions}
111
+
112
+ results = self.collection.query(
113
+ query_embeddings=[query_embedding],
114
+ n_results=top_k,
115
+ where=where,
116
+ include=["documents", "metadatas", "distances"],
117
+ )
118
+
119
+ output = []
120
+ if results["ids"] and results["ids"][0]:
121
+ for i, vid in enumerate(results["ids"][0]):
122
+ meta = results["metadatas"][0][i] if results["metadatas"] else {}
123
+ # Restore lists from JSON strings
124
+ for k, v in meta.items():
125
+ if isinstance(v, str) and v.startswith("["):
126
+ try:
127
+ meta[k] = json.loads(v)
128
+ except (json.JSONDecodeError, ValueError):
129
+ pass
130
+ output.append({
131
+ "id": vid,
132
+ "score": 1.0 - (results["distances"][0][i] if results["distances"] else 0),
133
+ "metadata": meta,
134
+ "document": results["documents"][0][i] if results["documents"] else "",
135
+ })
136
+ return output
137
+
138
+ def delete_by_job(self, job_id: str, tenant_id: str = "") -> None:
139
+ try:
140
+ where = {"job_id": {"$eq": job_id}}
141
+ if tenant_id:
142
+ where = {"$and": [
143
+ {"job_id": {"$eq": job_id}},
144
+ {"tenant_id": {"$eq": tenant_id}},
145
+ ]}
146
+ self.collection.delete(where=where)
147
+ except Exception as e:
148
+ logger.warning(f"ChromaStore delete_by_job failed (idempotent): {e}")
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # FAISS (per-job artifact)
153
+ # ---------------------------------------------------------------------------
154
+
155
+ class FAISSStore(BaseVectorStore):
156
+ """FAISS vector store — per-job file-based index with atomic writes."""
157
+
158
+ def __init__(
159
+ self,
160
+ collection_name: str = "longparser",
161
+ base_dir: str = "./faiss_data",
162
+ index_fingerprint: str = "",
163
+ ):
164
+ try:
165
+ import faiss # noqa: F401
166
+ except ImportError:
167
+ raise ImportError(
168
+ "faiss-cpu is required. Install: pip install clean_rag[faiss]"
169
+ )
170
+
171
+ self.base_dir = Path(base_dir)
172
+ self.base_dir.mkdir(parents=True, exist_ok=True)
173
+ self.collection_name = collection_name
174
+ self.index_fingerprint = index_fingerprint
175
+ self._indexes: dict = {} # job_id → (index, id_map, meta_map, doc_map)
176
+
177
+ def _index_path(self, job_id: str) -> Path:
178
+ # Isolate semantic space using fingerprint suffix explicitly
179
+ dirname = f"{job_id}_{self.index_fingerprint}" if self.index_fingerprint else job_id
180
+ return self.base_dir / dirname
181
+
182
+ def _load_index(self, job_id: str):
183
+ import faiss
184
+ import numpy as np
185
+
186
+ idx_dir = self._index_path(job_id)
187
+ index_file = idx_dir / "index.faiss"
188
+ meta_file = idx_dir / "metadata.json"
189
+
190
+ if index_file.exists() and meta_file.exists():
191
+ index = faiss.read_index(str(index_file))
192
+ with open(meta_file) as f:
193
+ data = json.load(f)
194
+ return index, data.get("ids", []), data.get("metadatas", []), data.get("documents", [])
195
+
196
+ return None, [], [], []
197
+
198
+ def _save_index(self, job_id: str, index, ids, metadatas, documents):
199
+ """Atomic write: temp → fsync → rename."""
200
+ import faiss
201
+
202
+ idx_dir = self._index_path(job_id)
203
+ idx_dir.mkdir(parents=True, exist_ok=True)
204
+
205
+ # Write index atomically
206
+ with tempfile.NamedTemporaryFile(dir=idx_dir, suffix=".faiss", delete=False) as tmp:
207
+ tmp_path = tmp.name
208
+ faiss.write_index(index, tmp_path)
209
+ os.fsync(tmp.fileno())
210
+ os.rename(tmp_path, str(idx_dir / "index.faiss"))
211
+
212
+ # Write metadata atomically
213
+ meta = {"ids": ids, "metadatas": metadatas, "documents": documents}
214
+ with tempfile.NamedTemporaryFile(dir=idx_dir, suffix=".json", mode="w", delete=False) as tmp:
215
+ tmp_meta_path = tmp.name
216
+ json.dump(meta, tmp, default=str)
217
+ os.fsync(tmp.fileno())
218
+ os.rename(tmp_meta_path, str(idx_dir / "metadata.json"))
219
+
220
+ def add(self, ids, embeddings, metadatas, documents) -> None:
221
+ import faiss
222
+ import numpy as np
223
+
224
+ if not embeddings:
225
+ return
226
+
227
+ # Determine job_id from first metadata
228
+ job_id = metadatas[0].get("job_id", "default") if metadatas else "default"
229
+
230
+ # Load or create index
231
+ index, existing_ids, existing_metas, existing_docs = self._load_index(job_id)
232
+
233
+ dim = len(embeddings[0])
234
+ if index is None:
235
+ index = faiss.IndexFlatIP(dim) # inner product (cosine with normalized vectors)
236
+
237
+ # Add vectors
238
+ vectors = np.array(embeddings, dtype="float32")
239
+ index.add(vectors)
240
+
241
+ # Append metadata
242
+ existing_ids.extend(ids)
243
+ existing_metas.extend(metadatas)
244
+ existing_docs.extend(documents)
245
+
246
+ self._save_index(job_id, index, existing_ids, existing_metas, existing_docs)
247
+
248
+ def search(self, query_embedding, top_k=5, filters=None) -> list[dict]:
249
+ import faiss
250
+ import numpy as np
251
+
252
+ job_id = filters.get("job_id", "default") if filters else "default"
253
+ index, ids, metadatas, documents = self._load_index(job_id)
254
+
255
+ if index is None or index.ntotal == 0:
256
+ return []
257
+
258
+ query = np.array([query_embedding], dtype="float32")
259
+ scores, indices = index.search(query, min(top_k, index.ntotal))
260
+
261
+ output = []
262
+ for i, idx in enumerate(indices[0]):
263
+ if idx < 0 or idx >= len(ids):
264
+ continue
265
+ output.append({
266
+ "id": ids[idx],
267
+ "score": float(scores[0][i]),
268
+ "metadata": metadatas[idx] if idx < len(metadatas) else {},
269
+ "document": documents[idx] if idx < len(documents) else "",
270
+ })
271
+ return output
272
+
273
+ def delete_by_job(self, job_id: str, tenant_id: str = "") -> None:
274
+ """Delete entire per-job index directory (rebuild pattern)."""
275
+ import shutil
276
+ idx_dir = self._index_path(job_id)
277
+ if idx_dir.exists():
278
+ shutil.rmtree(idx_dir)
279
+ logger.info(f"FAISSStore: deleted index for job {job_id}")
280
+
281
+
282
+ # ---------------------------------------------------------------------------
283
+ # Qdrant
284
+ # ---------------------------------------------------------------------------
285
+
286
+ class QdrantStore(BaseVectorStore):
287
+ """Qdrant vector store adapter."""
288
+
289
+ def __init__(
290
+ self,
291
+ collection_name: str = "longparser",
292
+ url: str = "http://localhost:6333",
293
+ index_fingerprint: str = "",
294
+ ):
295
+ try:
296
+ from qdrant_client import QdrantClient
297
+ from qdrant_client.models import Distance, VectorParams
298
+ except ImportError:
299
+ raise ImportError(
300
+ "qdrant-client is required. Install: pip install clean_rag[qdrant]"
301
+ )
302
+
303
+ self.client = QdrantClient(url=url)
304
+ # Securely isolate vector spaces based on model config
305
+ self.collection_name = f"{collection_name}_{index_fingerprint}" if index_fingerprint else collection_name
306
+ self._distance = Distance.COSINE
307
+ logger.info(f"QdrantStore: collection={collection_name}, url={url}")
308
+
309
+ def _ensure_collection(self, dim: int) -> None:
310
+ """Create or validate collection. Mismatch → new collection name."""
311
+ from qdrant_client.models import Distance, VectorParams
312
+
313
+ collections = [c.name for c in self.client.get_collections().collections]
314
+
315
+ if self.collection_name in collections:
316
+ # Validate dim + metric
317
+ info = self.client.get_collection(self.collection_name)
318
+ existing_dim = info.config.params.vectors.size
319
+ if existing_dim != dim:
320
+ # Mismatch — create new collection with hash suffix
321
+ import hashlib
322
+ suffix = hashlib.md5(f"{dim}".encode()).hexdigest()[:8]
323
+ self.collection_name = f"{self.collection_name}_{suffix}"
324
+ logger.warning(
325
+ f"QdrantStore: dim mismatch, using collection: {self.collection_name}"
326
+ )
327
+
328
+ if self.collection_name not in collections:
329
+ self.client.create_collection(
330
+ collection_name=self.collection_name,
331
+ vectors_config=VectorParams(size=dim, distance=self._distance),
332
+ )
333
+
334
+ def add(self, ids, embeddings, metadatas, documents) -> None:
335
+ from qdrant_client.models import PointStruct
336
+
337
+ if not embeddings:
338
+ return
339
+
340
+ dim = len(embeddings[0])
341
+ self._ensure_collection(dim)
342
+
343
+ points = []
344
+ for i, (vid, emb, meta, doc) in enumerate(zip(ids, embeddings, metadatas, documents)):
345
+ # Flatten lists in payload for Qdrant filtering
346
+ payload = {**meta, "document": doc}
347
+ for k, v in payload.items():
348
+ if isinstance(v, list):
349
+ payload[k] = json.dumps(v)
350
+
351
+ points.append(PointStruct(
352
+ id=i, # Qdrant needs int or UUID
353
+ vector=emb,
354
+ payload={**payload, "vector_id": vid},
355
+ ))
356
+
357
+ self.client.upsert(collection_name=self.collection_name, points=points)
358
+
359
+ def search(self, query_embedding, top_k=5, filters=None) -> list[dict]:
360
+ from qdrant_client.models import Filter, FieldCondition, MatchValue
361
+
362
+ search_filter = None
363
+ if filters:
364
+ conditions = []
365
+ for k, v in filters.items():
366
+ conditions.append(FieldCondition(key=k, match=MatchValue(value=v)))
367
+ if conditions:
368
+ search_filter = Filter(must=conditions)
369
+
370
+ results = self.client.query_points(
371
+ collection_name=self.collection_name,
372
+ query=query_embedding,
373
+ limit=top_k,
374
+ query_filter=search_filter,
375
+ ).points
376
+
377
+ output = []
378
+ for hit in results:
379
+ payload = hit.payload or {}
380
+ # Restore lists
381
+ for k, v in payload.items():
382
+ if isinstance(v, str) and v.startswith("["):
383
+ try:
384
+ payload[k] = json.loads(v)
385
+ except (json.JSONDecodeError, ValueError):
386
+ pass
387
+ output.append({
388
+ "id": payload.get("vector_id", ""),
389
+ "score": hit.score,
390
+ "metadata": payload,
391
+ "document": payload.get("document", ""),
392
+ })
393
+ return output
394
+
395
+ def delete_by_job(self, job_id: str, tenant_id: str = "") -> None:
396
+ from qdrant_client.models import Filter, FieldCondition, MatchValue
397
+
398
+ try:
399
+ conditions = [FieldCondition(key="job_id", match=MatchValue(value=job_id))]
400
+ if tenant_id:
401
+ conditions.append(
402
+ FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id))
403
+ )
404
+ self.client.delete(
405
+ collection_name=self.collection_name,
406
+ points_selector=Filter(must=conditions),
407
+ )
408
+ except Exception as e:
409
+ logger.warning(f"QdrantStore delete_by_job failed (idempotent): {e}")
410
+
411
+
412
+ # ---------------------------------------------------------------------------
413
+ # Factory
414
+ # ---------------------------------------------------------------------------
415
+
416
+ def get_vector_store(
417
+ backend: str,
418
+ collection_name: str = "longparser",
419
+ index_fingerprint: str = "",
420
+ **kwargs,
421
+ ) -> BaseVectorStore:
422
+ """Create a vector store adapter by name.
423
+
424
+ Args:
425
+ backend: "chroma", "faiss", or "qdrant"
426
+ collection_name: Name for the collection/index
427
+ index_fingerprint: 10-char hash to isolate different embedding models
428
+ **kwargs: Backend-specific options
429
+
430
+ Returns:
431
+ Configured BaseVectorStore instance
432
+ """
433
+ if backend == "chroma":
434
+ return ChromaStore(collection_name=collection_name, index_fingerprint=index_fingerprint, **kwargs)
435
+ elif backend == "faiss":
436
+ return FAISSStore(collection_name=collection_name, index_fingerprint=index_fingerprint, **kwargs)
437
+ elif backend == "qdrant":
438
+ return QdrantStore(collection_name=collection_name, index_fingerprint=index_fingerprint, **kwargs)
439
+ else:
440
+ raise ValueError(
441
+ f"Unknown vector store backend: {backend}. "
442
+ f"Supported: chroma, faiss, qdrant"
443
+ )