""" vectorstore.py ---------------------- FAISS vector store operations: filtering, chunking, indexing, deduplication. """ import re import uuid import gc from datetime import datetime, timezone, timedelta from langchain_community.vectorstores import FAISS from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_core.documents import Document from config import EXPIRY_DAYS from model_setup import embedding_model from logging_config import get_logger logger = get_logger(__name__) # Module-level state vectorstore: FAISS | None = None seen_urls: dict = {} # {url: {"ids": [...], "last_crawled": datetime}} # Content filtering _GARBAGE_LINE = re.compile(r'^\*?\s*\[.*?\]\(https?://.*?\)\s*$') _GARBAGE_PHRASES = [ 'follow us', 'sign up', 'log in', 'log out', 'subscribe', 'skip to main content', 'cookie', 'privacy policy', 'terms of service', 'all rights reserved', 'advertisement', ] def filter_content(text: str) -> str: """ Strip navigation, footer, and boilerplate lines from scraped web content before it enters FAISS. """ cleaned = [] for line in text.split('\n'): stripped = line.strip() if not stripped: continue if _GARBAGE_LINE.match(stripped): continue if any(phrase in stripped.lower() for phrase in _GARBAGE_PHRASES): continue cleaned.append(line) return '\n'.join(cleaned) def chunk_and_index(text: str, source_url: str = None) -> None: """ Filter -> chunk -> upsert into the FAISS index. chunk_size=1000/overlap=200: sized for MiniLM's effective encoding range. Deduplication: re-indexing the same URL first removes old entries so stale chunks don't accumulate. """ global vectorstore, seen_urls text = filter_content(text) if len(text.strip()) < 100: logger.debug(f"Skipping {source_url!r} — too sparse after filtering") return splitter = RecursiveCharacterTextSplitter( chunk_size=1000, chunk_overlap=200, ) chunks = splitter.split_text(text) docs = [ Document( page_content=chunk, metadata={"source": source_url or "unknown"}, ) for chunk in chunks ] ids = [str(uuid.uuid4()) for _ in docs] if vectorstore is None: vectorstore = FAISS.from_documents(docs, embedding_model, ids=ids) else: if source_url in seen_urls: vectorstore.delete(ids=seen_urls[source_url]["ids"]) vectorstore.add_documents(docs, ids=ids) seen_urls[source_url] = { "ids": ids, "last_crawled": datetime.now(timezone.utc), } del docs, chunks gc.collect() def prune_vectorstore() -> None: """Evict FAISS entries whose source URLs are older than EXPIRY_DAYS.""" global vectorstore if vectorstore is None: return now = datetime.now(timezone.utc) cutoff = timedelta(days=EXPIRY_DAYS) to_delete = [] for url, entry in list(seen_urls.items()): if now - entry["last_crawled"] > cutoff: to_delete.extend(entry["ids"]) del seen_urls[url] if to_delete: vectorstore.delete(to_delete)