221 lines
9.1 KiB
Python
221 lines
9.1 KiB
Python
"""
|
|
PERTEMUAN 10: RAG Part 1 - Embedding & Vector Database
|
|
Complete Lab Runner: Lab 1 (Chunking), Lab 2 (Indexing & Search), Lab 3 (Hit@k Evaluation)
|
|
"""
|
|
|
|
from pathlib import Path
|
|
import random
|
|
from pypdf import PdfReader
|
|
import chromadb
|
|
from chromadb.utils import embedding_functions
|
|
|
|
# ---------------------------------------------------------
|
|
# Utility: PDF Reader & Fixed-Size Splitter
|
|
# ---------------------------------------------------------
|
|
def baca_pdf(path: Path) -> str:
|
|
"""Extracts text from all pages of a PDF."""
|
|
reader = PdfReader(path)
|
|
return "\n".join((p.extract_text() or "") for p in reader.pages)
|
|
|
|
def potong(teks: str, ukuran: int = 800, tumpang: int = 100) -> list[str]:
|
|
"""Fixed-size chunking with overlap."""
|
|
chunks, start = [], 0
|
|
while start < len(teks):
|
|
chunk = teks[start:start + ukuran].strip()
|
|
if chunk:
|
|
chunks.append(chunk)
|
|
start += ukuran - tumpang
|
|
return chunks
|
|
|
|
# ---------------------------------------------------------
|
|
# Lab 1: Chunking Experiment
|
|
# ---------------------------------------------------------
|
|
def run_lab1(pdf_path: Path):
|
|
print("\n" + "=" * 60)
|
|
print("LAB 1: CHUNKING EXPERIMENT")
|
|
print("=" * 60)
|
|
|
|
text = baca_pdf(pdf_path)
|
|
print(f"\n[1] Reading PDF: {pdf_path.name} (Total length: {len(text)} chars)")
|
|
print("-" * 50)
|
|
print("First 500 characters preview:")
|
|
print(text[:500])
|
|
print("-" * 50)
|
|
|
|
configs = [(500, 50), (1000, 100), (1500, 150)]
|
|
print("\n[2] Chunk Counts Across Configurations (overlap ~10%):")
|
|
for size, overlap in configs:
|
|
chunks = potong(text, ukuran=size, tumpang=overlap)
|
|
print(f" - Chunk Size {size} (overlap {overlap}) -> {len(chunks)} chunks")
|
|
|
|
print("\n[3] Inspection for Truncated Sentences (3 sample chunks each):")
|
|
random.seed(42)
|
|
for size, overlap in configs:
|
|
chunks = potong(text, ukuran=size, tumpang=overlap)
|
|
sample_count = min(3, len(chunks))
|
|
samples = random.sample(chunks, sample_count)
|
|
print(f"\n--- Chunk Size {size} Samples ({sample_count} samples) ---")
|
|
for i, s in enumerate(samples, 1):
|
|
starts_mid = not s[0].isupper()
|
|
ends_mid = s[-1] not in ".!?"
|
|
print(f"Sample {i} (len {len(s)}):")
|
|
print(f" Preview: {s[:80]}... [ends with: '{s[-40:]}']")
|
|
print(f" Cutoff check: Starts mid-sentence={starts_mid}, Ends mid-sentence={ends_mid}")
|
|
|
|
# ---------------------------------------------------------
|
|
# Lab 2: Indexing & Querying
|
|
# ---------------------------------------------------------
|
|
def setup_collection(client: chromadb.PersistentClient, name: str, emb_fn, chunk_size: int, chunk_overlap: int, docs_dir: Path):
|
|
# Reset collection if exists for clean repeatable runs
|
|
try:
|
|
client.delete_collection(name=name)
|
|
except Exception:
|
|
pass
|
|
|
|
col = client.create_collection(
|
|
name=name,
|
|
embedding_function=emb_fn,
|
|
metadata={"hnsw:space": "cosine"}
|
|
)
|
|
|
|
for pdf in docs_dir.glob("*.pdf"):
|
|
raw_text = baca_pdf(pdf)
|
|
chunks = potong(raw_text, ukuran=chunk_size, tumpang=chunk_overlap)
|
|
if not chunks:
|
|
continue
|
|
col.upsert(
|
|
ids=[f"{pdf.stem}-sz{chunk_size}-{i}" for i in range(len(chunks))],
|
|
documents=chunks,
|
|
metadatas=[{"sumber": pdf.name, "chunk": i, "size": chunk_size} for i in range(len(chunks))]
|
|
)
|
|
return col
|
|
|
|
def run_lab2(col):
|
|
print("\n" + "=" * 60)
|
|
print("LAB 2: BUILD INDEX & SEARCH")
|
|
print("=" * 60)
|
|
|
|
# 5 standard queries
|
|
queries = [
|
|
"What is the minimum password length required?",
|
|
"What are the core working hours for remote staff?",
|
|
"What is the company policy on harassment and discrimination?",
|
|
"Where must electronic company data be stored?",
|
|
"What are the consequences of violating the Code of Conduct?"
|
|
]
|
|
|
|
print("\n[1] Testing 5 Standard Queries (top-3 results):")
|
|
for q in queries:
|
|
res = col.query(query_texts=[q], n_results=3)
|
|
top_doc = res["documents"][0][0]
|
|
top_meta = res["metadatas"][0][0]
|
|
top_dist = res["distances"][0][0]
|
|
print(f"\nQuery: '{q}'")
|
|
print(f" -> Top match: [{top_meta['sumber']} | chunk {top_meta['chunk']}] (distance: {top_dist:.4f})")
|
|
print(f" -> Snippet: {top_doc[:120].replace(chr(10), ' ')}...")
|
|
|
|
# 2 paraphrased queries (different wording, semantic match)
|
|
paraphrased = [
|
|
("Can I log in using a free cafe internet connection?", "Remote work / VPN on public Wi-Fi"),
|
|
("What is the penalty if I take a second job or outside gig?", "Conflict of interest / outside employment")
|
|
]
|
|
|
|
print("\n[2] Testing 2 Paraphrased Queries (Semantic vs Keyword):")
|
|
for q, intent in paraphrased:
|
|
res = col.query(query_texts=[q], n_results=1)
|
|
top_doc = res["documents"][0][0]
|
|
top_meta = res["metadatas"][0][0]
|
|
top_dist = res["distances"][0][0]
|
|
print(f"\nParaphrased: '{q}' (Target: {intent})")
|
|
print(f" -> Retrieved: [{top_meta['sumber']} | chunk {top_meta['chunk']}] (distance: {top_dist:.4f})")
|
|
print(f" -> Snippet: {top_doc[:140].replace(chr(10), ' ')}...")
|
|
|
|
# ---------------------------------------------------------
|
|
# Lab 3: Evaluation (Hit@k)
|
|
# ---------------------------------------------------------
|
|
# 15 targeted (query, required_keyword) pairs across all 3 PDFs
|
|
TEST_SET = [
|
|
("What is the minimum password length required?", "12 characters"),
|
|
("How often must employees update their passwords?", "90 days"),
|
|
("Is multi-factor authentication mandatory?", "MFA"),
|
|
("Where should suspected security incidents be reported?", "security@nexusglobal.dummy"),
|
|
("What is the shortcut to lock the screen on Windows?", "Win + L"),
|
|
("What are the mandatory core hours for remote employees?", "10:00 AM to 3:00 PM"),
|
|
("How quickly must equipment hardware issues be reported?", "24 hours"),
|
|
("What is required before connecting to public Wi-Fi?", "VPN"),
|
|
("Who decides if an employee is eligible for remote work?", "department head"),
|
|
("What should be done with sensitive paper documents?", "shredded"),
|
|
("What outside business activities must be disclosed to HR?", "outside employment"),
|
|
("What is the zero-tolerance policy regarding discrimination?", "zero-tolerance"),
|
|
("What are examples of intellectual property assets?", "client lists"),
|
|
("What disciplinary actions can follow policy violations?", "termination"),
|
|
("Where must electronic corporate data be stored?", "encrypted company servers")
|
|
]
|
|
|
|
def hit_at_k(col, test_set, k: int) -> float:
|
|
"""Computes Hit@k: proportion of queries where answer keyword appears in top-k chunks."""
|
|
hits = 0
|
|
for query, keyword in test_set:
|
|
docs = col.query(query_texts=[query], n_results=k)["documents"][0]
|
|
if any(keyword.lower() in d.lower() for d in docs):
|
|
hits += 1
|
|
return hits / len(test_set)
|
|
|
|
def run_lab3(col_500, col_1000):
|
|
print("\n" + "=" * 60)
|
|
print("LAB 3: MEASURE HIT@K EVALUATION")
|
|
print("=" * 60)
|
|
|
|
print(f"Evaluating {len(TEST_SET)} test queries across two chunk size collections:\n")
|
|
print(f"{'Collection':<25} | {'Hit@1':<8} | {'Hit@3':<8} | {'Hit@5':<8}")
|
|
print("-" * 55)
|
|
|
|
for name, col in [("Collection (size=500)", col_500), ("Collection (size=1000)", col_1000)]:
|
|
h1 = hit_at_k(col, TEST_SET, k=1)
|
|
h3 = hit_at_k(col, TEST_SET, k=3)
|
|
h5 = hit_at_k(col, TEST_SET, k=5)
|
|
print(f"{name:<25} | {h1:<8.2f} | {h3:<8.2f} | {h5:<8.2f}")
|
|
|
|
print("\nDetailed Miss Analysis for size=500 at k=1:")
|
|
for query, keyword in TEST_SET:
|
|
docs = col_500.query(query_texts=[query], n_results=1)["documents"][0]
|
|
if not any(keyword.lower() in d.lower() for d in docs):
|
|
print(f" - Missed: '{query}' (Target keyword: '{keyword}')")
|
|
print(f" Top retrieved snippet: {docs[0][:90].replace(chr(10), ' ')}...")
|
|
|
|
# ---------------------------------------------------------
|
|
# Main Execution
|
|
# ---------------------------------------------------------
|
|
def main():
|
|
docs_dir = Path("dokumen")
|
|
pdf_files = list(docs_dir.glob("*.pdf"))
|
|
if not pdf_files:
|
|
print("Error: No PDF files found in 'dokumen/' folder.")
|
|
return
|
|
|
|
# Initialize Embedding Function (runs locally)
|
|
print("Loading multilingual embedding model (paraphrase-multilingual-MiniLM-L12-v2)...")
|
|
emb_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
|
|
model_name="paraphrase-multilingual-MiniLM-L12-v2"
|
|
)
|
|
|
|
client = chromadb.PersistentClient(path="./chroma_db")
|
|
|
|
# Lab 1
|
|
run_lab1(pdf_files[0])
|
|
|
|
# Index two collections for comparison (size 500 & size 1000)
|
|
print("\nIndexing documents into ChromaDB collections...")
|
|
col_500 = setup_collection(client, "pedoman_500", emb_fn, chunk_size=500, chunk_overlap=50, docs_dir=docs_dir)
|
|
col_1000 = setup_collection(client, "pedoman_1000", emb_fn, chunk_size=1000, chunk_overlap=100, docs_dir=docs_dir)
|
|
print("Indexed collections 'pedoman_500' and 'pedoman_1000' successfully.")
|
|
|
|
# Lab 2 (using col_500)
|
|
run_lab2(col_500)
|
|
|
|
# Lab 3
|
|
run_lab3(col_500, col_1000)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|