Coverage for src/semware/services/vectordb.py: 71%
217 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-09 02:16 -0700
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-09 02:16 -0700
1"""LanceDB integration for vector storage and retrieval."""
3import json
4from datetime import datetime
5from pathlib import Path
6from typing import Any
8import lancedb
9import numpy as np
10import pyarrow as pa
11from loguru import logger
13from ..config import settings
14from ..models.schemas import DataRecord, SearchResult, TableSchema
17class VectorDBService:
18 """Service for managing LanceDB operations."""
20 def __init__(self, db_path: Path | None = None):
21 """Initialize the vector database service.
23 Args:
24 db_path: Path to the database directory
25 """
26 self.db_path = db_path or settings.db_path
27 self.db_path.mkdir(parents=True, exist_ok=True)
29 # Connect to LanceDB
30 self.db = lancedb.connect(str(self.db_path))
31 self.table_schemas: dict[str, TableSchema] = {}
33 # Load existing table schemas
34 self._load_table_schemas()
36 logger.info(f"VectorDB initialized at: {self.db_path}")
38 def _load_table_schemas(self) -> None:
39 """Load table schemas from metadata."""
40 schema_file = self.db_path / "table_schemas.json"
41 if schema_file.exists():
42 try:
43 with open(schema_file) as f:
44 schemas_data = json.load(f)
45 for table_name, schema_data in schemas_data.items():
46 self.table_schemas[table_name] = TableSchema(**schema_data)
47 logger.info(f"Loaded {len(self.table_schemas)} table schemas")
48 except Exception as e:
49 logger.error(f"Failed to load table schemas: {e}")
51 def _save_table_schemas(self) -> None:
52 """Save table schemas to metadata."""
53 schema_file = self.db_path / "table_schemas.json"
54 try:
55 schemas_data = {
56 name: schema.model_dump() for name, schema in self.table_schemas.items()
57 }
58 with open(schema_file, "w") as f:
59 json.dump(schemas_data, f, indent=2)
60 logger.debug("Table schemas saved")
61 except Exception as e:
62 logger.error(f"Failed to save table schemas: {e}")
64 def _create_arrow_schema(self, table_schema: TableSchema) -> pa.Schema:
65 """Create PyArrow schema from table schema.
67 Args:
68 table_schema: Table schema definition
70 Returns:
71 PyArrow schema
72 """
73 fields = []
75 # Add all columns from schema
76 for col_name, col_type in table_schema.columns.items():
77 if col_type == "string":
78 arrow_type = pa.string()
79 elif col_type == "int":
80 arrow_type = pa.int64()
81 elif col_type == "float":
82 arrow_type = pa.float64()
83 elif col_type == "bool":
84 arrow_type = pa.bool_()
85 else:
86 logger.warning(
87 f"Unknown column type '{col_type}', defaulting to string"
88 )
89 arrow_type = pa.string()
91 fields.append(pa.field(col_name, arrow_type))
93 # Add embedding vector column (fixed size for LanceDB vector indexing)
94 fields.append(pa.field("_embedding", pa.list_(pa.float32(), settings.embedding_dimension)))
96 # Add metadata columns
97 fields.append(pa.field("_created_at", pa.timestamp("ms")))
98 fields.append(pa.field("_updated_at", pa.timestamp("ms")))
100 return pa.schema(fields)
102 def _create_dummy_record(self, table_schema: TableSchema, arrow_schema: pa.Schema) -> pa.Table:
103 """Create a dummy record for table initialization.
105 Args:
106 table_schema: Table schema definition
107 arrow_schema: PyArrow schema
109 Returns:
110 PyArrow table with one dummy record
111 """
112 data = {}
113 current_time = datetime.now()
115 # Add dummy values for all columns
116 for col_name, col_type in table_schema.columns.items():
117 if col_type == "string":
118 data[col_name] = ["dummy"]
119 elif col_type == "int":
120 data[col_name] = [0]
121 elif col_type == "float":
122 data[col_name] = [0.0]
123 elif col_type == "bool":
124 data[col_name] = [False]
125 else:
126 data[col_name] = ["dummy"]
128 # Add dummy embedding (384 dimensions for MiniLM)
129 data["_embedding"] = [[0.0] * settings.embedding_dimension]
131 # Add metadata
132 data["_created_at"] = [current_time]
133 data["_updated_at"] = [current_time]
135 return pa.table(data, schema=arrow_schema)
137 def create_table(self, schema: TableSchema) -> None:
138 """Create a new table with the given schema.
140 Args:
141 schema: Table schema definition
143 Raises:
144 ValueError: If table already exists
145 """
146 if schema.name in self.table_schemas:
147 raise ValueError(f"Table '{schema.name}' already exists")
149 if schema.name in self.db.table_names():
150 raise ValueError(f"Table '{schema.name}' already exists in database")
152 # Create arrow schema
153 arrow_schema = self._create_arrow_schema(schema)
155 # Create table using schema only (LanceDB will handle empty table creation)
156 try:
157 table = self.db.create_table(schema.name, schema=arrow_schema, mode="create")
158 except Exception as e:
159 logger.error(f"Failed to create table with schema-only approach: {e}")
160 # Fallback: create with one dummy record then delete it
161 dummy_data = self._create_dummy_record(schema, arrow_schema)
162 table = self.db.create_table(schema.name, dummy_data, mode="create")
163 # Delete the dummy record
164 table.delete("_rowid = 0")
166 # Store schema
167 self.table_schemas[schema.name] = schema
168 self._save_table_schemas()
170 logger.info(f"Created table '{schema.name}' with {len(schema.columns)} columns")
172 def delete_table(self, table_name: str) -> None:
173 """Delete a table.
175 Args:
176 table_name: Name of the table to delete
178 Raises:
179 ValueError: If table doesn't exist
180 """
181 if table_name not in self.table_schemas:
182 raise ValueError(f"Table '{table_name}' does not exist")
184 # Drop the table
185 self.db.drop_table(table_name)
187 # Remove from schemas
188 del self.table_schemas[table_name]
189 self._save_table_schemas()
191 logger.info(f"Deleted table '{table_name}'")
193 def get_table_names(self) -> list[str]:
194 """Get list of all table names.
196 Returns:
197 List of table names
198 """
199 return list(self.table_schemas.keys())
201 def get_table_schema(self, table_name: str) -> TableSchema:
202 """Get schema for a specific table.
204 Args:
205 table_name: Name of the table
207 Returns:
208 Table schema
210 Raises:
211 ValueError: If table doesn't exist
212 """
213 if table_name not in self.table_schemas:
214 raise ValueError(f"Table '{table_name}' does not exist")
216 return self.table_schemas[table_name]
218 def get_table_record_count(self, table_name: str) -> int:
219 """Get the number of records in a table.
221 Args:
222 table_name: Name of the table
224 Returns:
225 Number of records
226 """
227 if table_name not in self.table_schemas:
228 raise ValueError(f"Table '{table_name}' does not exist")
230 try:
231 table = self.db.open_table(table_name)
232 return table.count_rows()
233 except Exception as e:
234 logger.error(f"Failed to count rows in table '{table_name}': {e}")
235 return 0
237 def upsert_records(
238 self, table_name: str, records: list[DataRecord], embeddings: list[np.ndarray]
239 ) -> tuple[int, int]:
240 """Insert or update records in a table.
242 Args:
243 table_name: Name of the table
244 records: List of data records
245 embeddings: List of embedding vectors (one per record)
247 Returns:
248 Tuple of (inserted_count, updated_count)
250 Raises:
251 ValueError: If table doesn't exist or data validation fails
252 """
253 if table_name not in self.table_schemas:
254 raise ValueError(f"Table '{table_name}' does not exist")
256 if len(records) != len(embeddings):
257 raise ValueError("Number of records and embeddings must match")
259 schema = self.table_schemas[table_name]
260 table = self.db.open_table(table_name)
262 # Prepare data for insertion
263 insert_data = []
264 now = datetime.now()
266 for record, embedding in zip(records, embeddings, strict=False):
267 # Validate record against schema
268 self._validate_record(record, schema)
270 # Prepare row data
271 row = record.data.copy()
272 row["_embedding"] = embedding.tolist()
273 row["_created_at"] = now
274 row["_updated_at"] = now
276 insert_data.append(row)
278 # Convert to PyArrow table with proper schema
279 arrow_schema = self._create_arrow_schema(schema)
281 # Convert dict data to column format
282 columns_data = {}
283 for field in arrow_schema:
284 column_name = field.name
285 if column_name == "_embedding":
286 columns_data[column_name] = [row[column_name] for row in insert_data]
287 else:
288 columns_data[column_name] = [row[column_name] for row in insert_data]
290 arrow_table = pa.table(columns_data, schema=arrow_schema)
292 # Upsert data (merge on ID column)
293 table.merge_insert(
294 f"{schema.id_column}"
295 ).when_matched_update_all().when_not_matched_insert_all().execute(arrow_table)
297 # For now, assume all are inserts (Lance doesn't return update counts easily)
298 inserted_count = len(records)
299 updated_count = 0
301 logger.info(f"Upserted {len(records)} records in table '{table_name}'")
302 return inserted_count, updated_count
304 def delete_record(self, table_name: str, record_id: str | int) -> None:
305 """Delete a record from a table.
307 Args:
308 table_name: Name of the table
309 record_id: ID of the record to delete
311 Raises:
312 ValueError: If table doesn't exist
313 """
314 if table_name not in self.table_schemas:
315 raise ValueError(f"Table '{table_name}' does not exist")
317 schema = self.table_schemas[table_name]
318 table = self.db.open_table(table_name)
320 # Delete the record
321 table.delete(f"{schema.id_column} = '{record_id}'")
323 logger.info(f"Deleted record with ID '{record_id}' from table '{table_name}'")
325 def get_record(
326 self, table_name: str, record_id: str | int
327 ) -> dict[str, Any] | None:
328 """Get a specific record by ID.
330 Args:
331 table_name: Name of the table
332 record_id: ID of the record
334 Returns:
335 Record data or None if not found
336 """
337 if table_name not in self.table_schemas:
338 raise ValueError(f"Table '{table_name}' does not exist")
340 schema = self.table_schemas[table_name]
341 table = self.db.open_table(table_name)
343 try:
344 result = (
345 table.search().where(f"{schema.id_column} = '{record_id}'").to_pandas()
346 )
347 if len(result) == 0:
348 return None
350 # Convert first row to dict and remove internal columns
351 record = result.iloc[0].to_dict()
352 return {k: v for k, v in record.items() if not k.startswith("_")}
353 except Exception as e:
354 logger.error(
355 f"Failed to get record '{record_id}' from table '{table_name}': {e}"
356 )
357 return None
359 def similarity_search(
360 self,
361 table_name: str,
362 query_embedding: np.ndarray,
363 threshold: float,
364 limit: int | None = None,
365 ) -> list[SearchResult]:
366 """Search for records by similarity threshold.
368 Args:
369 table_name: Name of the table
370 query_embedding: Query embedding vector
371 threshold: Minimum similarity threshold
372 limit: Maximum number of results
374 Returns:
375 List of search results
376 """
377 if table_name not in self.table_schemas:
378 raise ValueError(f"Table '{table_name}' does not exist")
380 schema = self.table_schemas[table_name]
381 table = self.db.open_table(table_name)
383 try:
384 # Perform vector search with explicit vector column
385 search_query = table.search(query_embedding.tolist(), vector_column_name="_embedding")
387 if limit:
388 search_query = search_query.limit(limit)
390 results = search_query.to_pandas()
392 # Filter by threshold and convert to SearchResult
393 search_results = []
394 for _, row in results.iterrows():
395 # LanceDB returns distance, convert to similarity (1 - normalized distance)
396 similarity = max(0.0, min(1.0, 1.0 - row["_distance"]))
398 if similarity >= threshold:
399 # Extract original data (exclude internal columns)
400 data = {k: v for k, v in row.items() if not k.startswith("_")}
402 search_results.append(
403 SearchResult(
404 id=row[schema.id_column],
405 data=data,
406 similarity_score=similarity,
407 )
408 )
410 logger.info(f"Similarity search returned {len(search_results)} results")
411 return search_results
413 except Exception as e:
414 logger.error(f"Similarity search failed for table '{table_name}': {e}")
415 return []
417 def top_k_search(
418 self, table_name: str, query_embedding: np.ndarray, k: int
419 ) -> list[SearchResult]:
420 """Search for top-k most similar records.
422 Args:
423 table_name: Name of the table
424 query_embedding: Query embedding vector
425 k: Number of top results to return
427 Returns:
428 List of search results
429 """
430 if table_name not in self.table_schemas:
431 raise ValueError(f"Table '{table_name}' does not exist")
433 schema = self.table_schemas[table_name]
434 table = self.db.open_table(table_name)
436 try:
437 # Perform vector search with explicit vector column
438 results = table.search(query_embedding.tolist(), vector_column_name="_embedding").limit(k).to_pandas()
440 # Convert to SearchResult
441 search_results = []
442 for _, row in results.iterrows():
443 # LanceDB returns distance, convert to similarity
444 similarity = max(0.0, min(1.0, 1.0 - row["_distance"]))
446 # Extract original data (exclude internal columns)
447 data = {k: v for k, v in row.items() if not k.startswith("_")}
449 search_results.append(
450 SearchResult(
451 id=row[schema.id_column], data=data, similarity_score=similarity
452 )
453 )
455 logger.info(f"Top-k search returned {len(search_results)} results")
456 return search_results
458 except Exception as e:
459 logger.error(f"Top-k search failed for table '{table_name}': {e}")
460 return []
462 def _validate_record(self, record: DataRecord, schema: TableSchema) -> None:
463 """Validate a record against the table schema.
465 Args:
466 record: Data record to validate
467 schema: Table schema
469 Raises:
470 ValueError: If validation fails
471 """
472 # Check if all required columns are present
473 for col_name in schema.columns.keys():
474 if col_name not in record.data:
475 raise ValueError(f"Missing required column: {col_name}")
477 # Check for extra columns
478 for col_name in record.data.keys():
479 if col_name not in schema.columns:
480 raise ValueError(f"Unknown column: {col_name}")
482 # Validate ID column
483 try:
484 record.get_id(schema.id_column)
485 except ValueError as e:
486 raise ValueError(f"Invalid ID: {e}")
488 # Validate embedding column
489 try:
490 text = record.get_text_for_embedding(schema.embedding_column)
491 if not text.strip():
492 raise ValueError("Embedding column cannot be empty")
493 except ValueError as e:
494 raise ValueError(f"Invalid embedding column: {e}")
497# Global vector database instance
498vectordb = VectorDBService()