""" Vector Database Management for Legal RAG Pipeline ================================================ This module provides comprehensive vector database functionality for the Legal RAG Pipeline, including document loading, text extraction, OCR processing, and FAISS-based vector storage with GPU acceleration support. Features: - Multi-format document loading (PDF, web pages, text files) - OCR fallback for scanned documents and images - FAISS vector store with GPU acceleration - Chunking and embedding of documents - Web document scraping and processing - Local file caching and management """ from langchain_community.document_loaders import WebBaseLoader, PyPDFLoader from langchain.text_splitter import RecursiveCharacterTextSplitter import faiss try: from faiss import StandardGpuResources, index_cpu_to_gpu except ImportError: StandardGpuResources = None index_cpu_to_gpu = None from langchain_community.vectorstores import FAISS from langchain_community.docstore.in_memory import InMemoryDocstore from langchain.text_splitter import RecursiveCharacterTextSplitter from src.generation.model_provider import get_embedding_model from langchain_core.documents import Document import os import pytesseract from pdf2image import convert_from_path import webbrowser from PIL import Image import pytesseract import requests import time from src.generation.model_provider import get_embedding_model import numpy as np from uuid import uuid4 from pathlib import Path from tqdm import tqdm import logging # Set up logger logger = logging.getLogger(__name__) # Ensure local directory exists for storing downloaded PDFs local_file_dir = "app_data/local_pdfs" os.makedirs(local_file_dir, exist_ok=True) def extract_text_with_ocr(file_path): """ Extract text from PDF or image files using OCR technology. This function uses Tesseract OCR to extract text from scanned documents and images when standard text extraction methods fail. Args: file_path (str): Path to the PDF or image file Returns: str: Extracted text content """ ext = os.path.splitext(file_path)[1].lower() text = "" try: if ext == ".pdf": # Convert PDF pages to images and apply OCR images = convert_from_path(file_path) for img in images: text += pytesseract.image_to_string(img) elif ext in [".png", ".jpg", ".jpeg", ".bmp", ".tiff"]: # Apply OCR directly to image files img = Image.open(file_path) text = pytesseract.image_to_string(img) except Exception as e: logger.info(f"OCR extraction failed for {file_path}: {e}") return text def load_source_with_fallback(source): """ Load document text with automatic fallback to OCR if needed. This function attempts to load documents using standard methods (PyPDFLoader for PDFs, web scraping for URLs) and falls back to OCR processing if standard extraction fails or produces poor results. Supports multiple document formats: - PDF files (local and web URLs) - Image files (PNG, JPG, JPEG, BMP, TIFF) - Text files (UTF-8 encoded) Args: source (str): Path to the document file or URL Returns: Document: LangChain Document object containing extracted text and metadata """ doc = None local_file_path = os.path.join(local_file_dir, source.split("/")[-1]) ext = os.path.splitext(source)[1].lower() previous_cache_from_web = f"{local_file_path}.txt" if os.path.exists(previous_cache_from_web): source = previous_cache_from_web ext = ".txt" if ext == ".pdf": # Handle web PDF downloads if source.startswith("https"): logger.info(f"\tLoading PDF from URL: {source}") # Download the PDF if it doesn't exist locally if not os.path.exists(local_file_path): headers = { "User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0", } response = requests.get(source, headers=headers, stream=True) if response.status_code == 200: os.makedirs(local_file_dir, exist_ok=True) with open(local_file_path, "wb") as pdf_file: for chunk in response.iter_content(chunk_size=1024): pdf_file.write(chunk) logger.info(f"PDF downloaded successfully to {local_file_path}") ocr_text = extract_text_with_ocr(local_file_path) if ocr_text.strip(): doc = Document(page_content=ocr_text, metadata={'source': source, "ocr_applied": True}) elif response.status_code == 404: source = source.replace(".pdf", ".html") web_doc = WebBaseLoader(source, continue_on_failure=True).load()[0] web_doc.page_content = web_doc.page_content.strip() with open(previous_cache_from_web, "w", encoding="utf-8") as f: f.write(web_doc.page_content) return web_doc elif response.status_code == 429: time.sleep(10) # Wait for the browser to close return load_source_with_fallback(source) else: logger.info(f"PDF already exists locally at {local_file_path}") ocr_text = extract_text_with_ocr(local_file_path) if ocr_text.strip(): doc = Document(page_content=ocr_text, metadata={'source': source, "ocr_applied": True}) else: logger.info(f"\tUsing PyPDFLoader for local PDF") loader = PyPDFLoader(source) doc = loader.load()[0] # If no text extracted, fallback to OCR if not doc or not doc.page_content.strip(): logger.info(f"\tNo text extracted from PDF, applying OCR") ocr_text = extract_text_with_ocr(source) if ocr_text.strip(): doc = Document(page_content=ocr_text, metadata={'source': source, "ocr_applied": True}) elif ext in [".png", ".jpg", ".jpeg", ".bmp", ".tiff"]: ocr_text = extract_text_with_ocr(source) if ocr_text.strip(): doc = Document(page_content=ocr_text, metadata={'source': source, "ocr_applied": True}) else: # Fallback: treat as text file logger.info(f"\tLoading text file: {source}") with open(source, 'r', encoding='utf-8', errors='ignore') as f: content = f.read() doc = Document(page_content=content, metadata={'source': source}) return doc # Index documents from URLs and create a retriever for RAG class VectorStoreManager: """ Vector Store Manager for Legal Document Processing. This class manages FAISS vector stores with GPU acceleration support, handling document indexing, embedding generation, and similarity search for legal document retrieval. Features: - GPU-accelerated FAISS indexing with configurable memory allocation - Multiple document format support (PDF, web, text) - Automatic embedding dimension detection - IVF (Inverted File) indexing for large-scale similarity search - Persistent storage and loading of vector indexes - Memory optimization for resource-constrained environments Attributes: file_path (str): Path to vector store file for persistence use_gpu (bool): Whether to use GPU acceleration nlist (int): Number of clusters for IVF index gpu_memory_mb (int): GPU memory allocation in MB gpu_res: FAISS GPU resources object embeddings: Embedding model instance dim (int): Embedding vector dimensionality vector_store: FAISS vector store instance """ def __init__(self, file_path=None, nlist=100, gpu_memory_mb=128): """ Initialize VectorStoreManager with configurable GPU and memory settings. Sets up embedding model, detects vector dimensions, and initializes GPU resources. Args: file_path (str, optional): Path to existing vector store file nlist (int): Number of clusters for IVF index gpu_memory_mb (int): GPU memory allocation in MB """ self.file_path = file_path self.nlist = nlist self.gpu_memory_mb = gpu_memory_mb self.gpu_res = None self.batch_size = 128 # Configure embedding model with optimized settings self.embeddings_config = {"wait_time": 60} self.embeddings = get_embedding_model("mistral-embed", **self.embeddings_config) # Detect embedding dimensionality test_embedding = self.embeddings.embed_query("Test query to determine embedding dimensionality.") self.dim = len(test_embedding) logger.info(f"Embedding dimension detected: {self.dim}") # Initialize GPU resources when available. Hugging Face Spaces and # most local installs use faiss-cpu, which does not expose GPU helpers. if StandardGpuResources is not None: try: self.gpu_res = StandardGpuResources() self.gpu_res.setTempMemory(self.gpu_memory_mb * 1024 * 1024) logger.info(f"GPU resources initialized for FAISS with {self.gpu_memory_mb} MB memory allocation.") except Exception as e: logger.info(f"FAISS GPU initialization failed; using CPU index: {e}") self.gpu_res = None else: logger.info("FAISS GPU helpers are unavailable; using CPU index.") if self.file_path: # Load existing vector store from disk self.vector_store = FAISS.load_local( self.file_path, self.embeddings, allow_dangerous_deserialization=True ) self._move_to_gpu() else: # Create a new vector store # Start with 'Flat' and upgrade to IVF when dataset size exceeds threshold index = self._create_optimized_index() self.vector_store = FAISS( embedding_function=self.embeddings, index=index, docstore=InMemoryDocstore(), index_to_docstore_id={}, ) self.text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder( chunk_size=1500, chunk_overlap=150, # 10% overlap separators=["\n\n", "\n", ".", "!", "?", "。", "!", "?", " "] ) def _train_index(self, embedding_vectors: list[np.ndarray]): """ Train the FAISS index if it requires training (IVF indices). Also handles switching from Flat to IVF index when enough vectors are available. Args: embedding_vectors: List of embedding vectors for training """ index = self.vector_store.index num_vectors = len(embedding_vectors) # Check if we have enough vectors to qualify for IVF ivf_index_required = num_vectors + index.ntotal >= self.nlist * 2 # Upgrade if ("Flat" in type(index).__name__ and ivf_index_required): # Check if we have enough vectors to qualify for IVF logger.info(f"Switching to IVF index with {num_vectors + index.ntotal} vectors (>= {self.nlist * 2})") self._upgrade_to_ivf_index() index = self.vector_store.index # Check if we need to train the index if hasattr(index, "is_trained") and not index.is_trained: # Check if we have enough vectors to train IVF index if ivf_index_required: logger.info(f"Not enough vectors to train IVF index with {self.nlist} clusters. " f"Need at least {self.nlist} vectors. Deferring training.") return vectors_np = np.array(embedding_vectors, dtype='float32') logger.info(f"Training index with {len(vectors_np)} vectors...") # Training happens on the current device (GPU or CPU) index.train(vectors_np) logger.info("Index training completed.") def _move_to_gpu(self): """ Move the loaded vector store index to GPU. Handles different index types that might be loaded from disk. """ if self.gpu_res: current_index = self.vector_store.index # Check if index is already on GPU if "Gpu" in type(current_index).__name__: logger.info("Index is already on GPU.") return try: # Move index to GPU gpu_index = index_cpu_to_gpu(self.gpu_res, 0, current_index) self.vector_store.index = gpu_index logger.info(f"Vector store index moved to GPU after load: {type(gpu_index).__name__}") except Exception as e: logger.info(f"Failed to move index to GPU (will continue with CPU): {e}") def _upgrade_to_ivf_index(self): """ Upgrade from a Flat index to an IVF index when we have enough vectors. """ current_index = self.vector_store.index # Extract all vectors from current index if current_index.ntotal > 0: logger.info(f"Extracting {current_index.ntotal} vectors from current index...") vectors = np.zeros((current_index.ntotal, self.dim), dtype='float32') for i in range(current_index.ntotal): vectors[i] = current_index.reconstruct(i) else: vectors = np.empty((0, self.dim), dtype='float32') # Create new IVF index quantizer = faiss.IndexFlatL2(self.dim) new_index = faiss.IndexIVFFlat(quantizer, self.dim, self.nlist) # Move to GPU if needed if self.gpu_res: try: new_index = index_cpu_to_gpu(self.gpu_res, 0, new_index) logger.info("New IVF index moved to GPU.") except Exception as e: logger.info(f"Failed to move new IVF index to GPU: {e}") logger.info("Continuing with CPU-based IVF index.") # Don't disable GPU entirely, just use CPU for this index # Train the new index if we have enough vectors if len(vectors) >= self.nlist: new_index.train(vectors) logger.info(f"New IVF index trained with {len(vectors)} vectors.") # Add existing vectors to new index if len(vectors) > 0: new_index.add(vectors) logger.info(f"Added {len(vectors)} existing vectors to new IVF index.") # Replace the index in vector store self.vector_store.index = new_index logger.info("Successfully upgraded to IVF index.") def _index_document(self, content, doc_metadata={'type': "unknown"}): """ Index a single document by splitting it into chunks and adding to vector store. Args: content: Document content to index doc_metadata: Metadata for the document Returns: List of chunk UUIDs """ doc_chunks = self.text_splitter.split_text(content) chunk_uuids = [str(uuid4()) for _ in range(len(doc_chunks))] # Clean up chunks to remove any empty strings doc_chunks = [chunk for chunk in doc_chunks if chunk.strip()] # Generate embeddings for all chunks vectors = [self.embeddings.embed_query(chunk) for chunk in doc_chunks] try: # Train the index if necessary (only for IVF indices) self._train_index(vectors) # Add texts with appropriate batch size for GPU performance self.vector_store.add_texts( texts=doc_chunks, metadatas=[doc_metadata] * len(doc_chunks), ids=chunk_uuids, batch_size=self.batch_size ) logger.info(f"Successfully indexed {len(doc_chunks)} chunks from {doc_metadata.get('type', 'unknown')} document.") except Exception as e: logger.info(f"Error adding document {doc_metadata.get('type', 'unknown')} to vector store: {e}") return chunk_uuids def save_vector_store(self, index_path=None): if not index_path: if not self.file_path: timestamp = int(time.time()) index_path = os.path.join("data/vector_db", f"vector_store_{timestamp}") os.makedirs(index_path, exist_ok=True) else: index_path = self.file_path else: os.makedirs(index_path, exist_ok=True) # Move index to CPU before saving if on GPU index_on_gpu = False current_index = self.vector_store.index # Check if index is on GPU by examining its type name if "Gpu" in type(current_index).__name__: try: import faiss cpu_index = faiss.index_gpu_to_cpu(current_index) self.vector_store.index = cpu_index index_on_gpu = True logger.info("Moved index to CPU for saving.") except Exception as e: logger.info(f"Failed to move index to CPU for saving: {e}") self.vector_store.save_local(index_path) self.file_path = index_path logger.info(f"Saved vector store to: {index_path}") # Move back to GPU if needed if index_on_gpu and self.gpu_res: try: from faiss import index_cpu_to_gpu self.vector_store.index = index_cpu_to_gpu(self.gpu_res, 0, self.vector_store.index) logger.info("Restored index to GPU after saving.") except Exception as e: logger.info(f"Failed to restore index to GPU after saving: {e}") return index_path def index_file_documents(self, paths, source_type): meta_datas = [] for path in tqdm(paths, desc="Indexing file documents"): doc = load_source_with_fallback(path) if not doc: logger.info(f"Failed to load document from {path}. Skipping.") continue doc.metadata["type"] = source_type doc.metadata["chunk_ids"] = self._index_document(doc.page_content, doc.metadata) meta_datas.append(doc.metadata) return meta_datas def index_web_documents(self, urls, source_type): web_documents = WebBaseLoader(urls, continue_on_failure=True).load() # Process each URL meta_datas = [] for doc in web_documents: # Doc metadata set by WebBaseLoader: # "source": url # "title": soup.find("title").get_text() # "description": soup.find("meta", attrs={"name": "description"}).get("content", "No description found.") # "language": soup.find("html").get("lang", "No language found.") doc.metadata["source_type"] = source_type doc.metadata["chunk_ids"] = self._index_document(doc.page_content, doc.metadata) meta_datas.append(doc.metadata) return meta_datas def add_texts(self, texts, metadatas=None, ids=None): """ Add texts to the vector store with proper training if needed. Optimized for GPU performance when available. Args: texts: List of text strings to add metadatas: List of metadata dictionaries (optional) ids: List of document IDs (optional) Returns: List of document IDs """ if not ids: ids = [str(uuid4()) for _ in range(len(texts))] # Generate embeddings for training if needed logger.info(f"Generating embeddings for {len(texts)} texts...") vectors = [self.embeddings.embed_query(text) for text in texts] # Train the index if necessary self._train_index(vectors) # Add the texts to the vector store result = self.vector_store.add_texts( texts=texts, metadatas=metadatas, ids=ids, batch_size=self.batch_size ) logger.info(f"Successfully added {len(texts)} texts to vector store.") return result def query_vector_store(self, query, k=5, meta_filter=None): if not self.vector_store: logger.info("No vector store loaded.") return [] return self.vector_store.similarity_search_with_score(query, k=k, filter=meta_filter) def _create_optimized_index(self): """ Create an optimized FAISS index based on expected dataset size and GPU availability. Starts with a Flat index and upgrades to IVF when enough vectors are available. Returns: FAISS index optimized for the current configuration """ # Always start with a Flat index for small datasets # We'll upgrade to IVF when we have enough vectors (>= nlist * 2) index = faiss.IndexFlatL2(self.dim) if self.gpu_res: try: # Move to GPU immediately for better performance index = index_cpu_to_gpu(self.gpu_res, 0, index) logger.info(f"Created Flat index on GPU (will upgrade to IVF when >= {self.nlist * 2} vectors)") return index except Exception as e: logger.info(f"Failed to create GPU index: {e}") logger.info("This might be due to GPU memory limitations. Falling back to CPU.") self.gpu_res = None # CPU index logger.info(f"Using CPU-based IndexFlatL2 (will upgrade to IVF when >= {self.nlist * 2} vectors)") return index def get_index_info(self): """ Get information about the current FAISS index configuration. Returns: Dictionary with index information """ if not self.vector_store or not self.vector_store.index: return {"status": "No index available"} index = self.vector_store.index info = { "index_type": type(index).__name__, "dimension": self.dim, "total_vectors": index.ntotal, "is_trained": getattr(index, "is_trained", True), "on_gpu": "Gpu" in type(index).__name__ } if hasattr(index, "nlist"): info["nlist"] = index.nlist return info def print_index_status(self): """ Print current index status and configuration. """ info = self.get_index_info() logger.info("=== FAISS Index Status ===") for key, value in info.items(): logger.info(f"{key}: {value}") logger.info("=" * 26) def get_case_vector_store_dict(self): """ Get a dictionary representation of the vector store for case management. """ return self.vector_store.docstore._dict def delete_documents(self, uuids_to_delete): """ Delete documents from the vector store by their UUIDs. Args: uuids_to_delete (list): List of UUIDs to delete from the vector store """ if not uuids_to_delete: logger.info("No UUIDs provided for deletion.") return logger.info(f"Deleting {len(uuids_to_delete)} documents from vector store...") self.vector_store.delete(uuids_to_delete) logger.info("Deletion completed.")