1544 lines
55 KiB
Python
1544 lines
55 KiB
Python
"""
|
|
Eric's Semantic Search - Index Manager
|
|
|
|
Description: Core index management coordinating FAISS vector search, SQLite metadata,
|
|
and thumbnail storage. Handles indexing of images, videos, and documents,
|
|
as well as search operations and index maintenance.
|
|
|
|
Author: Eric Hiss (GitHub: EricRollei)
|
|
Contact: eric@historic.camera, eric@rollei.us
|
|
License: Dual License (Non-Commercial: CC BY-NC 4.0, Commercial: Contact author)
|
|
Copyright (c) 2026 Eric Hiss. All rights reserved.
|
|
|
|
Dependencies:
|
|
- FAISS (MIT License): Facebook AI Similarity Search - https://github.com/facebookresearch/faiss
|
|
- NumPy (BSD License)
|
|
|
|
See LICENSE.md for complete license information.
|
|
"""
|
|
|
|
import json
|
|
import numpy as np
|
|
from pathlib import Path
|
|
from typing import List, Dict, Any, Optional, Tuple, Callable
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
import threading
|
|
|
|
import faiss
|
|
|
|
from .config import (
|
|
get_index_path,
|
|
get_index_faiss_path,
|
|
get_index_config_path,
|
|
get_thumbnails_path,
|
|
DEFAULT_THUMBNAIL_SIZE,
|
|
DEFAULT_BATCH_SIZE,
|
|
DEFAULT_TOP_K,
|
|
CHECKPOINT_INTERVAL,
|
|
VIDEO_FRAME_MAX_PIXELS,
|
|
)
|
|
from .database import DatabaseManager, ImageRecord
|
|
from .thumbnail_manager import (
|
|
ThumbnailManager,
|
|
collect_images_from_folder,
|
|
collect_media_from_folder,
|
|
collect_documents_from_folder,
|
|
get_file_info,
|
|
compute_file_hash,
|
|
is_supported_media,
|
|
)
|
|
from .video_utils import (
|
|
is_video_file,
|
|
extract_frames,
|
|
get_video_info,
|
|
)
|
|
from .pdf_utils import (
|
|
is_pdf_file,
|
|
get_pdf_info,
|
|
extract_all_pages,
|
|
get_pdf_page_path,
|
|
check_pymupdf,
|
|
)
|
|
from .model_wrapper import EmbeddingModelWrapper
|
|
from .index_factory import (
|
|
IndexFactory,
|
|
IndexConfig,
|
|
IndexType,
|
|
INDEX_TYPE_DESCRIPTIONS,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class SearchResult:
|
|
"""A single search result"""
|
|
file_path: str
|
|
thumbnail_path: str
|
|
score: float
|
|
vector_id: int
|
|
media_type: str = "image" # "image", "video", or "document"
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"file_path": self.file_path,
|
|
"thumbnail_path": self.thumbnail_path,
|
|
"score": float(self.score),
|
|
"vector_id": self.vector_id,
|
|
"media_type": self.media_type,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class SearchResults:
|
|
"""Collection of search results"""
|
|
results: List[SearchResult]
|
|
query_type: str # "text" or "image"
|
|
query_info: str # The query text or image path
|
|
|
|
def __len__(self) -> int:
|
|
return len(self.results)
|
|
|
|
def __iter__(self):
|
|
return iter(self.results)
|
|
|
|
def __getitem__(self, idx) -> SearchResult:
|
|
return self.results[idx]
|
|
|
|
def get_paths(self) -> List[str]:
|
|
"""Get list of file paths"""
|
|
return [r.file_path for r in self.results]
|
|
|
|
def get_thumbnails(self) -> List[str]:
|
|
"""Get list of thumbnail paths"""
|
|
return [r.thumbnail_path for r in self.results]
|
|
|
|
def get_scores(self) -> List[float]:
|
|
"""Get list of similarity scores"""
|
|
return [r.score for r in self.results]
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"query_type": self.query_type,
|
|
"query_info": self.query_info,
|
|
"count": len(self.results),
|
|
"results": [r.to_dict() for r in self.results],
|
|
}
|
|
|
|
|
|
class SemanticIndex:
|
|
"""
|
|
Manages a semantic search index combining:
|
|
- FAISS for vector similarity search
|
|
- SQLite for metadata storage
|
|
- Thumbnail cache for fast previews
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
index_name: str,
|
|
embedding_dim: int = 4096,
|
|
index_type: str = "flat",
|
|
):
|
|
"""
|
|
Initialize or load an existing index.
|
|
|
|
Args:
|
|
index_name: Name of the index (used for directory structure)
|
|
embedding_dim: Dimension of embedding vectors (4096 for 8B, 2048 for 2B)
|
|
index_type: Type of FAISS index ("flat", "ivf_flat", "hnsw", "ivf_pq")
|
|
"""
|
|
self.index_name = index_name
|
|
self.embedding_dim = embedding_dim
|
|
self.index_type_str = index_type
|
|
|
|
# Paths
|
|
self.index_path = get_index_path(index_name)
|
|
self.faiss_path = get_index_faiss_path(index_name)
|
|
self.config_path = get_index_config_path(index_name)
|
|
|
|
# Ensure directory exists
|
|
self.index_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Initialize components
|
|
self.db = DatabaseManager(index_name)
|
|
self.thumbnails = ThumbnailManager(index_name)
|
|
|
|
# Load or create config first (may contain index settings)
|
|
self._config = self._load_or_create_config()
|
|
|
|
# Load or create FAISS index
|
|
self._faiss_index = None
|
|
self._index_config = None
|
|
self._load_or_create_faiss()
|
|
|
|
# Thread safety - use RLock for re-entrancy (methods that hold the lock can call other locking methods)
|
|
self._lock = threading.RLock()
|
|
|
|
# Training buffer for IVF indexes
|
|
self._training_buffer = []
|
|
self._training_buffer_size = 0
|
|
|
|
def _load_or_create_faiss(self):
|
|
"""Load existing FAISS index or create new one"""
|
|
if self.faiss_path.exists():
|
|
print(f"[SemanticSearch] Loading existing FAISS index from {self.faiss_path}")
|
|
self._faiss_index = faiss.read_index(str(self.faiss_path))
|
|
# Update embedding dim from loaded index
|
|
self.embedding_dim = self._faiss_index.d
|
|
|
|
# Detect and store index config
|
|
detected_type = IndexFactory.detect_index_type(self._faiss_index)
|
|
self._index_config = IndexConfig(
|
|
index_type=detected_type,
|
|
dimension=self.embedding_dim,
|
|
nlist=self._config.get("index_params", {}).get("nlist", 100),
|
|
nprobe=self._config.get("index_params", {}).get("nprobe", 10),
|
|
hnsw_m=self._config.get("index_params", {}).get("hnsw_m", 32),
|
|
hnsw_ef_search=self._config.get("index_params", {}).get("hnsw_ef_search", 64),
|
|
is_trained=getattr(self._faiss_index, 'is_trained', True),
|
|
)
|
|
print(f"[SemanticSearch] Index type: {detected_type.value}, vectors: {self._faiss_index.ntotal}")
|
|
else:
|
|
# Create new index with specified type
|
|
index_type = IndexType.from_string(self.index_type_str)
|
|
print(f"[SemanticSearch] Creating new FAISS index: type={index_type.value}, dim={self.embedding_dim}")
|
|
|
|
self._index_config = IndexConfig(
|
|
index_type=index_type,
|
|
dimension=self.embedding_dim,
|
|
)
|
|
self._faiss_index = IndexFactory.create_index(self._index_config)
|
|
|
|
# Store index type in config
|
|
self._config["index_type"] = index_type.value
|
|
self._config["index_params"] = self._index_config.to_dict()
|
|
|
|
def _load_or_create_config(self) -> Dict[str, Any]:
|
|
"""Load or create index configuration"""
|
|
if self.config_path.exists():
|
|
with open(self.config_path, "r") as f:
|
|
return json.load(f)
|
|
else:
|
|
config = {
|
|
"index_name": self.index_name,
|
|
"embedding_dim": self.embedding_dim,
|
|
"created_at": datetime.now().isoformat(),
|
|
"model_used": None,
|
|
}
|
|
self._save_config(config)
|
|
return config
|
|
|
|
def _save_config(self, config: Dict[str, Any] = None):
|
|
"""Save configuration to disk"""
|
|
if config is None:
|
|
config = self._config
|
|
with open(self.config_path, "w") as f:
|
|
json.dump(config, f, indent=2)
|
|
|
|
def save(self):
|
|
"""Save FAISS index and config to disk"""
|
|
with self._lock:
|
|
faiss.write_index(self._faiss_index, str(self.faiss_path))
|
|
self._save_config()
|
|
|
|
def add_vectors(
|
|
self,
|
|
vectors: np.ndarray,
|
|
file_paths: List[str],
|
|
thumbnail_paths: List[str],
|
|
file_infos: List[Tuple[float, int]], # (mtime, size) for each file
|
|
media_types: Optional[List[str]] = None,
|
|
frame_counts: Optional[List[Optional[int]]] = None,
|
|
durations: Optional[List[Optional[float]]] = None,
|
|
) -> List[int]:
|
|
"""
|
|
Add vectors to the index.
|
|
|
|
Args:
|
|
vectors: Embedding vectors (n, embedding_dim)
|
|
file_paths: Paths to original image/video files
|
|
thumbnail_paths: Paths to cached thumbnails
|
|
file_infos: (mtime, size) tuples for change detection
|
|
media_types: "image" or "video" for each file (default: all "image")
|
|
frame_counts: Frame count for videos (None for images)
|
|
durations: Duration in seconds for videos (None for images)
|
|
|
|
Returns:
|
|
List of assigned vector IDs
|
|
"""
|
|
if len(vectors) == 0:
|
|
return []
|
|
|
|
# Default media types
|
|
if media_types is None:
|
|
media_types = ["image"] * len(vectors)
|
|
if frame_counts is None:
|
|
frame_counts = [None] * len(vectors)
|
|
if durations is None:
|
|
durations = [None] * len(vectors)
|
|
|
|
vectors = np.ascontiguousarray(vectors.astype(np.float32))
|
|
|
|
with self._lock:
|
|
# Handle IVF training if needed
|
|
if IndexFactory.needs_training(self._faiss_index):
|
|
min_training = IndexFactory.get_min_training_size(self._index_config)
|
|
|
|
# Buffer vectors for training
|
|
self._training_buffer.append(vectors)
|
|
self._training_buffer_size += len(vectors)
|
|
|
|
if self._training_buffer_size >= min_training:
|
|
# Combine all buffered vectors and train
|
|
all_vectors = np.vstack(self._training_buffer)
|
|
print(f"[SemanticSearch] Training index with {len(all_vectors)} vectors...")
|
|
|
|
if IndexFactory.train_index(self._faiss_index, all_vectors):
|
|
print("[SemanticSearch] Training complete, adding buffered vectors...")
|
|
# Now add all buffered vectors
|
|
self._faiss_index.add(all_vectors)
|
|
self._index_config.is_trained = True
|
|
self._index_config.training_size = len(all_vectors)
|
|
self._config["index_params"] = self._index_config.to_dict()
|
|
else:
|
|
print("[SemanticSearch] Training failed, falling back to Flat index")
|
|
# Fallback to flat index
|
|
self._index_config.index_type = IndexType.FLAT
|
|
self._faiss_index = IndexFactory.create_index(self._index_config)
|
|
self._faiss_index.add(all_vectors)
|
|
|
|
# Clear buffer
|
|
self._training_buffer = []
|
|
self._training_buffer_size = 0
|
|
else:
|
|
print(f"[SemanticSearch] Buffering vectors for training: {self._training_buffer_size}/{min_training}")
|
|
# Still need to record in database for later
|
|
# We'll add to FAISS once trained
|
|
else:
|
|
# Index is ready, add directly
|
|
self._faiss_index.add(vectors)
|
|
|
|
# Get starting vector ID
|
|
start_id = self._faiss_index.ntotal - len(vectors)
|
|
if start_id < 0:
|
|
# Vectors were buffered, not yet added
|
|
start_id = self.db.get_max_vector_id() + 1 if self.db.get_max_vector_id() >= 0 else 0
|
|
|
|
# Add to database
|
|
vector_ids = []
|
|
for i, (path, thumb, (mtime, size)) in enumerate(zip(file_paths, thumbnail_paths, file_infos)):
|
|
vector_id = start_id + i
|
|
file_hash = f"{size}_{mtime}" # Fast hash
|
|
|
|
self.db.add_image(
|
|
file_path=str(path),
|
|
file_hash=file_hash,
|
|
file_mtime=mtime,
|
|
file_size=size,
|
|
thumbnail_path=str(thumb),
|
|
vector_id=vector_id,
|
|
media_type=media_types[i],
|
|
frame_count=frame_counts[i],
|
|
duration_seconds=durations[i],
|
|
)
|
|
vector_ids.append(vector_id)
|
|
|
|
return vector_ids
|
|
|
|
def search(
|
|
self,
|
|
query_vector: np.ndarray,
|
|
top_k: int = 50,
|
|
) -> SearchResults:
|
|
"""
|
|
Search for similar images by vector.
|
|
|
|
Args:
|
|
query_vector: Query embedding (1D or 2D with batch size 1)
|
|
top_k: Number of results to return
|
|
|
|
Returns:
|
|
SearchResults object
|
|
"""
|
|
# Ensure correct shape
|
|
if query_vector.ndim == 1:
|
|
query_vector = query_vector.reshape(1, -1)
|
|
|
|
query_vector = np.ascontiguousarray(query_vector.astype(np.float32))
|
|
|
|
with self._lock:
|
|
# Search FAISS
|
|
scores, indices = self._faiss_index.search(query_vector, top_k)
|
|
scores = scores[0] # Remove batch dimension
|
|
indices = indices[0]
|
|
|
|
# Filter out invalid results (index -1 means not enough results)
|
|
valid_mask = indices >= 0
|
|
scores = scores[valid_mask]
|
|
indices = indices[valid_mask]
|
|
|
|
# Fetch metadata from database
|
|
records = self.db.get_images_by_vector_ids(indices.tolist())
|
|
|
|
# Build results
|
|
results = []
|
|
for score, vector_id in zip(scores, indices):
|
|
if vector_id in records:
|
|
record = records[vector_id]
|
|
# Check if file still exists
|
|
if Path(record.file_path).exists():
|
|
results.append(SearchResult(
|
|
file_path=record.file_path,
|
|
thumbnail_path=record.thumbnail_path,
|
|
score=float(score),
|
|
vector_id=int(vector_id),
|
|
media_type=getattr(record, 'media_type', 'image'),
|
|
))
|
|
|
|
return SearchResults(results=results, query_type="vector", query_info="")
|
|
|
|
def search_by_text(
|
|
self,
|
|
text: str,
|
|
model: EmbeddingModelWrapper,
|
|
top_k: int = 50,
|
|
instruction: Optional[str] = None,
|
|
) -> SearchResults:
|
|
"""
|
|
Search for images by text query.
|
|
|
|
Args:
|
|
text: Query text
|
|
model: Embedding model
|
|
top_k: Number of results
|
|
instruction: Optional instruction for the model
|
|
|
|
Returns:
|
|
SearchResults object
|
|
"""
|
|
# Encode query
|
|
query_vector = model.encode_text(text, instruction=instruction)
|
|
|
|
# Search
|
|
results = self.search(query_vector, top_k)
|
|
results.query_type = "text"
|
|
results.query_info = text
|
|
|
|
return results
|
|
|
|
def search_by_image(
|
|
self,
|
|
image_path: str,
|
|
model: EmbeddingModelWrapper,
|
|
top_k: int = 50,
|
|
instruction: Optional[str] = None,
|
|
) -> SearchResults:
|
|
"""
|
|
Search for similar images by image query.
|
|
|
|
Args:
|
|
image_path: Path to query image
|
|
model: Embedding model
|
|
top_k: Number of results
|
|
instruction: Optional instruction for the model
|
|
|
|
Returns:
|
|
SearchResults object
|
|
"""
|
|
# Encode query
|
|
query_vector = model.encode_image(image_path, instruction=instruction)
|
|
|
|
# Search
|
|
results = self.search(query_vector, top_k)
|
|
results.query_type = "image"
|
|
results.query_info = str(image_path)
|
|
|
|
return results
|
|
|
|
def search_by_video(
|
|
self,
|
|
video_path: str,
|
|
model: EmbeddingModelWrapper,
|
|
top_k: int = 50,
|
|
instruction: Optional[str] = None,
|
|
max_frames: int = 32,
|
|
) -> SearchResults:
|
|
"""
|
|
Search for similar content using a video query.
|
|
|
|
Args:
|
|
video_path: Path to query video
|
|
model: Embedding model
|
|
top_k: Number of results
|
|
instruction: Optional instruction for the model
|
|
max_frames: Maximum frames to extract from video
|
|
|
|
Returns:
|
|
SearchResults object
|
|
"""
|
|
from .config import VIDEO_DEFAULT_FPS
|
|
|
|
# Extract frames from video
|
|
frames = extract_frames(
|
|
video_path,
|
|
max_frames=max_frames,
|
|
target_fps=VIDEO_DEFAULT_FPS,
|
|
uniform_sampling=True,
|
|
)
|
|
|
|
if not frames:
|
|
return SearchResults(
|
|
results=[],
|
|
query_type="video",
|
|
query_info=str(video_path),
|
|
)
|
|
|
|
# Encode query video frames
|
|
query_vector = model.encode_video_frames(frames, instruction=instruction)
|
|
|
|
# Search
|
|
results = self.search(query_vector, top_k)
|
|
results.query_type = "video"
|
|
results.query_info = str(video_path)
|
|
|
|
return results
|
|
|
|
def search_by_document(
|
|
self,
|
|
pdf_path: str,
|
|
model: EmbeddingModelWrapper,
|
|
top_k: int = DEFAULT_TOP_K,
|
|
instruction: str = "",
|
|
page_number: int = 1,
|
|
) -> SearchResults:
|
|
"""
|
|
Search for similar content using a PDF page as query.
|
|
|
|
Args:
|
|
pdf_path: Path to PDF file
|
|
model: Embedding model
|
|
top_k: Number of results
|
|
instruction: Optional instruction for the model
|
|
page_number: Which page to use as query (1-indexed)
|
|
|
|
Returns:
|
|
SearchResults object
|
|
"""
|
|
from .pdf_utils import extract_page_image
|
|
|
|
# Extract the specified page as image
|
|
page_image = extract_page_image(pdf_path, page_number)
|
|
|
|
if page_image is None:
|
|
return SearchResults(
|
|
results=[],
|
|
query_type="document",
|
|
query_info=f"{pdf_path}#page={page_number}",
|
|
)
|
|
|
|
# Encode query page
|
|
default_instruction = instruction or "Represent this document page for retrieval."
|
|
query_vector = model.encode(
|
|
[{"image": page_image}],
|
|
instruction=default_instruction,
|
|
)
|
|
|
|
# Search
|
|
results = self.search(query_vector.reshape(-1), top_k)
|
|
results.query_type = "document"
|
|
results.query_info = f"{pdf_path}#page={page_number}"
|
|
|
|
return results
|
|
|
|
def search_by_text_with_exclusion(
|
|
self,
|
|
positive_query: str,
|
|
negative_terms: List[str],
|
|
model: EmbeddingModelWrapper,
|
|
top_k: int = DEFAULT_TOP_K,
|
|
exclusion_threshold: float = 0.3,
|
|
instruction: Optional[str] = None,
|
|
) -> SearchResults:
|
|
"""
|
|
Search for images by text query while excluding results similar to negative terms.
|
|
|
|
This uses a two-pass approach:
|
|
1. First pass: Get top_k*2 results for positive query
|
|
2. Second pass: Filter out results similar to any negative term
|
|
|
|
Args:
|
|
positive_query: Main search query
|
|
negative_terms: List of terms to exclude (e.g., ["beach", "sand"])
|
|
model: Embedding model
|
|
top_k: Number of final results
|
|
exclusion_threshold: Similarity threshold for exclusion (0-1).
|
|
Results with similarity >= threshold to negative terms are filtered.
|
|
instruction: Optional instruction for the model
|
|
|
|
Returns:
|
|
SearchResults object
|
|
"""
|
|
# First pass: get more candidates than needed
|
|
candidate_count = min(top_k * 3, 500) # Get 3x candidates for filtering
|
|
candidates = self.search_by_text(
|
|
text=positive_query,
|
|
model=model,
|
|
top_k=candidate_count,
|
|
instruction=instruction,
|
|
)
|
|
|
|
if not candidates.results or not negative_terms:
|
|
# No negatives, just return top_k
|
|
return SearchResults(
|
|
results=candidates.results[:top_k],
|
|
query_type="text_with_exclusion",
|
|
query_info=f"{positive_query} NOT ({', '.join(negative_terms)})",
|
|
)
|
|
|
|
# Encode all negative terms
|
|
negative_embeddings = []
|
|
for term in negative_terms:
|
|
neg_vec = model.encode_text(term, instruction=instruction)
|
|
negative_embeddings.append(neg_vec)
|
|
|
|
# Get embeddings for candidates from FAISS index
|
|
filtered_results = []
|
|
for result in candidates.results:
|
|
with self._lock:
|
|
try:
|
|
candidate_vec = self._faiss_index.reconstruct(result.vector_id)
|
|
except RuntimeError:
|
|
# Can't reconstruct, skip this result
|
|
continue
|
|
|
|
# Check similarity to all negative terms
|
|
should_exclude = False
|
|
for neg_vec in negative_embeddings:
|
|
# Compute cosine similarity (vectors are already normalized)
|
|
similarity = float(np.dot(candidate_vec, neg_vec.flatten()))
|
|
if similarity >= exclusion_threshold:
|
|
should_exclude = True
|
|
break
|
|
|
|
if not should_exclude:
|
|
filtered_results.append(result)
|
|
|
|
if len(filtered_results) >= top_k:
|
|
break
|
|
|
|
return SearchResults(
|
|
results=filtered_results[:top_k],
|
|
query_type="text_with_exclusion",
|
|
query_info=f"{positive_query} NOT ({', '.join(negative_terms)})",
|
|
)
|
|
|
|
@staticmethod
|
|
def merge_search_results(
|
|
results_list: List[SearchResults],
|
|
top_k: int = DEFAULT_TOP_K,
|
|
normalize_scores: bool = True,
|
|
) -> SearchResults:
|
|
"""
|
|
Merge results from multiple indexes, deduplicating by file path.
|
|
|
|
Args:
|
|
results_list: List of SearchResults from different indexes
|
|
top_k: Maximum results to return
|
|
normalize_scores: Whether to normalize scores to 0-1 range per index
|
|
|
|
Returns:
|
|
Merged SearchResults
|
|
"""
|
|
if not results_list:
|
|
return SearchResults(results=[], query_type="multi_index", query_info="")
|
|
|
|
# Collect all results with normalization
|
|
all_results: Dict[str, SearchResult] = {}
|
|
query_infos = []
|
|
|
|
for sr in results_list:
|
|
if sr.query_info:
|
|
query_infos.append(sr.query_info)
|
|
|
|
if not sr.results:
|
|
continue
|
|
|
|
# Normalize scores if requested
|
|
if normalize_scores and sr.results:
|
|
max_score = max(r.score for r in sr.results)
|
|
min_score = min(r.score for r in sr.results)
|
|
score_range = max_score - min_score if max_score > min_score else 1.0
|
|
|
|
for result in sr.results:
|
|
# Normalize score to 0-1
|
|
if normalize_scores and score_range > 0:
|
|
normalized_score = (result.score - min_score) / score_range
|
|
else:
|
|
normalized_score = result.score
|
|
|
|
# Deduplicate by file path, keeping highest score
|
|
if result.file_path in all_results:
|
|
if normalized_score > all_results[result.file_path].score:
|
|
all_results[result.file_path] = SearchResult(
|
|
file_path=result.file_path,
|
|
thumbnail_path=result.thumbnail_path,
|
|
score=normalized_score,
|
|
vector_id=result.vector_id,
|
|
)
|
|
else:
|
|
all_results[result.file_path] = SearchResult(
|
|
file_path=result.file_path,
|
|
thumbnail_path=result.thumbnail_path,
|
|
score=normalized_score,
|
|
vector_id=result.vector_id,
|
|
)
|
|
|
|
# Sort by score descending and take top_k
|
|
merged = sorted(all_results.values(), key=lambda x: x.score, reverse=True)[:top_k]
|
|
|
|
return SearchResults(
|
|
results=merged,
|
|
query_type="multi_index",
|
|
query_info=" + ".join(query_infos) if query_infos else "multi-index search",
|
|
)
|
|
|
|
def index_folder(
|
|
self,
|
|
folder_path: str,
|
|
model: EmbeddingModelWrapper,
|
|
recursive: bool = True,
|
|
batch_size: int = DEFAULT_BATCH_SIZE,
|
|
thumbnail_size: int = DEFAULT_THUMBNAIL_SIZE,
|
|
include_videos: bool = True,
|
|
include_documents: bool = True,
|
|
progress_callback: Optional[Callable[[int, int, str], None]] = None,
|
|
) -> Dict[str, int]:
|
|
"""
|
|
Index all images, videos, and documents in a folder.
|
|
|
|
Args:
|
|
folder_path: Path to folder
|
|
model: Embedding model
|
|
recursive: Search subdirectories
|
|
batch_size: Images per batch
|
|
thumbnail_size: Thumbnail size
|
|
include_videos: If True, also index video files
|
|
include_documents: If True, also index PDF documents
|
|
progress_callback: Callback(current, total, status)
|
|
|
|
Returns:
|
|
Dict with stats: {"added": n, "skipped": n, "failed": n, "videos": n, "documents": n, "pages": n}
|
|
"""
|
|
folder_path = Path(folder_path)
|
|
|
|
# Collect media files (images and optionally videos)
|
|
all_media = collect_media_from_folder(folder_path, recursive, include_videos)
|
|
|
|
# Collect documents if requested
|
|
all_documents = []
|
|
if include_documents and check_pymupdf():
|
|
all_documents_raw = collect_documents_from_folder(folder_path, recursive)
|
|
# Filter out already-indexed documents
|
|
for doc_path in all_documents_raw:
|
|
mtime, size = get_file_info(doc_path)
|
|
if self.db.image_needs_update(str(doc_path), mtime, size):
|
|
all_documents.append(doc_path)
|
|
docs_skipped = len(all_documents_raw) - len(all_documents)
|
|
if docs_skipped > 0:
|
|
skipped += docs_skipped
|
|
|
|
total = len(all_media) + len(all_documents)
|
|
|
|
if progress_callback:
|
|
progress_callback(0, total, f"Found {len(all_media)} media, {len(all_documents)} documents")
|
|
|
|
# Separate images and videos
|
|
images_to_process = []
|
|
videos_to_process = []
|
|
skipped = 0
|
|
|
|
for media_path in all_media:
|
|
mtime, size = get_file_info(media_path)
|
|
if self.db.image_needs_update(str(media_path), mtime, size):
|
|
if is_video_file(media_path):
|
|
videos_to_process.append(media_path)
|
|
else:
|
|
images_to_process.append(media_path)
|
|
else:
|
|
skipped += 1
|
|
|
|
if progress_callback:
|
|
progress_callback(skipped, total, f"Processing {len(images_to_process)} images, {len(videos_to_process)} videos")
|
|
|
|
# Track folder in database
|
|
self.db.add_folder(str(folder_path), recursive)
|
|
|
|
# Process images in batches
|
|
added = 0
|
|
failed = 0
|
|
videos_added = 0
|
|
|
|
import time as time_module
|
|
total_thumb_time = 0
|
|
total_encode_time = 0
|
|
total_db_time = 0
|
|
batch_count = 0
|
|
indexing_start = time_module.time()
|
|
|
|
print(f"[SemanticSearch] Starting indexing: {len(images_to_process)} images, {len(videos_to_process)} videos, {len(all_documents)} documents")
|
|
print(f"[SemanticSearch] Batch size: {batch_size}, Thumbnail size: {thumbnail_size}")
|
|
|
|
for batch_start in range(0, len(images_to_process), batch_size):
|
|
batch_end = min(batch_start + batch_size, len(images_to_process))
|
|
batch_paths = images_to_process[batch_start:batch_end]
|
|
|
|
# Generate thumbnails
|
|
t0 = time_module.time()
|
|
thumbnails = []
|
|
valid_paths = []
|
|
valid_infos = []
|
|
|
|
for img_path in batch_paths:
|
|
thumb = self.thumbnails.get_or_create_thumbnail(img_path)
|
|
if thumb:
|
|
thumbnails.append(thumb)
|
|
valid_paths.append(img_path)
|
|
valid_infos.append(get_file_info(img_path))
|
|
else:
|
|
failed += 1
|
|
|
|
if not thumbnails:
|
|
continue
|
|
|
|
thumb_time = time_module.time() - t0
|
|
total_thumb_time += thumb_time
|
|
|
|
# Compute embeddings from thumbnails
|
|
try:
|
|
t1 = time_module.time()
|
|
inputs = [{"image": str(t)} for t in thumbnails]
|
|
embeddings = model.encode(
|
|
inputs,
|
|
instruction="Represent this image for retrieval.",
|
|
)
|
|
encode_time = time_module.time() - t1
|
|
total_encode_time += encode_time
|
|
|
|
# Add to index
|
|
t2 = time_module.time()
|
|
self.add_vectors(
|
|
embeddings,
|
|
[str(p) for p in valid_paths],
|
|
[str(t) for t in thumbnails],
|
|
valid_infos,
|
|
)
|
|
db_time = time_module.time() - t2
|
|
total_db_time += db_time
|
|
|
|
added += len(valid_paths)
|
|
batch_count += 1
|
|
|
|
# Log timing every 10 batches
|
|
if batch_count % 10 == 0:
|
|
avg_encode = total_encode_time / batch_count
|
|
avg_thumb = total_thumb_time / batch_count
|
|
avg_db = total_db_time / batch_count
|
|
imgs_per_sec = (batch_count * batch_size) / (total_encode_time + total_thumb_time + total_db_time)
|
|
print(f"[SemanticSearch] Batch {batch_count}: Thumbnail={avg_thumb:.2f}s, Encode={avg_encode:.2f}s, DB={avg_db:.2f}s, Throughput={imgs_per_sec:.2f} img/s")
|
|
|
|
except Exception as e:
|
|
print(f"[SemanticSearch] Batch encoding failed: {e}")
|
|
failed += len(valid_paths)
|
|
|
|
# Progress update
|
|
processed = skipped + added + failed
|
|
if progress_callback:
|
|
progress_callback(processed, total, f"Indexed {added} images")
|
|
|
|
# Checkpoint save
|
|
if (batch_start // batch_size) % CHECKPOINT_INTERVAL == 0:
|
|
self.save()
|
|
|
|
# Image phase summary
|
|
if batch_count > 0:
|
|
images_elapsed = time_module.time() - indexing_start
|
|
print(f"[SemanticSearch] === IMAGES COMPLETE ===")
|
|
print(f"[SemanticSearch] Indexed: {added} images in {images_elapsed:.1f}s ({added/images_elapsed:.2f} img/s)")
|
|
print(f"[SemanticSearch] Avg per batch: Thumb={total_thumb_time/batch_count:.2f}s, Encode={total_encode_time/batch_count:.2f}s, DB={total_db_time/batch_count:.2f}s")
|
|
|
|
# Process videos one at a time (they're more complex)
|
|
video_start = time_module.time()
|
|
for i, video_path in enumerate(videos_to_process):
|
|
try:
|
|
video_added = self._index_video(video_path, model, thumbnail_size)
|
|
if video_added:
|
|
videos_added += 1
|
|
added += 1
|
|
else:
|
|
failed += 1
|
|
except Exception as e:
|
|
print(f"[SemanticSearch] Video encoding failed for {video_path}: {e}")
|
|
failed += 1
|
|
|
|
# Progress update
|
|
processed = skipped + added + failed
|
|
if progress_callback:
|
|
progress_callback(processed, total, f"Indexed {added} media ({videos_added} videos)")
|
|
|
|
# Checkpoint save periodically
|
|
if i % 10 == 0:
|
|
self.save()
|
|
|
|
# Video phase summary
|
|
if videos_added > 0:
|
|
video_elapsed = time_module.time() - video_start
|
|
print(f"[SemanticSearch] === VIDEOS COMPLETE ===")
|
|
print(f"[SemanticSearch] Indexed: {videos_added} videos in {video_elapsed:.1f}s ({videos_added/video_elapsed:.2f} vid/s)")
|
|
|
|
# Process documents (PDFs) - each page becomes a separate entry
|
|
documents_added = 0
|
|
pages_added = 0
|
|
doc_start = time_module.time()
|
|
|
|
for i, doc_path in enumerate(all_documents):
|
|
try:
|
|
doc_pages = self._index_pdf(doc_path, model, thumbnail_size)
|
|
if doc_pages > 0:
|
|
documents_added += 1
|
|
pages_added += doc_pages
|
|
added += doc_pages
|
|
# Log progress every 10 documents
|
|
if (i + 1) % 10 == 0:
|
|
doc_elapsed = time_module.time() - doc_start
|
|
print(f"[SemanticSearch] Documents: {documents_added} docs, {pages_added} pages in {doc_elapsed:.1f}s")
|
|
else:
|
|
failed += 1
|
|
except Exception as e:
|
|
print(f"[SemanticSearch] PDF encoding failed for {doc_path}: {e}")
|
|
failed += 1
|
|
|
|
# Progress update
|
|
processed_files = len(all_media) + i + 1
|
|
if progress_callback:
|
|
progress_callback(processed_files, total, f"Indexed {added} items ({documents_added} docs, {pages_added} pages)")
|
|
|
|
# Checkpoint save periodically
|
|
if i % 5 == 0:
|
|
self.save()
|
|
|
|
# Document phase summary
|
|
if documents_added > 0:
|
|
doc_elapsed = time_module.time() - doc_start
|
|
print(f"[SemanticSearch] === DOCUMENTS COMPLETE ===")
|
|
print(f"[SemanticSearch] Indexed: {documents_added} docs ({pages_added} pages) in {doc_elapsed:.1f}s ({pages_added/doc_elapsed:.2f} pages/s)")
|
|
|
|
# Final save
|
|
self.save()
|
|
|
|
# Total summary
|
|
total_elapsed = time_module.time() - indexing_start
|
|
print(f"[SemanticSearch] === INDEXING COMPLETE ===")
|
|
print(f"[SemanticSearch] Total: {added} items indexed, {skipped} skipped, {failed} failed")
|
|
print(f"[SemanticSearch] Time: {total_elapsed:.1f}s ({total_elapsed/60:.1f} min)")
|
|
if added > 0:
|
|
print(f"[SemanticSearch] Average: {total_elapsed/added:.2f}s per item, {added/total_elapsed:.2f} items/s")
|
|
|
|
# Update config with model name
|
|
self._config["model_used"] = model.model_name
|
|
self._config["last_indexed"] = datetime.now().isoformat()
|
|
self._save_config()
|
|
|
|
return {
|
|
"added": added,
|
|
"skipped": skipped,
|
|
"failed": failed,
|
|
"videos": videos_added,
|
|
"documents": documents_added,
|
|
"pages": pages_added,
|
|
}
|
|
|
|
def _index_video(
|
|
self,
|
|
video_path: Path,
|
|
model: EmbeddingModelWrapper,
|
|
thumbnail_size: int = DEFAULT_THUMBNAIL_SIZE,
|
|
) -> bool:
|
|
"""
|
|
Index a single video file.
|
|
|
|
Args:
|
|
video_path: Path to video file
|
|
model: Embedding model
|
|
thumbnail_size: Size for thumbnail
|
|
|
|
Returns:
|
|
True if successfully indexed, False otherwise
|
|
"""
|
|
from .config import VIDEO_DEFAULT_FPS, VIDEO_MAX_FRAMES, VIDEO_MIN_FRAMES
|
|
|
|
video_path = Path(video_path)
|
|
|
|
# Get video info
|
|
video_info = get_video_info(video_path)
|
|
if video_info is None:
|
|
print(f"[SemanticSearch] Failed to get video info: {video_path}")
|
|
return False
|
|
|
|
# Extract frames using FPS-based sampling (2 fps default)
|
|
frames = extract_frames(
|
|
video_path,
|
|
max_frames=VIDEO_MAX_FRAMES,
|
|
target_fps=VIDEO_DEFAULT_FPS,
|
|
uniform_sampling=False, # Use FPS-based sampling for better temporal coverage
|
|
)
|
|
|
|
if not frames or len(frames) < VIDEO_MIN_FRAMES:
|
|
print(f"[SemanticSearch] Failed to extract frames from: {video_path}")
|
|
return False
|
|
|
|
# Create thumbnail
|
|
thumb_path = self.thumbnails.get_or_create_thumbnail(video_path)
|
|
if thumb_path is None:
|
|
print(f"[SemanticSearch] Failed to create thumbnail for: {video_path}")
|
|
return False
|
|
|
|
# Generate embedding from frames (single embedding for the whole video)
|
|
# Uses batched processing and lower resolution to avoid OOM
|
|
try:
|
|
embedding = model.encode_video_frames(
|
|
frames,
|
|
instruction="Represent this video for retrieval.",
|
|
max_frames_per_batch=8, # Process 8 frames at a time
|
|
frame_max_pixels=VIDEO_FRAME_MAX_PIXELS, # 512x512 default
|
|
)
|
|
embedding = embedding.reshape(1, -1) # Ensure 2D array
|
|
|
|
# Add to index with video metadata
|
|
mtime, size = get_file_info(video_path)
|
|
self.add_video_vector(
|
|
embedding,
|
|
str(video_path),
|
|
str(thumb_path),
|
|
(mtime, size),
|
|
frame_count=len(frames),
|
|
duration_seconds=video_info.duration_seconds,
|
|
)
|
|
|
|
# Clear CUDA cache after video processing to free memory
|
|
import torch
|
|
if torch.cuda.is_available():
|
|
torch.cuda.empty_cache()
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"[SemanticSearch] Failed to encode video {video_path}: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
# Try to clear cache even on failure
|
|
import torch
|
|
if torch.cuda.is_available():
|
|
torch.cuda.empty_cache()
|
|
return False
|
|
|
|
def add_video_vector(
|
|
self,
|
|
embedding: np.ndarray,
|
|
file_path: str,
|
|
thumbnail_path: str,
|
|
file_info: Tuple[float, int],
|
|
frame_count: int,
|
|
duration_seconds: float,
|
|
):
|
|
"""
|
|
Add a single video embedding to the index.
|
|
|
|
Args:
|
|
embedding: Video embedding (1, dim)
|
|
file_path: Path to video file
|
|
thumbnail_path: Path to thumbnail
|
|
file_info: (mtime, size) tuple
|
|
frame_count: Number of frames extracted
|
|
duration_seconds: Video duration
|
|
"""
|
|
# Use add_vectors with video metadata
|
|
self.add_vectors(
|
|
vectors=embedding,
|
|
file_paths=[file_path],
|
|
thumbnail_paths=[thumbnail_path],
|
|
file_infos=[file_info],
|
|
media_types=["video"],
|
|
frame_counts=[frame_count],
|
|
durations=[duration_seconds],
|
|
)
|
|
|
|
def _index_pdf(
|
|
self,
|
|
pdf_path: Path,
|
|
model: EmbeddingModelWrapper,
|
|
thumbnail_size: int = DEFAULT_THUMBNAIL_SIZE,
|
|
) -> int:
|
|
"""
|
|
Index a single PDF file (all pages).
|
|
|
|
Args:
|
|
pdf_path: Path to PDF file
|
|
model: Embedding model
|
|
thumbnail_size: Size for thumbnail
|
|
|
|
Returns:
|
|
Number of pages successfully indexed (0 if failed)
|
|
"""
|
|
from .config import PDF_DPI, PDF_MAX_PAGES
|
|
|
|
pdf_path = Path(pdf_path)
|
|
|
|
# Get PDF info
|
|
pdf_info = get_pdf_info(str(pdf_path))
|
|
if pdf_info is None:
|
|
print(f"[SemanticSearch] Failed to get PDF info: {pdf_path}")
|
|
return 0
|
|
|
|
# Extract all pages as images
|
|
pages = extract_all_pages(
|
|
str(pdf_path),
|
|
max_pages=PDF_MAX_PAGES,
|
|
dpi=PDF_DPI,
|
|
)
|
|
|
|
if not pages:
|
|
print(f"[SemanticSearch] Failed to extract pages from: {pdf_path}")
|
|
return 0
|
|
|
|
mtime, size = get_file_info(pdf_path)
|
|
pages_indexed = 0
|
|
|
|
# Index each page separately
|
|
for page in pages:
|
|
try:
|
|
# Create a virtual path for this page
|
|
page_path = get_pdf_page_path(str(pdf_path), page.page_number)
|
|
|
|
# Check if already indexed
|
|
if not self.db.image_needs_update(page_path, mtime, size):
|
|
pages_indexed += 1 # Already indexed, count as success
|
|
continue
|
|
|
|
# Create thumbnail for this page
|
|
thumb_path = self.thumbnails.create_thumbnail(page_path, force=True, page_number=page.page_number)
|
|
if thumb_path is None:
|
|
# Try using the page image directly
|
|
page.image.thumbnail((thumbnail_size, thumbnail_size))
|
|
thumb_path = self.thumbnails.thumbnails_path / f"{pdf_path.stem}_p{page.page_number}.jpg"
|
|
page.image.save(thumb_path, "JPEG", quality=85)
|
|
|
|
# Generate embedding from page image
|
|
embedding = model.encode(
|
|
[{"image": page.image}],
|
|
instruction="Represent this document page for retrieval.",
|
|
)
|
|
|
|
# Add to index
|
|
self.add_document_vector(
|
|
embedding=embedding,
|
|
page_path=page_path,
|
|
thumbnail_path=str(thumb_path),
|
|
file_info=(mtime, size),
|
|
page_number=page.page_number,
|
|
parent_document=str(pdf_path),
|
|
total_pages=pdf_info.page_count,
|
|
)
|
|
|
|
pages_indexed += 1
|
|
|
|
except Exception as e:
|
|
print(f"[SemanticSearch] Failed to index page {page.page_number} of {pdf_path}: {e}")
|
|
continue
|
|
|
|
return pages_indexed
|
|
|
|
def add_document_vector(
|
|
self,
|
|
embedding: np.ndarray,
|
|
page_path: str,
|
|
thumbnail_path: str,
|
|
file_info: Tuple[float, int],
|
|
page_number: int,
|
|
parent_document: str,
|
|
total_pages: int = None,
|
|
):
|
|
"""
|
|
Add a single document page embedding to the index.
|
|
|
|
Args:
|
|
embedding: Page embedding (1, dim)
|
|
page_path: Virtual path (e.g., "file.pdf#page=3")
|
|
thumbnail_path: Path to thumbnail
|
|
file_info: (mtime, size) tuple
|
|
page_number: Page number (1-indexed)
|
|
parent_document: Path to parent PDF
|
|
total_pages: Total pages in document (optional, for metadata)
|
|
"""
|
|
# Ensure 2D array
|
|
if embedding.ndim == 1:
|
|
embedding = embedding.reshape(1, -1)
|
|
|
|
with self._lock:
|
|
# Add to FAISS
|
|
self._faiss_index.add(embedding.astype(np.float32))
|
|
vector_id = self._faiss_index.ntotal - 1
|
|
|
|
# Add to database with document metadata
|
|
mtime, size = file_info
|
|
file_hash = f"{size}_{mtime}"
|
|
|
|
self.db.add_image(
|
|
file_path=page_path,
|
|
file_hash=file_hash,
|
|
file_mtime=mtime,
|
|
file_size=size,
|
|
thumbnail_path=thumbnail_path,
|
|
vector_id=vector_id,
|
|
media_type="document",
|
|
page_number=page_number,
|
|
parent_document=parent_document,
|
|
)
|
|
|
|
def remove_folder(self, folder_path: str) -> int:
|
|
"""
|
|
Remove a folder and all its images from the index.
|
|
|
|
Note: This marks records for removal but doesn't compact the FAISS index.
|
|
Call rebuild() to reclaim space.
|
|
|
|
Args:
|
|
folder_path: Path to folder
|
|
|
|
Returns:
|
|
Number of images removed
|
|
"""
|
|
# Remove from database (and get count)
|
|
count = self.db.remove_images_in_folder(folder_path)
|
|
|
|
# Remove folder tracking
|
|
self.db.remove_folder(folder_path)
|
|
|
|
return count
|
|
|
|
def validate(self, auto_clean: bool = False) -> Dict[str, Any]:
|
|
"""
|
|
Validate the index and find stale entries.
|
|
|
|
Args:
|
|
auto_clean: If True, remove stale entries
|
|
|
|
Returns:
|
|
Dict with validation results
|
|
"""
|
|
stale = self.db.get_stale_images()
|
|
stale_count = len(stale)
|
|
|
|
if auto_clean and stale:
|
|
paths = [s.file_path for s in stale]
|
|
self.db.remove_images_batch(paths)
|
|
|
|
# Also clean up orphaned thumbnails
|
|
valid_paths = {r.file_path for r in self.db.get_all_images()}
|
|
orphaned = self.thumbnails.cleanup_orphaned_thumbnails(valid_paths)
|
|
else:
|
|
orphaned = 0
|
|
|
|
return {
|
|
"stale_count": stale_count,
|
|
"stale_paths": [s.file_path for s in stale] if not auto_clean else [],
|
|
"cleaned": auto_clean,
|
|
"orphaned_thumbnails_removed": orphaned,
|
|
}
|
|
|
|
def get_stats(self) -> Dict[str, Any]:
|
|
"""Get index statistics"""
|
|
db_stats = self.db.get_stats()
|
|
|
|
# Get FAISS index stats
|
|
faiss_stats = IndexFactory.get_index_stats(self._faiss_index)
|
|
|
|
# Compaction info
|
|
needs_compact, deleted_count, wasted_pct = self.needs_compaction()
|
|
|
|
return {
|
|
**db_stats,
|
|
"vector_count": self._faiss_index.ntotal,
|
|
"embedding_dim": self.embedding_dim,
|
|
"index_type": faiss_stats["index_type"],
|
|
"is_trained": faiss_stats["is_trained"],
|
|
"faiss_path": str(self.faiss_path),
|
|
"faiss_size_mb": round(self.faiss_path.stat().st_size / (1024 * 1024), 2) if self.faiss_path.exists() else 0,
|
|
"thumbnail_count": self.thumbnails.get_thumbnail_count(),
|
|
"thumbnail_cache_mb": self.thumbnails.get_cache_size_mb(),
|
|
"model_used": self._config.get("model_used"),
|
|
"created_at": self._config.get("created_at"),
|
|
"last_indexed": self._config.get("last_indexed"),
|
|
"index_params": faiss_stats,
|
|
# Compaction stats
|
|
"deleted_vectors": deleted_count,
|
|
"wasted_percentage": round(wasted_pct, 1),
|
|
"needs_compaction": needs_compact,
|
|
}
|
|
|
|
def rebuild(
|
|
self,
|
|
new_index_type: Optional[str] = None,
|
|
progress_callback: Optional[Callable[[int, int, str], None]] = None,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Rebuild the FAISS index, optionally changing the index type.
|
|
|
|
This also compacts the index by removing gaps from deleted entries.
|
|
|
|
Args:
|
|
new_index_type: New index type ("flat", "ivf_flat", "hnsw", "ivf_pq")
|
|
If None, keeps the current type
|
|
progress_callback: Optional callback(current, total, status)
|
|
|
|
Returns:
|
|
Dict with rebuild stats
|
|
"""
|
|
with self._lock:
|
|
old_type = IndexFactory.detect_index_type(self._faiss_index)
|
|
old_count = self._faiss_index.ntotal
|
|
|
|
# Determine new config
|
|
if new_index_type:
|
|
new_type = IndexType.from_string(new_index_type)
|
|
else:
|
|
new_type = old_type
|
|
|
|
new_config = IndexConfig(
|
|
index_type=new_type,
|
|
dimension=self.embedding_dim,
|
|
nlist=self._config.get("index_params", {}).get("nlist", 100),
|
|
nprobe=self._config.get("index_params", {}).get("nprobe", 10),
|
|
hnsw_m=self._config.get("index_params", {}).get("hnsw_m", 32),
|
|
hnsw_ef_search=self._config.get("index_params", {}).get("hnsw_ef_search", 64),
|
|
)
|
|
|
|
# Rebuild
|
|
new_index, success = IndexFactory.rebuild_index(
|
|
self._faiss_index,
|
|
new_config,
|
|
progress_callback,
|
|
)
|
|
|
|
if success:
|
|
self._faiss_index = new_index
|
|
self._index_config = new_config
|
|
self._config["index_type"] = new_type.value
|
|
self._config["index_params"] = new_config.to_dict()
|
|
self.save()
|
|
|
|
return {
|
|
"success": True,
|
|
"old_type": old_type.value,
|
|
"new_type": new_type.value,
|
|
"old_count": old_count,
|
|
"new_count": new_index.ntotal,
|
|
}
|
|
else:
|
|
return {
|
|
"success": False,
|
|
"error": "Rebuild failed - could not extract vectors from old index",
|
|
}
|
|
|
|
def compact(
|
|
self,
|
|
new_index_type: Optional[str] = None,
|
|
progress_callback: Optional[Callable[[int, int, str], None]] = None,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Compact the index by removing deleted vectors.
|
|
|
|
This rebuilds the FAISS index keeping only vectors that are still
|
|
referenced in the database, reassigns sequential vector IDs, and
|
|
updates the database mappings.
|
|
|
|
Args:
|
|
new_index_type: Optionally change index type during compaction
|
|
progress_callback: Optional callback(current, total, status)
|
|
|
|
Returns:
|
|
Dict with compaction stats
|
|
"""
|
|
with self._lock:
|
|
old_count = self._faiss_index.ntotal
|
|
deleted_count = self.db.get_deleted_vector_count()
|
|
|
|
if deleted_count == 0:
|
|
return {
|
|
"success": True,
|
|
"compacted": False,
|
|
"message": "No deleted vectors to compact",
|
|
"vector_count": old_count,
|
|
}
|
|
|
|
# Get valid vector IDs from database
|
|
valid_vector_ids = self.db.get_all_vector_ids()
|
|
new_count = len(valid_vector_ids)
|
|
|
|
if progress_callback:
|
|
progress_callback(0, new_count, f"Compacting: {old_count} -> {new_count} vectors...")
|
|
|
|
# Determine new config
|
|
if new_index_type:
|
|
new_type = IndexType.from_string(new_index_type)
|
|
else:
|
|
new_type = IndexFactory.detect_index_type(self._faiss_index)
|
|
|
|
new_config = IndexConfig(
|
|
index_type=new_type,
|
|
dimension=self.embedding_dim,
|
|
nlist=self._config.get("index_params", {}).get("nlist", 100),
|
|
nprobe=self._config.get("index_params", {}).get("nprobe", 10),
|
|
hnsw_m=self._config.get("index_params", {}).get("hnsw_m", 32),
|
|
hnsw_ef_search=self._config.get("index_params", {}).get("hnsw_ef_search", 64),
|
|
)
|
|
|
|
# Compact the FAISS index
|
|
new_index, id_mapping, success = IndexFactory.compact_index(
|
|
self._faiss_index,
|
|
valid_vector_ids,
|
|
new_config,
|
|
progress_callback,
|
|
)
|
|
|
|
if not success:
|
|
return {
|
|
"success": False,
|
|
"error": "Compaction failed - could not extract vectors from index",
|
|
}
|
|
|
|
# Update database with new vector IDs
|
|
if progress_callback:
|
|
progress_callback(new_count, new_count, "Updating database mappings...")
|
|
|
|
self.db.update_vector_ids(id_mapping)
|
|
|
|
# Clear deleted vectors table
|
|
self.db.clear_deleted_vectors()
|
|
|
|
# Update index
|
|
self._faiss_index = new_index
|
|
self._index_config = new_config
|
|
self._config["index_type"] = new_type.value
|
|
self._config["index_params"] = new_config.to_dict()
|
|
self.save()
|
|
|
|
# Vacuum database
|
|
self.db.vacuum()
|
|
|
|
if progress_callback:
|
|
progress_callback(new_count, new_count, "Compaction complete")
|
|
|
|
return {
|
|
"success": True,
|
|
"compacted": True,
|
|
"old_count": old_count,
|
|
"new_count": new_count,
|
|
"removed_count": old_count - new_count,
|
|
"index_type": new_type.value,
|
|
}
|
|
|
|
def needs_compaction(self) -> Tuple[bool, int, float]:
|
|
"""
|
|
Check if compaction is recommended.
|
|
|
|
Returns:
|
|
(needs_compaction, deleted_count, wasted_percentage)
|
|
"""
|
|
deleted_count = self.db.get_deleted_vector_count()
|
|
total_vectors = self._faiss_index.ntotal
|
|
|
|
if total_vectors == 0:
|
|
return False, 0, 0.0
|
|
|
|
wasted_pct = (deleted_count / total_vectors) * 100
|
|
|
|
# Recommend compaction if >10% wasted or >1000 deleted vectors
|
|
needs_compact = wasted_pct > 10.0 or deleted_count > 1000
|
|
|
|
return needs_compact, deleted_count, wasted_pct
|
|
|
|
def get_index_type(self) -> str:
|
|
"""Get the current index type as a string"""
|
|
return IndexFactory.detect_index_type(self._faiss_index).value
|
|
|
|
def close(self):
|
|
"""Close the index and free resources"""
|
|
self.save()
|
|
self.db.close()
|
|
|
|
|
|
# Global index cache
|
|
_index_cache: Dict[str, SemanticIndex] = {}
|
|
_index_lock = threading.Lock()
|
|
|
|
|
|
def get_or_create_index(
|
|
index_name: str,
|
|
embedding_dim: int = 4096,
|
|
index_type: str = "flat",
|
|
) -> SemanticIndex:
|
|
"""
|
|
Get or create a cached index instance.
|
|
|
|
Args:
|
|
index_name: Name of the index
|
|
embedding_dim: Embedding dimension (only used for new indexes)
|
|
index_type: Type of index ("flat", "ivf_flat", "hnsw", "ivf_pq")
|
|
|
|
Returns:
|
|
SemanticIndex instance
|
|
"""
|
|
with _index_lock:
|
|
if index_name not in _index_cache:
|
|
_index_cache[index_name] = SemanticIndex(index_name, embedding_dim, index_type)
|
|
return _index_cache[index_name]
|
|
|
|
|
|
def list_indexes() -> List[str]:
|
|
"""List all available indexes"""
|
|
from .config import INDEXES_PATH
|
|
|
|
indexes = []
|
|
if INDEXES_PATH.exists():
|
|
for item in INDEXES_PATH.iterdir():
|
|
if item.is_dir() and (item / "metadata.db").exists():
|
|
indexes.append(item.name)
|
|
return indexes
|
|
|
|
|
|
def delete_index(index_name: str) -> bool:
|
|
"""
|
|
Delete an index and all its data.
|
|
|
|
Args:
|
|
index_name: Name of the index to delete
|
|
|
|
Returns:
|
|
True if deleted, False if not found
|
|
"""
|
|
import shutil
|
|
|
|
with _index_lock:
|
|
# Remove from cache
|
|
if index_name in _index_cache:
|
|
_index_cache[index_name].close()
|
|
del _index_cache[index_name]
|
|
|
|
# Delete directory
|
|
index_path = get_index_path(index_name)
|
|
if index_path.exists():
|
|
shutil.rmtree(index_path)
|
|
return True
|
|
|
|
return False
|