light-infer-chat commited on
Commit
c2bb116
·
1 Parent(s): 08919be

add vector store

Browse files
.gitignore CHANGED
@@ -2,6 +2,9 @@ __pycache__/
2
  *.py[cod]
3
  *$py.class
4
  postman_collection.json
 
 
 
5
  *.so
6
 
7
  .Python
@@ -121,5 +124,5 @@ logs/
121
  *.temp
122
 
123
  local_deploy.py
124
-
125
  deploy_sdk.py
 
2
  *.py[cod]
3
  *$py.class
4
  postman_collection.json
5
+
6
+ # Runtime data (vector stores, SQLite DBs, model caches, etc.)
7
+ data/
8
  *.so
9
 
10
  .Python
 
124
  *.temp
125
 
126
  local_deploy.py
127
+ test_vector_store_async.py
128
  deploy_sdk.py
app/api/deps.py CHANGED
@@ -11,6 +11,7 @@ from app.services.extraction_service import ExtractionService
11
  from app.services.ocr_service import OCRService
12
  from app.services.sql_validator_service import SqlValidatorService
13
  from app.services.text_cleaner_service import TextCleanerService
 
14
  from app.services.web_search_service import WebSearchService
15
 
16
 
@@ -47,5 +48,10 @@ def get_embeddings_service() -> EmbeddingService:
47
  return _embedding_service
48
 
49
 
 
 
 
 
 
50
  def require_auth(token: str = Depends(require_api_key)) -> str:
51
  return token
 
11
  from app.services.ocr_service import OCRService
12
  from app.services.sql_validator_service import SqlValidatorService
13
  from app.services.text_cleaner_service import TextCleanerService
14
+ from app.services.vector_store_service import VectorStoreService
15
  from app.services.web_search_service import WebSearchService
16
 
17
 
 
48
  return _embedding_service
49
 
50
 
51
+ def get_vector_store_service() -> VectorStoreService:
52
+ from app.api.server import _vector_store_service
53
+ return _vector_store_service
54
+
55
+
56
  def require_auth(token: str = Depends(require_api_key)) -> str:
57
  return token
app/api/server.py CHANGED
@@ -13,13 +13,16 @@ from app.core.database import pool_manager
13
  from app.core.logger import get_logger
14
  from app.core.redis_client import create_redis_client, close_redis
15
  from app.core.scripts import load_scripts
 
16
  from app.services.embeddings_service import EmbeddingService
 
17
  from app.api.v1.router import api_v1_router
18
 
19
  _logger = get_logger(__name__)
20
  _settings = get_settings()
21
 
22
  _embedding_service: EmbeddingService = EmbeddingService()
 
23
 
24
 
25
  async def _self_ping():
@@ -44,11 +47,17 @@ async def lifespan(app: FastAPI):
44
  await init_auth_db()
45
  _logger.info("Authentication database initialized")
46
 
 
 
 
 
 
47
  _logger.info("Initializing embedding service (loading 384-dim model)...")
48
  loop = asyncio.get_running_loop()
49
  await loop.run_in_executor(None, _embedding_service.load_model, 384)
50
  # await loop.run_in_executor(None, _embedding_service.load_vision_model) # DISABLED (OOM mitigation)
51
  _logger.info("Embedding service initialized with dims: %s", _embedding_service.loaded_dimensions)
 
52
 
53
  redis = create_redis_client(_settings.redis_url) if _settings.redis_url else None
54
  scripts = await load_scripts(redis) if redis else {}
@@ -63,6 +72,7 @@ async def lifespan(app: FastAPI):
63
  yield
64
  _logger.info("Shutting down...")
65
  await close_redis(redis)
 
66
  await pool_manager.close_all()
67
 
68
 
@@ -79,6 +89,7 @@ def create_application() -> FastAPI:
79
  {"name": "System", "description": "Health, info, and supported formats"},
80
  {"name": "Embeddings", "description": "Text embedding generation using transformer models"},
81
  {"name": "Verify", "description": "Phone number and identity verification"},
 
82
  ],
83
  lifespan=lifespan,
84
  )
@@ -124,7 +135,17 @@ def create_application() -> FastAPI:
124
 
125
  @app.get("/health", include_in_schema=False)
126
  async def root_health():
127
- return {"status": "ok", "version": _settings.app_version}
 
 
 
 
 
 
 
 
 
 
128
 
129
  @app.get("/ping", include_in_schema=False)
130
  async def ping():
 
13
  from app.core.logger import get_logger
14
  from app.core.redis_client import create_redis_client, close_redis
15
  from app.core.scripts import load_scripts
16
+ from app.core.vector_store.deps import init_vector_store_db
17
  from app.services.embeddings_service import EmbeddingService
18
+ from app.services.vector_store_service import VectorStoreService
19
  from app.api.v1.router import api_v1_router
20
 
21
  _logger = get_logger(__name__)
22
  _settings = get_settings()
23
 
24
  _embedding_service: EmbeddingService = EmbeddingService()
25
+ _vector_store_service: VectorStoreService = VectorStoreService(_embedding_service)
26
 
27
 
28
  async def _self_ping():
 
47
  await init_auth_db()
48
  _logger.info("Authentication database initialized")
49
 
50
+ _logger.info("Initializing vector store database...")
51
+ await init_vector_store_db()
52
+ await _vector_store_service.init_db()
53
+ _logger.info("Vector store database initialized with %d stores", len(_vector_store_service.list_stores()))
54
+
55
  _logger.info("Initializing embedding service (loading 384-dim model)...")
56
  loop = asyncio.get_running_loop()
57
  await loop.run_in_executor(None, _embedding_service.load_model, 384)
58
  # await loop.run_in_executor(None, _embedding_service.load_vision_model) # DISABLED (OOM mitigation)
59
  _logger.info("Embedding service initialized with dims: %s", _embedding_service.loaded_dimensions)
60
+ _logger.info("Vector store service initialized with %d existing stores", len(_vector_store_service.list_stores()))
61
 
62
  redis = create_redis_client(_settings.redis_url) if _settings.redis_url else None
63
  scripts = await load_scripts(redis) if redis else {}
 
72
  yield
73
  _logger.info("Shutting down...")
74
  await close_redis(redis)
75
+ await _vector_store_service.close_all()
76
  await pool_manager.close_all()
77
 
78
 
 
89
  {"name": "System", "description": "Health, info, and supported formats"},
90
  {"name": "Embeddings", "description": "Text embedding generation using transformer models"},
91
  {"name": "Verify", "description": "Phone number and identity verification"},
92
+ {"name": "Vector Stores", "description": "Create, manage, and search vector stores for RAG"},
93
  ],
94
  lifespan=lifespan,
95
  )
 
135
 
136
  @app.get("/health", include_in_schema=False)
137
  async def root_health():
138
+ store_count = len(_vector_store_service.list_stores())
139
+ doc_count = await _vector_store_service.get_total_document_count()
140
+ return {
141
+ "success": True,
142
+ "app_name": _settings.app_name,
143
+ "version": _settings.app_version,
144
+ "embedding_dimension": _settings.embedding_dimension,
145
+ "vector_store_count": store_count,
146
+ "total_documents": doc_count,
147
+ "model_loaded": _embedding_service.is_loaded(384),
148
+ }
149
 
150
  @app.get("/ping", include_in_schema=False)
151
  async def ping():
app/api/v1/router.py CHANGED
@@ -2,7 +2,7 @@ from __future__ import annotations
2
 
3
  from fastapi import APIRouter
4
 
5
- from app.api.v1 import auth, batch, chat, code_executor, convert, database, embeddings, reconcile, scraper, semantic_router, sql_validator, system, token_counter, token_generator, web_search
6
  from app.api.verify import router as verify_router
7
 
8
  api_v1_router = APIRouter()
@@ -22,3 +22,4 @@ api_v1_router.include_router(semantic_router.router, tags=["Semantic Router"])
22
  api_v1_router.include_router(token_counter.router, tags=["Token Counter"])
23
  api_v1_router.include_router(token_generator.router, tags=["Token Generator"])
24
  api_v1_router.include_router(chat.router, tags=["Chat"])
 
 
2
 
3
  from fastapi import APIRouter
4
 
5
+ from app.api.v1 import auth, batch, chat, code_executor, convert, database, embeddings, reconcile, scraper, semantic_router, sql_validator, system, token_counter, token_generator, vector_stores, web_search
6
  from app.api.verify import router as verify_router
7
 
8
  api_v1_router = APIRouter()
 
22
  api_v1_router.include_router(token_counter.router, tags=["Token Counter"])
23
  api_v1_router.include_router(token_generator.router, tags=["Token Generator"])
24
  api_v1_router.include_router(chat.router, tags=["Chat"])
25
+ api_v1_router.include_router(vector_stores.router, tags=["Vector Stores"])
app/api/v1/vector_stores.py ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import tempfile
6
+ import time
7
+
8
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
9
+
10
+ from app.api.deps import get_vector_store_service, require_auth
11
+ from app.core.logger import get_logger
12
+ from app.models.domain import ConversionError
13
+ from app.models.schemas import (
14
+ DeleteRequest,
15
+ DeleteResponse,
16
+ DocumentIngestRequest,
17
+ DocumentIngestResponse,
18
+ SearchRequest,
19
+ SearchResponse,
20
+ VectorStoreCreate,
21
+ VectorStoreListResponse,
22
+ VectorStoreResponse,
23
+ )
24
+ from app.services.converter_service import ConverterService
25
+ from app.services.vector_store_service import VectorStoreService
26
+
27
+ router = APIRouter()
28
+ logger = get_logger(__name__)
29
+
30
+
31
+ @router.post(
32
+ "/vector-stores",
33
+ response_model=VectorStoreResponse,
34
+ summary="Create a new vector store",
35
+ status_code=status.HTTP_201_CREATED,
36
+ )
37
+ async def create_vector_store(
38
+ body: VectorStoreCreate,
39
+ token: str = Depends(require_auth),
40
+ vector_store_service: VectorStoreService = Depends(get_vector_store_service),
41
+ ) -> VectorStoreResponse:
42
+ store_id, app_id = await vector_store_service.create_store(
43
+ name=body.name,
44
+ description=body.description or "",
45
+ metadata=body.metadata,
46
+ )
47
+ stats = await vector_store_service.get_store_stats(store_id)
48
+ return VectorStoreResponse(
49
+ success=True,
50
+ vector_store_id=store_id,
51
+ app_id=app_id,
52
+ name=body.name,
53
+ description=body.description,
54
+ embedding_dimension=stats["embedding_dimension"],
55
+ document_count=stats["document_count"],
56
+ created_at=stats["created_at"],
57
+ metadata=stats["metadata"],
58
+ )
59
+
60
+
61
+ @router.get(
62
+ "/vector-stores",
63
+ response_model=VectorStoreListResponse,
64
+ summary="List all vector stores",
65
+ )
66
+ async def list_vector_stores(
67
+ token: str = Depends(require_auth),
68
+ vector_store_service: VectorStoreService = Depends(get_vector_store_service),
69
+ ) -> VectorStoreListResponse:
70
+ records = vector_store_service.list_stores()
71
+ stores = []
72
+ for r in records:
73
+ try:
74
+ stats = await vector_store_service.get_store_stats(r.store_id)
75
+ except Exception:
76
+ stats = {}
77
+ stores.append(VectorStoreResponse(
78
+ success=True,
79
+ vector_store_id=r.store_id,
80
+ app_id=stats.get("app_id", r.store_id),
81
+ name=r.name,
82
+ description=r.description,
83
+ embedding_dimension=stats.get("embedding_dimension", 0),
84
+ document_count=stats.get("document_count", 0),
85
+ created_at=r.created_at,
86
+ metadata=r.metadata,
87
+ ))
88
+ return VectorStoreListResponse(success=True, total=len(stores), stores=stores)
89
+
90
+
91
+ @router.get(
92
+ "/vector-stores/{store_id}",
93
+ response_model=VectorStoreResponse,
94
+ summary="Get vector store details",
95
+ )
96
+ async def get_vector_store(
97
+ store_id: str,
98
+ token: str = Depends(require_auth),
99
+ vector_store_service: VectorStoreService = Depends(get_vector_store_service),
100
+ ) -> VectorStoreResponse:
101
+ try:
102
+ stats = await vector_store_service.get_store_stats(store_id)
103
+ except ValueError as exc:
104
+ raise HTTPException(status_code=404, detail=str(exc))
105
+ return VectorStoreResponse(
106
+ success=True,
107
+ vector_store_id=stats["store_id"],
108
+ app_id=stats["app_id"],
109
+ name=stats["name"],
110
+ description=stats["description"],
111
+ embedding_dimension=stats["embedding_dimension"],
112
+ document_count=stats["document_count"],
113
+ created_at=stats["created_at"],
114
+ metadata=stats["metadata"],
115
+ )
116
+
117
+
118
+ @router.delete(
119
+ "/vector-stores/{store_id}",
120
+ response_model=VectorStoreResponse,
121
+ summary="Delete a vector store and all its data",
122
+ )
123
+ async def delete_vector_store(
124
+ store_id: str,
125
+ token: str = Depends(require_auth),
126
+ vector_store_service: VectorStoreService = Depends(get_vector_store_service),
127
+ ) -> VectorStoreResponse:
128
+ record = vector_store_service.get_store(store_id)
129
+ if record is None:
130
+ raise HTTPException(status_code=404, detail=f"Vector store {store_id} not found")
131
+ ok = await vector_store_service.delete_store(store_id)
132
+ if not ok:
133
+ raise HTTPException(status_code=500, detail="Failed to delete vector store")
134
+ return VectorStoreResponse(
135
+ success=True,
136
+ vector_store_id=store_id,
137
+ app_id="",
138
+ name=record.name,
139
+ document_count=0,
140
+ embedding_dimension=0,
141
+ created_at=record.created_at,
142
+ )
143
+
144
+
145
+ @router.post(
146
+ "/vector-stores/{store_id}/documents",
147
+ response_model=DocumentIngestResponse,
148
+ summary="Ingest a document into the vector store with automatic chunking and embedding",
149
+ )
150
+ async def ingest_document(
151
+ store_id: str,
152
+ body: DocumentIngestRequest,
153
+ token: str = Depends(require_auth),
154
+ vector_store_service: VectorStoreService = Depends(get_vector_store_service),
155
+ ) -> DocumentIngestResponse:
156
+ record = vector_store_service.get_store(store_id)
157
+ if record is None:
158
+ raise HTTPException(status_code=404, detail=f"Vector store {store_id} not found")
159
+ try:
160
+ chunks, elapsed = await vector_store_service.ingest_document(
161
+ store_id=store_id,
162
+ doc_id=body.doc_id,
163
+ text=body.text,
164
+ source=body.source,
165
+ metadata=body.metadata,
166
+ chunk_size=body.chunk_size,
167
+ chunk_overlap=body.chunk_overlap,
168
+ )
169
+ except Exception as exc:
170
+ logger.error("Ingest failed for store %s: %s", store_id, exc)
171
+ return DocumentIngestResponse(
172
+ success=False,
173
+ vector_store_id=store_id,
174
+ doc_id=body.doc_id,
175
+ chunks_ingested=0,
176
+ time_ms=0,
177
+ error=str(exc),
178
+ )
179
+ return DocumentIngestResponse(
180
+ success=True,
181
+ vector_store_id=store_id,
182
+ doc_id=body.doc_id,
183
+ chunks_ingested=chunks,
184
+ time_ms=round(elapsed, 3),
185
+ )
186
+
187
+
188
+ @router.post(
189
+ "/vector-stores/{store_id}/documents/upload",
190
+ response_model=DocumentIngestResponse,
191
+ summary="Upload a PDF file and ingest it into the vector store",
192
+ )
193
+ async def ingest_pdf_document(
194
+ store_id: str,
195
+ file: UploadFile = File(..., description="PDF file to ingest"),
196
+ doc_id: str = Form(..., min_length=1, max_length=256),
197
+ chunk_size: int = Form(512, ge=64, le=4096),
198
+ chunk_overlap: int = Form(64, ge=0, le=512),
199
+ token: str = Depends(require_auth),
200
+ vector_store_service: VectorStoreService = Depends(get_vector_store_service),
201
+ ) -> DocumentIngestResponse:
202
+ record = vector_store_service.get_store(store_id)
203
+ if record is None:
204
+ raise HTTPException(status_code=404, detail=f"Vector store {store_id} not found")
205
+
206
+ if file.content_type != "application/pdf":
207
+ raise HTTPException(status_code=400, detail="Only PDF files are accepted")
208
+ if not file.filename or not file.filename.lower().endswith(".pdf"):
209
+ raise HTTPException(status_code=400, detail="Only .pdf files are accepted")
210
+
211
+ tmp_path = None
212
+ try:
213
+ raw = await file.read()
214
+ if len(raw) < 5 or raw[:5] != b"%PDF-":
215
+ raise HTTPException(status_code=400, detail="File is not a valid PDF")
216
+
217
+ suffix = ".pdf"
218
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
219
+ tmp.write(raw)
220
+ tmp_path = tmp.name
221
+
222
+ converter = ConverterService()
223
+ loop = asyncio.get_running_loop()
224
+ result = await loop.run_in_executor(None, converter.convert_file, tmp_path)
225
+
226
+ if isinstance(result, ConversionError):
227
+ logger.error("PDF conversion failed: %s", result.message)
228
+ return DocumentIngestResponse(
229
+ success=False,
230
+ vector_store_id=store_id,
231
+ doc_id=doc_id,
232
+ chunks_ingested=0,
233
+ time_ms=0,
234
+ error=result.message,
235
+ )
236
+
237
+ text = result.markdown
238
+ source = file.filename
239
+
240
+ chunks, elapsed = await vector_store_service.ingest_document(
241
+ store_id=store_id,
242
+ doc_id=doc_id,
243
+ text=text,
244
+ source=source,
245
+ chunk_size=chunk_size,
246
+ chunk_overlap=chunk_overlap,
247
+ )
248
+
249
+ return DocumentIngestResponse(
250
+ success=True,
251
+ vector_store_id=store_id,
252
+ doc_id=doc_id,
253
+ chunks_ingested=chunks,
254
+ time_ms=round(elapsed, 3),
255
+ )
256
+ except HTTPException:
257
+ raise
258
+ except Exception as exc:
259
+ logger.error("PDF ingest failed for store %s: %s", store_id, exc)
260
+ return DocumentIngestResponse(
261
+ success=False,
262
+ vector_store_id=store_id,
263
+ doc_id=doc_id,
264
+ chunks_ingested=0,
265
+ time_ms=0,
266
+ error=str(exc),
267
+ )
268
+ finally:
269
+ if tmp_path and os.path.exists(tmp_path):
270
+ os.unlink(tmp_path)
271
+ await file.close()
272
+
273
+
274
+ @router.post(
275
+ "/vector-stores/{store_id}/search",
276
+ response_model=SearchResponse,
277
+ summary="Search the vector store using natural language queries (RAG)",
278
+ )
279
+ async def search_vector_store(
280
+ store_id: str,
281
+ body: SearchRequest,
282
+ token: str = Depends(require_auth),
283
+ vector_store_service: VectorStoreService = Depends(get_vector_store_service),
284
+ ) -> SearchResponse:
285
+ record = vector_store_service.get_store(store_id)
286
+ if record is None:
287
+ raise HTTPException(status_code=404, detail=f"Vector store {store_id} not found")
288
+ try:
289
+ items, elapsed = await vector_store_service.search(
290
+ store_id=store_id,
291
+ query_text=body.query,
292
+ top_k=body.top_k,
293
+ filter_expr=body.filter,
294
+ min_score=body.min_score,
295
+ include_vectors=body.include_vectors,
296
+ include_metadata=body.include_metadata,
297
+ )
298
+ except Exception as exc:
299
+ logger.error("Search failed for store %s: %s", store_id, exc)
300
+ return SearchResponse(
301
+ success=False,
302
+ vector_store_id=store_id,
303
+ query=body.query,
304
+ results=[],
305
+ total_results=0,
306
+ time_ms=0,
307
+ error=str(exc),
308
+ )
309
+ return SearchResponse(
310
+ success=True,
311
+ vector_store_id=store_id,
312
+ query=body.query,
313
+ results=items,
314
+ total_results=len(items),
315
+ time_ms=round(elapsed, 3),
316
+ )
317
+
318
+
319
+ @router.post(
320
+ "/vector-stores/{store_id}/delete",
321
+ response_model=DeleteResponse,
322
+ summary="Delete documents from the vector store by IDs or filter",
323
+ )
324
+ async def delete_documents(
325
+ store_id: str,
326
+ body: DeleteRequest,
327
+ token: str = Depends(require_auth),
328
+ vector_store_service: VectorStoreService = Depends(get_vector_store_service),
329
+ ) -> DeleteResponse:
330
+ record = vector_store_service.get_store(store_id)
331
+ if record is None:
332
+ raise HTTPException(status_code=404, detail=f"Vector store {store_id} not found")
333
+ start = time.perf_counter()
334
+ try:
335
+ deleted = await vector_store_service.delete_documents(
336
+ store_id=store_id,
337
+ ids=body.ids,
338
+ filter_expr=body.filter,
339
+ )
340
+ except Exception as exc:
341
+ elapsed = (time.perf_counter() - start) * 1000
342
+ return DeleteResponse(
343
+ success=False,
344
+ vector_store_id=store_id,
345
+ deleted_count=0,
346
+ time_ms=round(elapsed, 3),
347
+ error=str(exc),
348
+ )
349
+ elapsed = (time.perf_counter() - start) * 1000
350
+ return DeleteResponse(
351
+ success=True,
352
+ vector_store_id=store_id,
353
+ deleted_count=deleted,
354
+ time_ms=round(elapsed, 3),
355
+ )
app/config.py CHANGED
@@ -61,6 +61,13 @@ class Settings(BaseSettings):
61
  key_lock_ttl: int = 600
62
  rounds_per_model: int = 50
63
 
 
 
 
 
 
 
 
64
  jwt_secret_key: str = "changeme-jwt-secret"
65
  jwt_algorithm: str = "HS256"
66
  jwt_default_expiry_minutes: int = 30
 
61
  key_lock_ttl: int = 600
62
  rounds_per_model: int = 50
63
 
64
+ data_dir: str = "./data"
65
+ embedding_model: str = "ibm-granite/granite-embedding-small-english-r2"
66
+ embedding_dimension: int = 384
67
+ default_top_k: int = 10
68
+ max_chunk_size: int = 512
69
+ chunk_overlap: int = 64
70
+
71
  jwt_secret_key: str = "changeme-jwt-secret"
72
  jwt_algorithm: str = "HS256"
73
  jwt_default_expiry_minutes: int = 30
app/core/vector_store/__init__.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from app.core.vector_store.models import VectorStoreIndex, Base
4
+ from app.core.vector_store.deps import (
5
+ engine,
6
+ AsyncSessionLocal,
7
+ init_vector_store_db,
8
+ get_vs_db,
9
+ )
10
+
11
+ __all__ = [
12
+ "Base",
13
+ "VectorStoreIndex",
14
+ "engine",
15
+ "AsyncSessionLocal",
16
+ "init_vector_store_db",
17
+ "get_vs_db",
18
+ ]
app/core/vector_store/deps.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import AsyncGenerator
5
+
6
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
7
+
8
+ from app.config import get_settings
9
+ from app.core.vector_store.models import Base
10
+
11
+ _settings = get_settings()
12
+
13
+ DATA_DIR = _settings.data_dir
14
+ os.makedirs(DATA_DIR, exist_ok=True)
15
+
16
+ VECTOR_STORE_DB_URL = f"sqlite+aiosqlite:///{os.path.join(DATA_DIR, 'vector_stores.db')}"
17
+
18
+ engine = create_async_engine(VECTOR_STORE_DB_URL, echo=False)
19
+ AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
20
+
21
+
22
+ async def init_vector_store_db():
23
+ async with engine.begin() as conn:
24
+ await conn.run_sync(Base.metadata.create_all)
25
+
26
+
27
+ async def get_vs_db() -> AsyncGenerator[AsyncSession, None]:
28
+ async with AsyncSessionLocal() as session:
29
+ try:
30
+ yield session
31
+ finally:
32
+ await session.close()
app/core/vector_store/models.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from typing import Any, Dict, Optional
6
+
7
+ from sqlalchemy import Column, String, Text
8
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
9
+
10
+
11
+ class Base(DeclarativeBase):
12
+ pass
13
+
14
+
15
+ def _utcnow() -> str:
16
+ return datetime.now(timezone.utc).isoformat()
17
+
18
+
19
+ class VectorStoreIndex(Base):
20
+ __tablename__ = "vector_store_index"
21
+
22
+ store_id: Mapped[str] = mapped_column(String(36), primary_key=True)
23
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
24
+ path: Mapped[str] = mapped_column(String(1024), nullable=False)
25
+ description: Mapped[str] = mapped_column(String(1024), default="")
26
+ metadata_json: Mapped[str] = mapped_column(Text, default="{}")
27
+ created_at: Mapped[str] = mapped_column(String(64), default=_utcnow)
28
+
29
+ def to_dict(self) -> Dict[str, Any]:
30
+ return {
31
+ "store_id": self.store_id,
32
+ "name": self.name,
33
+ "path": self.path,
34
+ "description": self.description,
35
+ "metadata": json.loads(self.metadata_json or "{}"),
36
+ "created_at": self.created_at,
37
+ }
38
+
39
+ @classmethod
40
+ def from_dict(cls, data: Dict[str, Any]) -> "VectorStoreIndex":
41
+ return cls(
42
+ store_id=data["store_id"],
43
+ name=data["name"],
44
+ path=data["path"],
45
+ description=data.get("description", ""),
46
+ metadata_json=json.dumps(data.get("metadata", {})),
47
+ created_at=data.get("created_at", _utcnow()),
48
+ )
app/models/schemas.py CHANGED
@@ -563,3 +563,92 @@ class SqlValidationResponse(BaseModel):
563
  warnings: List[str] = []
564
  tables: List[str] = []
565
  columns: List[str] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
563
  warnings: List[str] = []
564
  tables: List[str] = []
565
  columns: List[str] = []
566
+
567
+
568
+ # ---------------------------------------------------------------------------
569
+ # Vector Store (RAG) - Powered by Zvec
570
+ # ---------------------------------------------------------------------------
571
+
572
+ class VectorStoreCreate(BaseModel):
573
+ name: str = Field(..., min_length=1, max_length=256, description="Human-readable name")
574
+ description: Optional[str] = Field(None, max_length=1024)
575
+ metadata: Dict[str, Any] = Field(default_factory=dict)
576
+
577
+
578
+ class VectorStoreResponse(BaseModel):
579
+ success: bool
580
+ vector_store_id: str
581
+ app_id: str
582
+ name: str
583
+ description: Optional[str] = None
584
+ embedding_dimension: int
585
+ document_count: int
586
+ created_at: str
587
+ metadata: Dict[str, Any] = {}
588
+
589
+
590
+ class VectorStoreListResponse(BaseModel):
591
+ success: bool
592
+ total: int
593
+ stores: List[VectorStoreResponse]
594
+
595
+
596
+ class DocumentIngestRequest(BaseModel):
597
+ doc_id: str = Field(..., min_length=1, max_length=256)
598
+ text: str = Field(..., min_length=1)
599
+ source: Optional[str] = Field(None, max_length=512)
600
+ metadata: Dict[str, Any] = Field(default_factory=dict)
601
+ chunk_size: Optional[int] = Field(None, ge=64, le=4096)
602
+ chunk_overlap: Optional[int] = Field(None, ge=0, le=512)
603
+
604
+
605
+ class DocumentIngestResponse(BaseModel):
606
+ success: bool
607
+ vector_store_id: str
608
+ doc_id: str
609
+ chunks_ingested: int
610
+ time_ms: float
611
+ error: Optional[str] = None
612
+
613
+
614
+ class SearchRequest(BaseModel):
615
+ query: str = Field(..., min_length=1, max_length=5000, description="Natural language query")
616
+ top_k: int = Field(default=10, ge=1, le=100, description="Max results to return")
617
+ filter: Optional[str] = Field(None, description="Filter expression (e.g. 'category = \"tech\"')")
618
+ min_score: Optional[float] = Field(None, ge=0.0, le=1.0, description="Minimum similarity score threshold")
619
+ include_vectors: bool = Field(default=False, description="Include vector embeddings in results")
620
+ include_metadata: bool = Field(default=False, description="Include source metadata in results")
621
+
622
+
623
+ class SearchResultItem(BaseModel):
624
+ rank: int
625
+ doc_id: str
626
+ chunk_index: int
627
+ text: str
628
+ score: float
629
+ source: Optional[str] = None
630
+ metadata: Dict[str, Any] = {}
631
+ vector: Optional[List[float]] = None
632
+
633
+
634
+ class SearchResponse(BaseModel):
635
+ success: bool
636
+ vector_store_id: str
637
+ query: str
638
+ results: List[SearchResultItem]
639
+ total_results: int
640
+ time_ms: float
641
+ error: Optional[str] = None
642
+
643
+
644
+ class DeleteRequest(BaseModel):
645
+ ids: Optional[List[str]] = Field(None)
646
+ filter: Optional[str] = Field(None)
647
+
648
+
649
+ class DeleteResponse(BaseModel):
650
+ success: bool
651
+ vector_store_id: str
652
+ deleted_count: int
653
+ time_ms: float
654
+ error: Optional[str] = None
app/services/chunking_service.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import re
6
+ from typing import List
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def chunk_text(
12
+ text: str,
13
+ chunk_size: int = 512,
14
+ chunk_overlap: int = 64,
15
+ ) -> List[str]:
16
+ if chunk_overlap >= chunk_size:
17
+ chunk_overlap = chunk_size // 4
18
+
19
+ paragraphs = re.split(r"\n\s*\n", text.strip())
20
+ chunks: List[str] = []
21
+ current: List[str] = []
22
+ current_len = 0
23
+
24
+ for para in paragraphs:
25
+ para = para.strip()
26
+ if not para:
27
+ continue
28
+ para_len = len(para)
29
+
30
+ if current_len + para_len + 1 <= chunk_size:
31
+ current.append(para)
32
+ current_len += para_len + 1
33
+ else:
34
+ if current:
35
+ chunks.append("\n\n".join(current))
36
+ if para_len > chunk_size:
37
+ for i in range(0, para_len, chunk_size):
38
+ segment = para[i:i + chunk_size]
39
+ if len(segment) >= chunk_size // 3:
40
+ chunks.append(segment)
41
+ current = []
42
+ current_len = 0
43
+ else:
44
+ current = [para]
45
+ current_len = para_len + 1
46
+
47
+ if current:
48
+ chunks.append("\n\n".join(current))
49
+
50
+ if chunk_overlap > 0 and len(chunks) > 1:
51
+ overlapped: List[str] = []
52
+ for i, chunk in enumerate(chunks):
53
+ if i == 0:
54
+ overlapped.append(chunk)
55
+ else:
56
+ prev = chunks[i - 1]
57
+ overlap_text = " ".join(prev.split()[-chunk_overlap:]) if len(prev.split()) > chunk_overlap else prev
58
+ combined = f"{overlap_text}\n\n{chunk}"
59
+ overlapped.append(combined)
60
+ return overlapped if all(len(c) <= chunk_size + chunk_overlap + 10 for c in overlapped) else chunks
61
+
62
+ return chunks if chunks else [text]
63
+
64
+
65
+ async def chunk_text_async(
66
+ text: str,
67
+ chunk_size: int = 512,
68
+ chunk_overlap: int = 64,
69
+ ) -> List[str]:
70
+ loop = asyncio.get_running_loop()
71
+ return await loop.run_in_executor(None, chunk_text, text, chunk_size, chunk_overlap)
app/services/vector_store_service.py ADDED
@@ -0,0 +1,528 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import concurrent.futures
5
+ import json
6
+ import os
7
+ import shutil
8
+ import time
9
+ import uuid
10
+ from datetime import datetime, timezone
11
+ from typing import Any, Dict, List, Optional, Tuple
12
+
13
+ import zvec
14
+ from sqlalchemy import select
15
+
16
+ from app.config import get_settings
17
+ from app.core.logger import get_logger
18
+ from app.core.vector_store.deps import AsyncSessionLocal
19
+ from app.core.vector_store.models import VectorStoreIndex
20
+ from app.services.chunking_service import chunk_text_async
21
+ from app.services.embeddings_service import EmbeddingService
22
+
23
+ logger = get_logger(__name__)
24
+ settings = get_settings()
25
+
26
+ _EMBEDDING_DIM = 384
27
+ _MAX_WORKERS = min(16, (os.cpu_count() or 1) + 4)
28
+
29
+
30
+ def _run_sync(fn, *args, **kwargs):
31
+ return fn(*args, **kwargs)
32
+
33
+
34
+ class VectorStoreRecord:
35
+ def __init__(
36
+ self,
37
+ store_id: str,
38
+ name: str,
39
+ path: str,
40
+ description: str = "",
41
+ metadata: Optional[Dict[str, Any]] = None,
42
+ created_at: Optional[str] = None,
43
+ ):
44
+ self.store_id = store_id
45
+ self.name = name
46
+ self.path = path
47
+ self.description = description
48
+ self.metadata = metadata or {}
49
+ self.created_at = created_at or datetime.now(timezone.utc).isoformat()
50
+
51
+
52
+ class VectorStoreService:
53
+ def __init__(self, embedding_service: EmbeddingService):
54
+ self._embedding_service = embedding_service
55
+ self._stores: Dict[str, VectorStoreRecord] = {}
56
+ self._collections: Dict[str, "zvec.Collection"] = {}
57
+ self._data_dir = os.path.join(settings.data_dir, "vector_stores")
58
+ self._thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=_MAX_WORKERS, thread_name_prefix="zvec")
59
+ os.makedirs(self._data_dir, exist_ok=True)
60
+
61
+ def _store_path(self, store_id: str) -> str:
62
+ return os.path.join(self._data_dir, f"store_{store_id}")
63
+
64
+ def _get_collection(self, store_id: str) -> Optional["zvec.Collection"]:
65
+ return self._collections.get(store_id)
66
+
67
+ # --- SQLite persistence ---
68
+
69
+ async def init_db(self) -> None:
70
+ from app.core.vector_store.deps import init_vector_store_db
71
+ await init_vector_store_db()
72
+ async with AsyncSessionLocal() as session:
73
+ result = await session.execute(select(VectorStoreIndex))
74
+ rows = result.scalars().all()
75
+ for row in rows:
76
+ d = row.to_dict()
77
+ record = VectorStoreRecord(
78
+ store_id=d["store_id"],
79
+ name=d["name"],
80
+ path=d["path"],
81
+ description=d["description"],
82
+ metadata=d["metadata"],
83
+ created_at=d["created_at"],
84
+ )
85
+ self._stores[record.store_id] = record
86
+ store_path = d["path"]
87
+ if os.path.exists(os.path.join(store_path, "__zvec_meta")):
88
+ try:
89
+ col = zvec.open(store_path)
90
+ if col is not None:
91
+ self._collections[record.store_id] = col
92
+ except Exception as exc:
93
+ logger.warning("Could not open collection %s: %s", record.store_id, exc)
94
+
95
+ async def _persist_store(self, record: VectorStoreRecord) -> None:
96
+ async with AsyncSessionLocal() as session:
97
+ existing = await session.get(VectorStoreIndex, record.store_id)
98
+ if existing:
99
+ existing.name = record.name
100
+ existing.description = record.description
101
+ existing.metadata_json = json.dumps(record.metadata)
102
+ else:
103
+ session.add(VectorStoreIndex.from_dict({
104
+ "store_id": record.store_id,
105
+ "name": record.name,
106
+ "path": record.path,
107
+ "description": record.description,
108
+ "metadata": record.metadata,
109
+ "created_at": record.created_at,
110
+ }))
111
+ await session.commit()
112
+
113
+ async def _remove_persisted_store(self, store_id: str) -> None:
114
+ async with AsyncSessionLocal() as session:
115
+ row = await session.get(VectorStoreIndex, store_id)
116
+ if row:
117
+ await session.delete(row)
118
+ await session.commit()
119
+
120
+ # --- Synchronous helpers (run in thread pool) ---
121
+
122
+ def _open_or_create_collection_sync(self, store_id: str, store_path: str) -> "zvec.Collection":
123
+ col = self._collections.get(store_id)
124
+ if col is not None:
125
+ return col
126
+
127
+ if os.path.exists(os.path.join(store_path, "__zvec_meta")):
128
+ logger.info("Opening existing collection: %s", store_id)
129
+ try:
130
+ col = zvec.open(store_path)
131
+ if col is not None:
132
+ self._collections[store_id] = col
133
+ return col
134
+ except Exception as exc:
135
+ logger.warning("Could not open collection %s: %s", store_id, exc)
136
+
137
+ schema = zvec.CollectionSchema(
138
+ name=f"store_{store_id}",
139
+ fields=[
140
+ zvec.FieldSchema(name="text", data_type=zvec.DataType.STRING),
141
+ zvec.FieldSchema(
142
+ name="doc_id",
143
+ data_type=zvec.DataType.STRING,
144
+ index_param=zvec.InvertIndexParam(),
145
+ ),
146
+ zvec.FieldSchema(name="chunk_index", data_type=zvec.DataType.INT32),
147
+ zvec.FieldSchema(
148
+ name="source",
149
+ data_type=zvec.DataType.STRING,
150
+ index_param=zvec.InvertIndexParam(),
151
+ ),
152
+ zvec.FieldSchema(name="created_at", data_type=zvec.DataType.INT64),
153
+ ],
154
+ vectors=[
155
+ zvec.VectorSchema(
156
+ name="embedding",
157
+ data_type=zvec.DataType.VECTOR_FP32,
158
+ dimension=_EMBEDDING_DIM,
159
+ index_param=zvec.HnswIndexParam(metric_type=zvec.MetricType.COSINE),
160
+ ),
161
+ ],
162
+ )
163
+ logger.info("Creating new collection: %s", store_id)
164
+ col = zvec.create_and_open(path=store_path, schema=schema)
165
+ self._collections[store_id] = col
166
+ return col
167
+
168
+ def _ingest_document_sync(
169
+ self,
170
+ store_id: str,
171
+ doc_id: str,
172
+ text: str,
173
+ chunks: List[str],
174
+ embeddings: List[List[float]],
175
+ source: str,
176
+ ) -> Tuple[int, float]:
177
+ col = self._get_collection(store_id)
178
+ if col is None:
179
+ record = self._stores.get(store_id)
180
+ if record is None:
181
+ raise ValueError(f"Vector store {store_id} not found")
182
+ col = self._open_or_create_collection_sync(store_id, record.path)
183
+
184
+ now_ts = int(time.time())
185
+
186
+ docs = [
187
+ zvec.Doc(
188
+ id=f"{doc_id}_{i}",
189
+ vectors={"embedding": emb},
190
+ fields={
191
+ "text": chunk_text_str,
192
+ "doc_id": doc_id,
193
+ "chunk_index": i,
194
+ "source": source or "",
195
+ "created_at": now_ts,
196
+ },
197
+ )
198
+ for i, (chunk_text_str, emb) in enumerate(zip(chunks, embeddings))
199
+ ]
200
+
201
+ for i in range(0, len(docs), 100):
202
+ col.insert(docs[i:i + 100])
203
+
204
+ col.flush()
205
+ col.optimize()
206
+ return len(chunks), 0.0
207
+
208
+ def _search_sync(
209
+ self,
210
+ store_id: str,
211
+ query_emb: List[float],
212
+ top_k: int,
213
+ filter_expr: Optional[str],
214
+ min_score: Optional[float] = None,
215
+ include_vectors: bool = False,
216
+ include_metadata: bool = False,
217
+ ) -> List[Dict[str, Any]]:
218
+ col = self._get_collection(store_id)
219
+ if col is None:
220
+ record = self._stores.get(store_id)
221
+ if record is None:
222
+ raise ValueError(f"Vector store {store_id} not found")
223
+ col = self._open_or_create_collection_sync(store_id, record.path)
224
+
225
+ kwargs: Dict[str, Any] = {
226
+ "vectors": zvec.VectorQuery(field_name="embedding", vector=query_emb),
227
+ "topk": top_k,
228
+ }
229
+ if filter_expr:
230
+ kwargs["filter"] = filter_expr
231
+
232
+ results = col.query(**kwargs)
233
+
234
+ items = []
235
+ for i, r in enumerate(results):
236
+ score = float(r.score) if hasattr(r, "score") and r.score is not None else 0.0
237
+ if min_score is not None and score > (1.0 - min_score):
238
+ continue
239
+ item: Dict[str, Any] = {
240
+ "rank": len(items) + 1,
241
+ "doc_id": r.field("doc_id") if hasattr(r, "field") else r.id.rsplit("_", 1)[0],
242
+ "chunk_index": r.field("chunk_index") if hasattr(r, "field") else 0,
243
+ "text": r.field("text") if hasattr(r, "field") else "",
244
+ "score": score,
245
+ "source": r.field("source") if hasattr(r, "field") else "",
246
+ "metadata": {},
247
+ "vector": None,
248
+ }
249
+ if include_vectors:
250
+ try:
251
+ vec = r.vector("embedding") if hasattr(r, "vector") else None
252
+ item["vector"] = list(vec) if vec is not None else None
253
+ except Exception:
254
+ item["vector"] = None
255
+ if include_metadata:
256
+ item["metadata"] = {
257
+ "doc_id": item["doc_id"],
258
+ "chunk_index": item["chunk_index"],
259
+ "source": item["source"],
260
+ }
261
+ items.append(item)
262
+ return items
263
+
264
+ def _fetch_documents_sync(self, store_id: str, ids: List[str]) -> Dict[str, Any]:
265
+ col = self._get_collection(store_id)
266
+ if col is None:
267
+ record = self._stores.get(store_id)
268
+ if record is None:
269
+ raise ValueError(f"Vector store {store_id} not found")
270
+ col = self._open_or_create_collection_sync(store_id, record.path)
271
+
272
+ internal_ids = []
273
+ for doc_id in ids:
274
+ fetched = col.fetch(ids=[doc_id])
275
+ if doc_id in fetched:
276
+ internal_ids.append(doc_id)
277
+ continue
278
+ for i in range(0, 1024):
279
+ chunk_id = f"{doc_id}_{i}"
280
+ fetched = col.fetch(ids=[chunk_id])
281
+ if chunk_id in fetched:
282
+ internal_ids.append(chunk_id)
283
+ else:
284
+ break
285
+ break
286
+
287
+ if not internal_ids:
288
+ return {}
289
+
290
+ fetched = col.fetch(ids=internal_ids)
291
+ result = {}
292
+ for k, v in fetched.items():
293
+ result[k] = {
294
+ "id": v.id,
295
+ "text": v.field("text") if hasattr(v, "field") else "",
296
+ "doc_id": v.field("doc_id") if hasattr(v, "field") else "",
297
+ "chunk_index": v.field("chunk_index") if hasattr(v, "field") else 0,
298
+ "source": v.field("source") if hasattr(v, "field") else "",
299
+ }
300
+ return result
301
+
302
+ def _delete_documents_sync(
303
+ self,
304
+ store_id: str,
305
+ ids: Optional[List[str]],
306
+ filter_expr: Optional[str],
307
+ ) -> int:
308
+ col = self._get_collection(store_id)
309
+ if col is None:
310
+ record = self._stores.get(store_id)
311
+ if record is None:
312
+ raise ValueError(f"Vector store {store_id} not found")
313
+ col = self._open_or_create_collection_sync(store_id, record.path)
314
+
315
+ deleted = 0
316
+ if ids:
317
+ chunk_ids = []
318
+ for doc_id in ids:
319
+ chunk_ids.extend(f"{doc_id}_{i}" for i in range(4096))
320
+ fetched = col.fetch(ids=chunk_ids[:1000])
321
+ actual_ids = [k for k in chunk_ids if k in fetched]
322
+ if actual_ids:
323
+ col.delete(ids=actual_ids)
324
+ deleted = len(actual_ids)
325
+
326
+ if filter_expr:
327
+ col.delete_by_filter(filter=filter_expr)
328
+ deleted = max(deleted, 1)
329
+
330
+ col.flush()
331
+ return deleted
332
+
333
+ def _get_store_stats_sync(self, store_id: str) -> Dict[str, Any]:
334
+ col = self._get_collection(store_id)
335
+ if col is None:
336
+ record = self._stores.get(store_id)
337
+ if record is None:
338
+ raise ValueError(f"Vector store {store_id} not found")
339
+ col = self._open_or_create_collection_sync(store_id, record.path)
340
+
341
+ try:
342
+ stats = col.stats
343
+ doc_count = getattr(stats, 'doc_count', 0) if stats else 0
344
+ except Exception:
345
+ doc_count = 0
346
+
347
+ record = self._stores[store_id]
348
+ return {
349
+ "store_id": store_id,
350
+ "name": record.name,
351
+ "description": record.description,
352
+ "app_id": settings.application_id or store_id,
353
+ "embedding_dimension": _EMBEDDING_DIM,
354
+ "document_count": doc_count,
355
+ "created_at": record.created_at,
356
+ "metadata": record.metadata,
357
+ }
358
+
359
+ # --- Async public API ---
360
+
361
+ async def _run_in_thread(self, fn, *args, **kwargs):
362
+ loop = asyncio.get_running_loop()
363
+ return await loop.run_in_executor(self._thread_pool, _run_sync, lambda: fn(*args, **kwargs))
364
+
365
+ async def _run_sync_fn(self, fn):
366
+ loop = asyncio.get_running_loop()
367
+ return await loop.run_in_executor(self._thread_pool, fn)
368
+
369
+ async def create_store(
370
+ self,
371
+ name: str,
372
+ description: str = "",
373
+ metadata: Optional[Dict[str, Any]] = None,
374
+ ) -> Tuple[str, str]:
375
+ store_id = str(uuid.uuid4())
376
+ store_path = self._store_path(store_id)
377
+
378
+ await self._run_sync_fn(
379
+ lambda: self._open_or_create_collection_sync(store_id, store_path)
380
+ )
381
+
382
+ record = VectorStoreRecord(
383
+ store_id=store_id,
384
+ name=name,
385
+ path=store_path,
386
+ description=description,
387
+ metadata=metadata or {},
388
+ )
389
+ self._stores[store_id] = record
390
+ await self._persist_store(record)
391
+
392
+ app_id = settings.application_id or store_id
393
+ logger.info("Created vector store: %s (name=%s, app_id=%s)", store_id, name, app_id)
394
+ return store_id, app_id
395
+
396
+ def list_stores(self) -> List[VectorStoreRecord]:
397
+ return list(self._stores.values())
398
+
399
+ def get_store(self, store_id: str) -> Optional[VectorStoreRecord]:
400
+ return self._stores.get(store_id)
401
+
402
+ async def delete_store(self, store_id: str) -> bool:
403
+ record = self._stores.pop(store_id, None)
404
+ if record is None:
405
+ return False
406
+
407
+ col = self._collections.pop(store_id, None)
408
+ if col is not None:
409
+ try:
410
+ await self._run_sync_fn(lambda: col.destroy())
411
+ except Exception as exc:
412
+ logger.warning("Error destroying collection %s: %s", store_id, exc)
413
+
414
+ store_path = record.path
415
+ if os.path.exists(store_path):
416
+ await self._run_sync_fn(lambda: shutil.rmtree(store_path, ignore_errors=True))
417
+
418
+ await self._remove_persisted_store(store_id)
419
+ logger.info("Deleted vector store: %s", store_id)
420
+ return True
421
+
422
+ async def _ensure_embedding_model(self) -> None:
423
+ if not self._embedding_service.is_loaded(_EMBEDDING_DIM):
424
+ loop = asyncio.get_running_loop()
425
+ await loop.run_in_executor(None, self._embedding_service.load_model, _EMBEDDING_DIM)
426
+
427
+ async def ingest_document(
428
+ self,
429
+ store_id: str,
430
+ doc_id: str,
431
+ text: str,
432
+ source: Optional[str] = None,
433
+ metadata: Optional[Dict[str, Any]] = None,
434
+ chunk_size: Optional[int] = None,
435
+ chunk_overlap: Optional[int] = None,
436
+ ) -> Tuple[int, float]:
437
+ await self._ensure_embedding_model()
438
+
439
+ size = chunk_size or 512
440
+ overlap = chunk_overlap or 64
441
+ chunks = await chunk_text_async(text, chunk_size=size, chunk_overlap=overlap)
442
+ metadata = metadata or {}
443
+ source = source or metadata.get("source", "")
444
+
445
+ start = time.perf_counter()
446
+
447
+ loop = asyncio.get_running_loop()
448
+ embeddings = await loop.run_in_executor(
449
+ self._thread_pool,
450
+ self._embedding_service.generate_embedding,
451
+ chunks,
452
+ _EMBEDDING_DIM,
453
+ )
454
+
455
+ elapsed_sync = await self._run_sync_fn(
456
+ lambda: self._ingest_document_sync(store_id, doc_id, text, chunks, embeddings, source or "")
457
+ )
458
+
459
+ elapsed = (time.perf_counter() - start) * 1000
460
+ logger.info("Ingested doc %s into store %s: %d chunks in %.2f ms", doc_id, store_id, len(chunks), elapsed)
461
+ return len(chunks), elapsed
462
+
463
+ async def search(
464
+ self,
465
+ store_id: str,
466
+ query_text: str,
467
+ top_k: int = 10,
468
+ filter_expr: Optional[str] = None,
469
+ min_score: Optional[float] = None,
470
+ include_vectors: bool = False,
471
+ include_metadata: bool = False,
472
+ ) -> Tuple[List[Dict[str, Any]], float]:
473
+ await self._ensure_embedding_model()
474
+
475
+ start = time.perf_counter()
476
+
477
+ loop = asyncio.get_running_loop()
478
+ query_emb = await loop.run_in_executor(
479
+ self._thread_pool,
480
+ lambda: self._embedding_service.generate_embedding([query_text], _EMBEDDING_DIM)[0],
481
+ )
482
+
483
+ items = await self._run_sync_fn(
484
+ lambda: self._search_sync(store_id, query_emb, top_k, filter_expr, min_score, include_vectors, include_metadata)
485
+ )
486
+
487
+ elapsed = (time.perf_counter() - start) * 1000
488
+ return items, elapsed
489
+
490
+ async def fetch_documents(self, store_id: str, ids: List[str]) -> Dict[str, Any]:
491
+ return await self._run_sync_fn(
492
+ lambda: self._fetch_documents_sync(store_id, ids)
493
+ )
494
+
495
+ async def delete_documents(
496
+ self,
497
+ store_id: str,
498
+ ids: Optional[List[str]] = None,
499
+ filter_expr: Optional[str] = None,
500
+ ) -> int:
501
+ return await self._run_sync_fn(
502
+ lambda: self._delete_documents_sync(store_id, ids, filter_expr)
503
+ )
504
+
505
+ async def get_store_stats(self, store_id: str) -> Dict[str, Any]:
506
+ return await self._run_sync_fn(
507
+ lambda: self._get_store_stats_sync(store_id)
508
+ )
509
+
510
+ async def get_total_document_count(self) -> int:
511
+ total = 0
512
+ for store_id in list(self._stores.keys()):
513
+ try:
514
+ stats = await self.get_store_stats(store_id)
515
+ total += stats.get("document_count", 0)
516
+ except Exception:
517
+ pass
518
+ return total
519
+
520
+ async def close_all(self) -> None:
521
+ for store_id, col in list(self._collections.items()):
522
+ try:
523
+ await self._run_sync_fn(lambda: col.flush())
524
+ except Exception:
525
+ pass
526
+ self._collections.clear()
527
+ self._stores.clear()
528
+ self._thread_pool.shutdown(wait=True)
requirements.txt CHANGED
@@ -1,3 +1,4 @@
 
1
  markitdown[all]>=0.1.5
2
  fastapi>=0.111.0
3
  uvicorn[standard]>=0.30.0
 
1
+ zvec>=0.4.0
2
  markitdown[all]>=0.1.5
3
  fastapi>=0.111.0
4
  uvicorn[standard]>=0.30.0