CRag / rag_system /vector_store.py
quantumbit's picture
Upload folder using huggingface_hub
7704053 verified
Raw
History Blame Contribute Delete
11.4 kB
#faiss index management
import json
import logging
import time
import os
import warnings
from pathlib import Path
from typing import Optional
import faiss
from langchain_core.documents import Document
from langchain_community.vectorstores import FAISS
from .config import get_settings
from .embeddings import (
get_embeddings,
get_embedding_info,
get_default_embedding_mode,
infer_embedding_mode_from_dim,
normalize_embedding_mode,
)
logger = logging.getLogger(__name__)
settings = get_settings()
_stores: dict[str, FAISS] = {}
# Tracks last-used timestamp per collection (epoch seconds) for TTL-based cleanup
_last_used: dict[str, float] = {}
_collection_embeddings: dict[str, str] = {}
_pinned: set[str] = set()
_EMBEDDING_META_FILE = "embedding.json"
def _embedding_meta_path(collection: str) -> Path:
return Path(_index_path(collection)) / _EMBEDDING_META_FILE
def _read_embedding_meta(collection: str) -> dict | None:
meta_path = _embedding_meta_path(collection)
if not meta_path.exists():
return None
try:
return json.loads(meta_path.read_text(encoding="utf-8"))
except Exception:
logger.warning("Failed to read embedding metadata for '%s'", collection)
return None
def _write_embedding_meta(collection: str, info: dict) -> None:
meta_path = _embedding_meta_path(collection)
meta_path.parent.mkdir(parents=True, exist_ok=True)
meta_path.write_text(json.dumps(info, indent=2), encoding="utf-8")
def get_collection_embedding_mode(collection: str) -> Optional[str]:
if collection in _collection_embeddings:
return _collection_embeddings[collection]
meta = _read_embedding_meta(collection)
if meta and isinstance(meta, dict):
mode = meta.get("mode") or meta.get("embedding_mode")
if isinstance(mode, str):
_collection_embeddings[collection] = mode
return mode
return None
def pin_collection(collection: str) -> None:
_pinned.add(collection)
def is_pinned_collection(collection: str) -> bool:
return collection in _pinned
def resolve_embedding_mode_for_collections(
collections: list[str],
requested_mode: Optional[str] = None,
) -> str:
requested = None
if requested_mode and requested_mode != "auto":
requested = normalize_embedding_mode(requested_mode)
modes = []
for coll in collections:
mode = get_collection_embedding_mode(coll)
if mode:
modes.append(mode)
if requested and modes and any(m != requested for m in modes):
logger.warning(
"Embedding mode mismatch (requested=%s, existing=%s). Using existing.",
requested,
sorted(set(modes)),
)
return modes[0]
if requested:
return requested
if modes:
if any(m != modes[0] for m in modes):
logger.warning("Multiple embedding modes across collections: %s", sorted(set(modes)))
return modes[0]
return get_default_embedding_mode()
def _index_path(collection: str) -> str:
return str(Path(settings.faiss_index_path)/ collection)
#load or create
def load_or_create_store(collection: str = "default") -> FAISS:
"""
Load index from disk if it exists, otherwise return an empty placeholder.
Stores are registered globally so the API reuses them without re-loading.
"""
if collection in _stores:
_last_used[collection] = time.time()
return _stores[collection]
path = _index_path(collection)
embedding_mode = resolve_embedding_mode_for_collections([collection])
embeddings = get_embeddings(embedding_mode)
if Path(path).exists():
logger.info(f"Loading FAISS index from {path}")
store = FAISS.load_local(
path,
embeddings,
allow_dangerous_deserialization=True,
)
expected_dim = int(get_embedding_info(embedding_mode)["dimensions"])
if store.index.d != expected_dim:
inferred_mode = infer_embedding_mode_from_dim(store.index.d)
if inferred_mode and inferred_mode != embedding_mode:
embeddings = get_embeddings(inferred_mode)
store = FAISS.load_local(
path,
embeddings,
allow_dangerous_deserialization=True,
)
embedding_mode = inferred_mode
expected_dim = int(get_embedding_info(embedding_mode)["dimensions"])
if store.index.d != expected_dim:
logger.error(
"Embedding dim mismatch for collection '%s': index dim=%s, expected=%s. "
"Re-ingest with force_reindex or delete the collection.",
collection,
store.index.d,
expected_dim,
)
_stores[collection] = None
else:
_stores[collection] = store
_collection_embeddings[collection] = embedding_mode
_write_embedding_meta(collection, get_embedding_info(embedding_mode))
else:
logger.warning(f"No index at {path}. Will create on first Ingest.")
_stores[collection] = None
_last_used[collection] = time.time()
return _stores[collection]
#Ingest
def add_documents(
docs: list[Document],
collection: str = "default",
force_reindex: bool = False,
embedding_mode: Optional[str] = None,
) -> FAISS:
"""
Adding docs to a FAISS collection.
- force_reindex: wipe exiting index and rebuild from scratch
- Persists to disk after every write
"""
existing_mode = get_collection_embedding_mode(collection)
selected_mode = resolve_embedding_mode_for_collections([collection], embedding_mode)
if existing_mode and existing_mode != selected_mode and not force_reindex:
raise ValueError(
f"Embedding mode mismatch for '{collection}': existing={existing_mode}, requested={selected_mode}. "
"Use force_reindex to rebuild."
)
embeddings = get_embeddings(selected_mode)
path = _index_path(collection)
existing = None if force_reindex else _stores.get(collection)
if existing is not None:
logger.info(f"Merging {len(docs)} docs into existing collection '{collection}'")
texts = [d.page_content for d in docs]
metas = [d.metadata for d in docs]
existing.add_texts(texts, metadatas=metas)
store = existing
else:
logger.info(f"Creating a new FAISS index for collection '{collection}' with {len(docs)} docs")
store = FAISS.from_documents(docs, embeddings)
#persist
Path(path).mkdir(parents=True, exist_ok=True)
store.save_local(path)
_stores[collection] = store
_last_used[collection] = time.time()
_collection_embeddings[collection] = selected_mode
_write_embedding_meta(collection, get_embedding_info(selected_mode))
# Prebuild BM25 index on ingest
from .retriever import _bm25_cache, _get_bm25
if collection in _bm25_cache:
del _bm25_cache[collection]
_get_bm25(collection)
logger.info(f"Index Saved at {path}")
return store
#rettrieval helpers
def similarity_search_with_scores(
query=str,
collection: str = "default",
k: int = 20,
) -> list[tuple[Document, float]]:
store = _stores.get(collection)
if store is None:
raise ValueError(f"Collection '{collection}' not loaded. Ingest documents first.")
_last_used[collection] = time.time()
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message=r"Relevance scores must be between 0 and 1, got.*",
category=UserWarning,
)
return store.similarity_search_with_relevance_scores(query, k=k)
def get_store(collection: str = "default") -> Optional[FAISS]:
return _stores.get(collection)
def is_loaded(collection: str = None) -> bool:
if collection is None:
return any(s is not None for s in _stores.values())
return _stores.get(collection) is not None
def list_collections() -> list[str]:
"""Return all collection names that have a persisted index on disk or are loaded in memory."""
base = Path(settings.faiss_index_path)
on_disk = [d.name for d in base.iterdir() if d.is_dir()] if base.exists() else []
in_memory = [name for name, store in _stores.items() if store is not None]
return sorted(set(on_disk + in_memory))
def get_collection_stats(collection: str) -> dict:
"""Return chunk count, size-on-disk, and load status for a collection."""
store = load_or_create_store(collection)
path = _index_path(collection)
embedding_mode = get_collection_embedding_mode(collection)
embedding_info = get_embedding_info(embedding_mode) if embedding_mode else None
chunk_count = 0
if store is not None and hasattr(store, "index"):
chunk_count = store.index.ntotal
size_mb = 0.0
p = Path(path)
if p.exists():
size_mb = round(
sum(f.stat().st_size for f in p.rglob("*") if f.is_file()) / (1024 * 1024),
3,
)
return {
"name": collection,
"chunk_count": chunk_count,
"size_mb": size_mb,
"loaded": store is not None,
"index_path": path,
"embedding_mode": embedding_mode,
"embedding_dimensions": embedding_info["dimensions"] if embedding_info else None,
"embedding_provider": embedding_info["provider"] if embedding_info else None,
}
def cleanup_stale_collections(ttl_seconds: int = 1800) -> list[str]:
"""
Delete all collections that have not been accessed within ttl_seconds.
Called periodically by the API to reclaim memory and disk from idle sessions.
Returns the list of collection names that were removed.
"""
cutoff = time.time() - ttl_seconds
stale = [
name for name, ts in list(_last_used.items())
if ts < cutoff and name not in _pinned
]
for name in stale:
logger.info(f"Cleaning up stale collection '{name}' (idle > {ttl_seconds}s)")
delete_collection(name)
return stale
def delete_collection(collection: str) -> bool:
"""Remove a collection from memory and delete its index directory from disk."""
import shutil
path = _index_path(collection)
if collection in _stores:
del _stores[collection]
if collection in _collection_embeddings:
del _collection_embeddings[collection]
if collection in _pinned:
_pinned.discard(collection)
# Local import to avoid circular dependency with retriever
from .retriever import _bm25_cache
if collection in _bm25_cache:
del _bm25_cache[collection]
p = Path(path)
if p.exists():
shutil.rmtree(path)
return True
return False
def get_session_collections(session_id: str) -> list[str]:
"""Return all per-doc sub-collections for this session (format: {session_id}__{docname})."""
prefix = f"{session_id}__"
found: set[str] = set()
for name, store in _stores.items():
if name.startswith(prefix) and store is not None:
found.add(name)
base = Path(settings.faiss_index_path)
if base.exists():
for d in base.iterdir():
if d.is_dir() and d.name.startswith(prefix):
found.add(d.name)
return sorted(found)
print("[vector_store] Module ready.")