pdf-explainer-using-RAG / app /retrieval.py
arnel8888's picture
Added project files
cc8152f verified
Raw
History Blame Contribute Delete
12 kB
# This file contains the functions for the text processing and document retrieval segment of the chatbot
import os
from typing import List, Dict, Any
import pymupdf4llm
import re
import unicodedata
from langchain_text_splitters import RecursiveCharacterTextSplitter
import chromadb
from chromadb.utils import embedding_functions
def parse_pdf(filepath: str, write_images: bool = False) -> List[Dict[str, Any]]:
"""
Parse a PDF file and extract text with metadata from each page using pymupdf4llm.
Args:
filepath (str): Path to the PDF file
write_images (bool): Whether to extract and save images from the PDF
Returns:
list: List of dictionaries with format including filename, page, text, and additional metadata
"""
result = []
# Extract filename from filepath
filename = os.path.basename(filepath)
try:
# Extract text using pymupdf4llm with page-wise extraction
page_data_list = pymupdf4llm.to_markdown(
filepath,
page_chunks = True,
write_images = write_images
)
# Process each page's data
for page_info in page_data_list:
# Extract the text content
page_text = page_info.get('text', '')
page_metadata = page_info.get('metadata', {})
# Create enhanced page data dictionary
enhanced_page_data = {
'filename': filename,
'page': page_metadata.get('page', 0),
'text': page_text,
'text_format': 'markdown',
'extraction_method': 'pymupdf4llm',
'has_tables': '|' in page_text, # Basic table detection
'char_count': len(page_text),
'word_count': len(page_text.split()),
'line_count': len(page_text.split('\n')),
'images_extracted': write_images,
'source_bbox': page_metadata.get('bbox', None),
'source_page_size': page_metadata.get('page_size', None)
}
# Add any additional metadata from pymupdf4llm
for key, value in page_metadata.items():
if key not in ['page', 'bbox', 'page_size']: # Avoid duplicates
enhanced_page_data[f'source_{key}'] = value
result.append(enhanced_page_data)
except Exception as e:
print(f"Error parsing PDF {filepath}: {str(e)}")
# Fallback: try without page chunks
try:
md_text_fallback = pymupdf4llm.to_markdown(filepath, write_images = write_images)
page_data = {
'filename': filename,
'page': 1,
'text': md_text_fallback,
'text_format': 'markdown',
'extraction_method': 'pymupdf4llm_fallback',
'has_tables': '|' in md_text_fallback,
'char_count': len(md_text_fallback),
'word_count': len(md_text_fallback.split()),
'line_count': len(md_text_fallback.split('\n')),
'images_extracted': write_images,
'error_note': 'Page-wise extraction failed, using full document'
}
result.append(page_data)
except Exception as fallback_error:
print(f"Fallback extraction also failed for {filepath}: {str(fallback_error)}")
return []
return result
def clean_text(text: str) -> str:
"""
Clean text for better RAG performance while preserving markdown structure.
Args:
text (str): Raw text to clean
Returns:
str: Cleaned text optimized for embedding and chunking
"""
if not text or not text.strip():
return ""
# Normalize unicode characters
text = unicodedata.normalize('NFKD', text)
# Fix common PDF extraction artifacts
# Fix hyphenated words broken across lines
text = re.sub(r'(\w+)-\s*\n\s*(\w+)', r'\1\2', text)
# Remove excessive whitespace while preserving structure
text = re.sub(r' +', ' ', text) # Multiple spaces to single space
text = re.sub(r'\t+', ' ', text) # Tabs to single space
text = re.sub(r'\n +', '\n', text) # Remove spaces after newlines
text = re.sub(r' +\n', '\n', text) # Remove spaces before newlines
# Normalize line breaks (preserve paragraph structure)
text = re.sub(r'\n{3,}', '\n\n', text) # Max 2 consecutive newlines
text = re.sub(r'\r\n', '\n', text) # Windows line endings to Unix
text = re.sub(r'\r', '\n', text) # Old Mac line endings to Unix
# Clean up common PDF artifacts
# Remove standalone page numbers (numbers on their own line)
text = re.sub(r'\n\s*\d+\s*\n', '\n', text)
# Remove standalone roman numerals (common in headers/footers)
text = re.sub(r'\n\s*[ivxlcdm]+\s*\n', '\n', text, flags = re.IGNORECASE)
# Clean up markdown table formatting (preserve structure but clean spacing)
# Fix spacing around table delimiters
text = re.sub(r' +\| +', ' | ', text) # Normalize spacing around pipes
text = re.sub(r'^\| +', '| ', text, flags = re.MULTILINE) # Start of line pipes
text = re.sub(r' +\|$', ' |', text, flags = re.MULTILINE) # End of line pipes
# Preserve list formatting but clean spacing
text = re.sub(r'\n +([•\-\*\+])', r'\n\1', text) # Bullet lists
text = re.sub(r'\n +(\d+\.)', r'\n\1', text) # Numbered lists
# Clean up header formatting (preserve markdown headers)
text = re.sub(r'\n +(#+)', r'\n\1', text) # Remove spaces before headers
text = re.sub(r'(#+) +([^\n]+)', r'\1 \2', text) # Normalize header spacing
# Remove excessive punctuation (but preserve meaningful punctuation)
text = re.sub(r'\.{3,}', '...', text) # Multiple dots to ellipsis
text = re.sub(r'-{3,}', '---', text) # Multiple dashes to em dash
# Clean up quote marks
text = re.sub(r'[\u201C\u201D\u201E]', '"', text) # Normalize quotes
text = re.sub(r'[\u2018\u2019]', "'", text) # Normalize apostrophes
# Remove zero-width characters and other invisible characters
text = re.sub(r'[\u200B\u200C\u200D\uFEFF]', '', text)
# Final cleanup
text = text.strip() # Remove leading/trailing whitespace
# Ensure text doesn't start or end with newlines after cleaning
text = text.strip('\n')
return text
def chunk_text_recursive(text: str, chunk_size: int = 500, chunk_overlap: int = 150) -> List[str]:
"""
Split text into chunks using LangChain's RecursiveCharacterTextSplitter.
Args:
text (str): Text to be chunked
chunk_size (int): Maximum size of each chunk in characters
chunk_overlap (int): Number of characters to overlap between chunks
Returns:
List[str]: List of text chunks
"""
if not text or not text.strip():
return []
# Initialize the text splitter
text_splitter = RecursiveCharacterTextSplitter(
chunk_size = chunk_size,
chunk_overlap = chunk_overlap,
length_function = len,
is_separator_regex = False,
)
# Split the text and return chunks
chunks = text_splitter.split_text(text)
return chunks
def access_chroma_collection(name: str):
"""
Get or create a Chroma collection with the given name using ephemeral client.
Args:
name (str): Name of the collection
Returns:
Collection: ChromaDB collection object
"""
client = chromadb.EphemeralClient()
sentence_transformer_ef = embedding_functions.SentenceTransformerEmbeddingFunction(
model_name = "BAAI/bge-small-en-v1.5"
)
collection = client.get_or_create_collection(name = name, embedding_function = sentence_transformer_ef)
return collection
def preprocess_text(pages: List[Dict[str, Any]], chunk_size: int = 500, chunk_overlap: int = 150) -> List[Dict[str, Any]]:
"""
Clean and chunk text from parsed pages, retaining metadata.
Args:
pages (List[Dict[str, Any]]): Output from parse_pdf function
chunk_size (int): Size for text chunking
chunk_overlap (int): Overlap for text chunking
Returns:
List[Dict[str, Any]]: List of chunk dictionaries with metadata
"""
chunk_documents = []
for page in pages:
# Clean the text
cleaned_text = clean_text(page['text'])
# Skip empty pages
if not cleaned_text.strip():
continue
# Chunk the cleaned text
chunks = chunk_text_recursive(cleaned_text, chunk_size, chunk_overlap)
# Create chunk documents with metadata
for chunk_num, chunk_text in enumerate(chunks):
chunk_doc = {
# Original page metadata
'filename': page['filename'],
'page': page['page'],
'text_format': page['text_format'],
'extraction_method': page['extraction_method'],
'page_has_tables': page['has_tables'],
'page_char_count': page['char_count'],
'page_word_count': page['word_count'],
'page_line_count': page['line_count'],
'page_images_extracted': page['images_extracted'],
'page_source_bbox': page['source_bbox'],
'page_source_page_size': page['source_page_size'],
# Chunk-specific data
'text': chunk_text,
'chunk_number': chunk_num + 1,
'total_chunks_for_page': len(chunks),
'chunk_char_count': len(chunk_text),
'chunk_word_count': len(chunk_text.split()),
'is_chunked': True,
'chunk_size_used': chunk_size,
'chunk_overlap_used': chunk_overlap
}
chunk_documents.append(chunk_doc)
return chunk_documents
def add_documents(name: str, documents: List[Dict[str, Any]]) -> None:
"""
Add documents to a ChromaDB collection.
Args:
name (str): Collection name
documents (List[Dict[str, Any]]): List of document dictionaries
"""
collection = access_chroma_collection(name)
chunk_documents = preprocess_text(documents)
# Prepare data for ChromaDB
ids = []
texts = []
metadatas = []
for doc in chunk_documents:
# Create unique ID: {filename}_page{page}_chunk{chunk}
doc_id = f"{doc['filename']}_page{doc['page']}_chunk{doc['chunk_number']}"
ids.append(doc_id)
texts.append(doc['text'])
# Prepare metadata (exclude text and None values)
metadata = {}
for key, value in doc.items():
if key != 'text' and value is not None:
metadata[key] = value
metadatas.append(metadata)
# Add to collection
collection.add(
ids = ids,
documents = texts,
metadatas = metadatas
)
def retrieve_documents(name: str, query: str, top_k: int = 5) -> Dict[str, Any]:
"""
Query documents from a ChromaDB collection.
Args:
name (str): Collection name
query (str): Query text
top_k (int): Number of top results to return
Returns:
Dict[str, Any]: Query results from ChromaDB
"""
collection = access_chroma_collection(name)
results = collection.query(
query_texts = [query],
n_results = top_k
)
return results