LegalNexus / src /storage /vector_database.py
Ramtinhoss's picture
Deploy LegalNexus Docker Space
77348b4 verified
Raw
History Blame Contribute Delete
24.1 kB
"""
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.")