Spaces:
Runtime error
Runtime error
Download app.py from harshaojha/Harsha_Chunking_Embedding_Benchmark: direct link, hf CLI and curl.
- Browser
- Download file 16.2 kB
-
https://huggingface.co/spaces/harshaojha/Harsha_Chunking_Embedding_Benchmark/resolve/main/app.py
- Command line
-
hf download hf://spaces/harshaojha/Harsha_Chunking_Embedding_Benchmark/app.py
-
curl -L -o app.py https://huggingface.co/spaces/harshaojha/Harsha_Chunking_Embedding_Benchmark/resolve/main/app.py
16.2 kB
| from pathlib import Path | |
| import spaces | |
| import gradio as gr | |
| import matplotlib.pyplot as plt | |
| import numpy as np | |
| import pandas as pd | |
| import torch | |
| import json | |
| import random | |
| import re | |
| import time | |
| import uuid | |
| import zipfile | |
| from bs4 import BeautifulSoup | |
| from docx import Document as DocxDocument | |
| from langchain_text_splitters import RecursiveCharacterTextSplitter | |
| from pypdf import PdfReader | |
| from sentence_transformers import SentenceTransformer | |
| from transformers import AutoTokenizer | |
| SEED = 42 | |
| MODEL_ID = "sentence-transformers/all-MiniLM-L6-v2" | |
| BATCH_SIZE = 8 | |
| DEVICE = "cpu" | |
| random.seed(SEED) | |
| np.random.seed(SEED) | |
| torch.manual_seed(SEED) | |
| tokenizer = AutoTokenizer.from_pretrained(MODEL_ID) | |
| _embedding_model = None | |
| def get_embedding_model(): | |
| global _embedding_model | |
| if _embedding_model is None: | |
| _embedding_model = SentenceTransformer(MODEL_ID, device=DEVICE) | |
| return _embedding_model | |
| def clean_text(value): | |
| value = value.replace("\x00", " ") | |
| value = value.replace("\r\n", "\n").replace("\r", "\n") | |
| value = re.sub(r"[ \t]+", " ", value) | |
| value = re.sub(r" *\n *", "\n", value) | |
| value = re.sub(r"\n{3,}", "\n\n", value) | |
| return value.strip() | |
| def extract_pdf_page(page): | |
| try: | |
| return page.extract_text(extraction_mode="layout") or "" | |
| except TypeError: | |
| return page.extract_text() or "" | |
| def extract_document(path): | |
| path = Path(path) | |
| suffix = path.suffix.lower() | |
| page_count = None | |
| if suffix == ".pdf": | |
| reader = PdfReader(str(path)) | |
| pages = [extract_pdf_page(page) for page in reader.pages] | |
| raw_text = "\n\n".join(pages) | |
| page_count = len(pages) | |
| elif suffix == ".docx": | |
| document = DocxDocument(str(path)) | |
| raw_text = "\n\n".join( | |
| paragraph.text | |
| for paragraph in document.paragraphs | |
| if paragraph.text.strip() | |
| ) | |
| elif suffix == ".txt": | |
| raw_text = path.read_text(encoding="utf-8", errors="ignore") | |
| elif suffix in {".html", ".htm"}: | |
| html = path.read_text(encoding="utf-8", errors="ignore") | |
| soup = BeautifulSoup(html, "lxml") | |
| for tag in soup(["script", "style", "nav", "footer"]): | |
| tag.decompose() | |
| raw_text = soup.get_text("\n") | |
| else: | |
| raise ValueError("Upload a PDF, DOCX, TXT, HTML or HTM file.") | |
| return clean_text(raw_text), page_count, suffix[1:].upper() | |
| def token_count(value): | |
| return len( | |
| tokenizer.encode( | |
| value, | |
| add_special_tokens=False, | |
| truncation=False, | |
| verbose=False, | |
| ) | |
| ) | |
| def split_sentences(value): | |
| return [ | |
| sentence.strip() | |
| for sentence in re.split(r"(?<=[.!?])\s+|\n+", value) | |
| if sentence.strip() | |
| ] | |
| def fixed_chunks(value, chunk_size, overlap_ratio): | |
| token_ids = tokenizer.encode(value, add_special_tokens=False) | |
| overlap = int(chunk_size * overlap_ratio) | |
| step = max(1, chunk_size - overlap) | |
| chunks = [] | |
| for start in range(0, len(token_ids), step): | |
| piece = token_ids[start:start + chunk_size] | |
| if not piece: | |
| continue | |
| chunk = tokenizer.decode(piece, skip_special_tokens=True).strip() | |
| if chunk: | |
| chunks.append(chunk) | |
| if start + chunk_size >= len(token_ids): | |
| break | |
| return chunks | |
| def merge_units(units, chunk_size, overlap_ratio): | |
| chunks = [] | |
| current = [] | |
| for unit in units: | |
| if token_count(unit) > chunk_size: | |
| if current: | |
| chunks.append(" ".join(current).strip()) | |
| current = [] | |
| chunks.extend(fixed_chunks(unit, chunk_size, overlap_ratio)) | |
| continue | |
| candidate = " ".join(current + [unit]).strip() | |
| if current and token_count(candidate) > chunk_size: | |
| chunks.append(" ".join(current).strip()) | |
| overlap_budget = int(chunk_size * overlap_ratio) | |
| carried = [] | |
| for previous in reversed(current): | |
| test = " ".join([previous] + carried).strip() | |
| if token_count(test) <= overlap_budget: | |
| carried.insert(0, previous) | |
| else: | |
| break | |
| current = carried + [unit] | |
| else: | |
| current.append(unit) | |
| if current: | |
| chunks.append(" ".join(current).strip()) | |
| return [chunk for chunk in chunks if chunk] | |
| def sentence_chunks(value, chunk_size, overlap_ratio): | |
| return merge_units(split_sentences(value), chunk_size, overlap_ratio) | |
| def paragraph_chunks(value, chunk_size, overlap_ratio): | |
| paragraphs = [ | |
| paragraph.strip() | |
| for paragraph in re.split(r"\n\s*\n", value) | |
| if paragraph.strip() | |
| ] | |
| return merge_units(paragraphs, chunk_size, overlap_ratio) | |
| def recursive_chunks(value, chunk_size, overlap_ratio): | |
| splitter = RecursiveCharacterTextSplitter.from_huggingface_tokenizer( | |
| tokenizer, | |
| chunk_size=chunk_size, | |
| chunk_overlap=int(chunk_size * overlap_ratio), | |
| separators=["\n\n", "\n", ". ", " ", ""], | |
| ) | |
| return [chunk.strip() for chunk in splitter.split_text(value) if chunk.strip()] | |
| def semantic_chunks(value, target_size, break_percentile=25): | |
| sentences = split_sentences(value) | |
| if len(sentences) <= 1: | |
| return sentences | |
| vectors = get_embedding_model().encode( | |
| sentences, | |
| batch_size=BATCH_SIZE, | |
| normalize_embeddings=True, | |
| convert_to_numpy=True, | |
| show_progress_bar=False, | |
| ) | |
| similarities = np.sum(vectors[:-1] * vectors[1:], axis=1) | |
| threshold = np.percentile(similarities, break_percentile) | |
| chunks = [] | |
| current = [sentences[0]] | |
| for index in range(1, len(sentences)): | |
| candidate = " ".join(current + [sentences[index]]) | |
| topic_shift = similarities[index - 1] < threshold | |
| too_large = token_count(candidate) > target_size | |
| if topic_shift or too_large: | |
| chunks.append(" ".join(current).strip()) | |
| current = [sentences[index]] | |
| else: | |
| current.append(sentences[index]) | |
| if current: | |
| chunks.append(" ".join(current).strip()) | |
| return chunks | |
| def create_chunks(text, strategy, chunk_size, overlap_percent): | |
| overlap_ratio = overlap_percent / 100 | |
| if strategy == "Fixed": | |
| return fixed_chunks(text, chunk_size, overlap_ratio) | |
| if strategy == "Sentence": | |
| return sentence_chunks(text, chunk_size, overlap_ratio) | |
| if strategy == "Paragraph": | |
| return paragraph_chunks(text, chunk_size, overlap_ratio) | |
| if strategy == "Recursive": | |
| return recursive_chunks(text, chunk_size, overlap_ratio) | |
| if strategy == "Semantic": | |
| return semantic_chunks(text, chunk_size) | |
| raise ValueError(f"Unsupported strategy: {strategy}") | |
| def boundary_score(chunks): | |
| if not chunks: | |
| return 0.0 | |
| valid = sum( | |
| bool(re.search(r"[.!?][\"')\]]?$", chunk.strip())) | |
| for chunk in chunks | |
| ) | |
| return valid / len(chunks) | |
| def context_preservation_score(source_text, chunks, sample_size=100): | |
| candidates = [ | |
| sentence | |
| for sentence in split_sentences(source_text) | |
| if token_count(sentence) >= 8 | |
| ] | |
| if not candidates: | |
| return 0.0 | |
| sample = random.Random(SEED).sample( | |
| candidates, | |
| min(sample_size, len(candidates)), | |
| ) | |
| preserved = sum(any(sentence in chunk for chunk in chunks) for sentence in sample) | |
| return preserved / len(sample) | |
| def run_benchmark(uploaded_file, strategy, chunk_size, overlap_percent, maximum_chunks): | |
| if uploaded_file is None: | |
| raise gr.Error("Upload one document first.") | |
| run_directory = Path("/tmp") / f"benchmark_{uuid.uuid4().hex[:8]}" | |
| run_directory.mkdir(parents=True, exist_ok=True) | |
| try: | |
| text, page_count, document_type = extract_document(uploaded_file) | |
| except Exception as error: | |
| raise gr.Error(f"Document extraction failed: {error}") | |
| if len(text) < 100: | |
| raise gr.Error("Very little text was extracted. A scanned PDF may require OCR.") | |
| document_summary = pd.DataFrame([{ | |
| "File": Path(uploaded_file).name, | |
| "Type": document_type, | |
| "Pages": page_count, | |
| "Characters": len(text), | |
| "Words": len(text.split()), | |
| "Tokens": token_count(text), | |
| }]) | |
| started = time.perf_counter() | |
| chunks = create_chunks(text, strategy, int(chunk_size), int(overlap_percent)) | |
| chunk_seconds = time.perf_counter() - started | |
| if not chunks: | |
| raise gr.Error("The selected configuration produced no chunks.") | |
| lengths = [token_count(chunk) for chunk in chunks] | |
| duplicates = len(chunks) - len(set(chunks)) | |
| effective_overlap = 0 if strategy == "Semantic" else int(overlap_percent) | |
| chunk_summary = pd.DataFrame([{ | |
| "Strategy": strategy, | |
| "Target tokens": int(chunk_size), | |
| "Overlap": f"{effective_overlap}%", | |
| "Chunks": len(chunks), | |
| "Average tokens": round(float(np.mean(lengths)), 2), | |
| "Minimum tokens": int(min(lengths)), | |
| "Maximum tokens": int(max(lengths)), | |
| "Boundary score": round(boundary_score(chunks), 4), | |
| "Context preservation": round(context_preservation_score(text, chunks), 4), | |
| "Duplicate ratio": round(duplicates / max(len(chunks), 1), 4), | |
| "Processing seconds": round(chunk_seconds, 4), | |
| }]) | |
| preview = "\n\n".join( | |
| f"CHUNK {index}\n{'=' * 60}\n{chunk[:1200]}" | |
| for index, chunk in enumerate(chunks[:3], start=1) | |
| ) | |
| selected_chunks = chunks[:int(maximum_chunks)] | |
| model = get_embedding_model() | |
| started = time.perf_counter() | |
| vectors = model.encode( | |
| selected_chunks, | |
| batch_size=BATCH_SIZE, | |
| normalize_embeddings=True, | |
| convert_to_numpy=True, | |
| show_progress_bar=False, | |
| ) | |
| embedding_seconds = time.perf_counter() - started | |
| test_pairs = { | |
| "Related": [ | |
| "Chunking divides a document into smaller retrieval units.", | |
| "Documents are split into smaller passages before retrieval.", | |
| ], | |
| "Unrelated": [ | |
| "Chunking divides a document into smaller retrieval units.", | |
| "The football match was postponed because of heavy rain.", | |
| ], | |
| } | |
| similarity_rows = [] | |
| for pair_name, pair in test_pairs.items(): | |
| pair_vectors = model.encode( | |
| pair, | |
| normalize_embeddings=True, | |
| convert_to_numpy=True, | |
| show_progress_bar=False, | |
| ) | |
| similarity_rows.append({ | |
| "Pair": pair_name, | |
| "Cosine similarity": round(float(np.dot(pair_vectors[0], pair_vectors[1])), 4), | |
| }) | |
| similarity_df = pd.DataFrame(similarity_rows) | |
| embedding_summary = pd.DataFrame([{ | |
| "Model": "all-MiniLM-L6-v2", | |
| "Device": DEVICE, | |
| "Chunks embedded": len(selected_chunks), | |
| "Vector dimension": int(vectors.shape[1]), | |
| "Output shape": str(tuple(vectors.shape)), | |
| "Embedding seconds": round(embedding_seconds, 3), | |
| "Chunks per second": round(len(selected_chunks) / max(embedding_seconds, 1e-9), 3), | |
| "Vector storage MB": round(vectors.nbytes / (1024 ** 2), 4), | |
| }]) | |
| figure, axes = plt.subplots(1, 2, figsize=(13, 5)) | |
| axes[0].hist(lengths, bins=min(15, max(5, len(lengths))), color="#4F46E5", edgecolor="white") | |
| axes[0].set_title("Chunk token distribution") | |
| axes[0].set_xlabel("Tokens per chunk") | |
| axes[0].set_ylabel("Number of chunks") | |
| axes[0].grid(alpha=0.2) | |
| axes[1].bar( | |
| similarity_df["Pair"], | |
| similarity_df["Cosine similarity"], | |
| color=["#16A34A", "#DC2626"], | |
| ) | |
| axes[1].set_title("Semantic similarity test") | |
| axes[1].set_xlabel("Sentence-pair type") | |
| axes[1].set_ylabel("Cosine similarity") | |
| axes[1].grid(axis="y", alpha=0.2) | |
| figure.tight_layout() | |
| document_summary.to_csv(run_directory / "document_summary.csv", index=False) | |
| chunk_summary.to_csv(run_directory / "chunk_summary.csv", index=False) | |
| pd.DataFrame({ | |
| "chunk_id": range(len(chunks)), | |
| "tokens": lengths, | |
| "text": chunks, | |
| }).to_csv(run_directory / "chunks.csv", index=False) | |
| embedding_summary.to_csv(run_directory / "embedding_summary.csv", index=False) | |
| similarity_df.to_csv(run_directory / "similarity_results.csv", index=False) | |
| np.save(run_directory / "vectors.npy", vectors) | |
| figure.savefig(run_directory / "benchmark_plots.png", dpi=180, bbox_inches="tight") | |
| settings = { | |
| "strategy": strategy, | |
| "chunk_size": int(chunk_size), | |
| "overlap_percent": effective_overlap, | |
| "model": MODEL_ID, | |
| "maximum_chunks": int(maximum_chunks), | |
| "device": DEVICE, | |
| "seed": SEED, | |
| } | |
| with open(run_directory / "settings.json", "w") as file: | |
| json.dump(settings, file, indent=2) | |
| archive_path = run_directory / "benchmark_results.zip" | |
| with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as archive: | |
| for output_path in run_directory.iterdir(): | |
| if output_path.is_file() and output_path != archive_path: | |
| archive.write(output_path, arcname=output_path.name) | |
| status = ( | |
| "### Benchmark completed\n" | |
| f"Created **{len(chunks)} chunks** and embedded " | |
| f"**{len(selected_chunks)} chunks** using **all-MiniLM-L6-v2**." | |
| ) | |
| return ( | |
| status, | |
| document_summary, | |
| chunk_summary, | |
| preview, | |
| embedding_summary, | |
| figure, | |
| str(archive_path), | |
| ) | |
| with gr.Blocks(title="Chunking and Embedding Benchmark") as demo: | |
| gr.Markdown( | |
| """ | |
| # Chunking and Embedding Benchmark | |
| Upload a document and test a chunking configuration. The application | |
| measures chunk quality, generates MiniLM embeddings and exports the results. | |
| """ | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| uploaded_file = gr.File( | |
| label="Upload document", | |
| file_types=[".pdf", ".docx", ".txt", ".html", ".htm"], | |
| type="filepath", | |
| ) | |
| strategy = gr.Dropdown( | |
| choices=["Fixed", "Sentence", "Paragraph", "Recursive", "Semantic"], | |
| value="Recursive", | |
| label="Chunking strategy", | |
| ) | |
| chunk_size = gr.Dropdown( | |
| choices=[128, 256, 512, 1024], | |
| value=256, | |
| label="Target chunk size", | |
| ) | |
| overlap_percent = gr.Dropdown( | |
| choices=[0, 10, 20, 30], | |
| value=10, | |
| label="Overlap percentage", | |
| ) | |
| maximum_chunks = gr.Slider( | |
| minimum=5, | |
| maximum=300, | |
| value=100, | |
| step=5, | |
| label="Maximum chunks to embed", | |
| ) | |
| run_button = gr.Button("Run Benchmark", variant="primary") | |
| with gr.Column(scale=2): | |
| status = gr.Markdown("Upload a document and run the benchmark.") | |
| document_output = gr.Dataframe(label="Document summary", interactive=False) | |
| chunk_output = gr.Dataframe(label="Chunking results", interactive=False) | |
| with gr.Tab("Chunk preview"): | |
| preview_output = gr.Textbox(label="First three chunks", lines=22, interactive=False) | |
| with gr.Tab("Embedding results"): | |
| embedding_output = gr.Dataframe(label="Embedding measurements", interactive=False) | |
| plot_output = gr.Plot(label="Benchmark plots") | |
| with gr.Tab("Download"): | |
| download_output = gr.File(label="Download benchmark results") | |
| run_button.click( | |
| fn=run_benchmark, | |
| inputs=[uploaded_file, strategy, chunk_size, overlap_percent, maximum_chunks], | |
| outputs=[ | |
| status, | |
| document_output, | |
| chunk_output, | |
| preview_output, | |
| embedding_output, | |
| plot_output, | |
| download_output, | |
| ], | |
| ) | |
| if __name__ == "__main__": | |
| demo.queue().launch( | |
| theme=gr.themes.Soft(), | |
| ssr_mode=False, | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| ) |