अध्याय ४ · खण्ड III · 22 मिनेट
नेपाली कागजातहरूमाथि semantic search
एक अनुभवजन्य खण्ड। हामी वास्तविक नेपाली कागजातहरू (सरकारी नियमहरू, समाचार लेखहरू) लिन्छौँ, तिनलाई sensibly chunk गर्छौँ, तिनलाई embed गर्छौँ, र तिनलाई semantically search-able बनाउँछौँ। नेपाली-भाषा retrieval पाइपलाइनको लागि इन्जिनियरिङ अभ्यास।
खेलौना FAQ मा semantic search काम गर्छ। यसलाई वास्तविक नेपाली कागजातहरूमा — PDF हरू, बहु-पृष्ठ नियमावली दस्तावेजहरू, समाचार लेखहरू — प्राप्त गर्न केही अतिरिक्त इन्जिनियरिङ चाहिन्छ। यो खण्ड पूर्ण pipeline हो: ingest, clean, chunk, embed, search। हरेक चरण खण्डको एउटा भाग हो, र हरेकको लागि Nepali-specific तर्क छ। अन्त्यमा, तपाईंसँग वास्तविक कागजातहरूमाथि working semantic search system हुनेछ।
Pipeline
Real world मा semantic search भनेको खाली embed(text) भन्दा धेरै हो। पूर्ण pipeline पाँच चरणहरूमा हो:
1. Ingest — PDF वा वेब पृष्ठहरूबाट पाठ पाउनुहोस्।
2. Clean — headers, footers, page numbers हटाउनुहोस्।
3. Chunk — कागजातहरूलाई search-able segments मा तोड्नुहोस्।
4. Embed — प्रत्येक chunk को embedding compute गर्नुहोस्।
5. Search — query embed गर्नुहोस्, top-K chunks फर्काउनुहोस्।
चरण 1-4 एकपटक बैच work हो। चरण 5 प्रति-query, real-time मा हुन्छ।
चरण 1 — Ingest (PDFs)
धेरै नेपाली सरकारी कागजातहरू PDFs का रूपमा आउँछन्। तिनीहरूबाट पाठ बाहिर निकाल्ने standard tool pypdf हो:
pip install pypdf
from pypdf import PdfReader
def extract_pdf_text(path: str) -> str:
reader = PdfReader(path)
parts = []
for page in reader.pages:
parts.append(page.extract_text())
return "\n".join(parts)
text = extract_pdf_text("nagarita-niyamawali-2079.pdf")
print(f"Length: {len(text)} characters")
print(text[:500])
नेपाली PDFs का लागि दुई सामान्य समस्याहरू:
- Scanned PDFs। केही सरकारी PDFs हरू scanned images हुन्, real text होइनन्।
pypdfले खाली string फर्काउँछ। समाधान: OCR। नेपाली Devanagari को लागि,tesseractएउटाneplanguage pack सँग सबैभन्दा राम्रो open-source विकल्प हो, तर सेटअप गैर-तुच्छ छ। एक pragmatic विकल्प: तपाईंको PDFs Google Cloud Vision API मा पठाउनुहोस्, जुन आधुनिक नेपाली मा राम्रोसँग काम गर्छ। - खराब-encoded fonts। कहिलेकाहीँ पुराना PDFs ले नेपाली text लाई preet जस्तै legacy fonts मा encode गर्छन्, र
pypdfले tofu वा gibberish outputs गर्छ। समाधान: तिनीहरूलाई फेरि प्रकाशन गरिएको Unicode संस्करणसँग बदल्ने प्रयास गर्नुहोस्।
आजका लागि, हामी pypdf मार्फत निकाल्न सकिने आधुनिक Unicode PDFs छन् भनेर मान्नेछौँ।
चरण 2 — Clean
Extracted पाठमा प्रायः repeated headers, footers, र page numbers हुन्छन्, जुन embedding गुणस्तर बिगार्छ। एक साधारण cleaning function:
import re
def clean_text(text: str) -> str:
# Collapse whitespace
text = re.sub(r"\s+", " ", text)
# Drop page numbers on their own line
text = re.sub(r"पृष्ठ \d+", "", text)
text = re.sub(r"Page \d+", "", text)
# Drop common headers/footers (customise per document)
text = text.replace("नेपाल सरकार गृह मन्त्रालय", "")
text = text.replace("© २०७९", "")
return text.strip()
Cleaning rules प्रति-कागजात hand-crafted हुन्छन्। कागजात collection review गर्नुहोस्, दोहोर्याइएको noise लाई note गर्नुहोस्, र यसलाई हटाउनुहोस्। दस मिनेट cleaning ले search गुणस्तर सुधार गर्न सक्छ।
चरण 3 — Chunking
यहाँ सबैभन्दा महत्त्वपूर्ण design निर्णय आउँछ। एकल embedding मा तपाईं कति पाठ राख्नुहुन्छ?
बहुत ठूलो (जस्तै 50-पृष्ठ PDF एक embedding): embedding ले specific सामग्रीको sense गुमाउँछ। एक query ले "maternity leave" लाई पूर्ण HR policy PDF सँग match गर्नेछ, तर सान्दर्भिक अनुच्छेद कहाँ छ थाहा हुँदैन।
बहुत सानो (जस्तै 3 शब्द प्रति chunk): धेरै noise, र तपाईंले सन्दर्भ गुमाउनु हुनेछ। "नागरिकता" जस्तो chunk मात्रैले तपाईंको खोजी मा केही थप्दैन।
दायाँ आकार: 300-800 शब्दहरू प्रति chunk। यो सामान्यतया एक अनुच्छेदको कवरेज हो, जुन embedding gets मा जति यसले rich contextual signal देखाउँछ र specific रहन्छ।
एक अनुच्छेद-आधारित chunker:
def chunk_by_paragraphs(text: str, target_size: int = 500) -> list[str]:
"""
Split text into chunks of ~target_size words, breaking at paragraphs.
"""
paragraphs = [p.strip() for p in text.split("\n") if p.strip()]
chunks = []
current = []
current_size = 0
for para in paragraphs:
word_count = len(para.split())
if current_size + word_count > target_size and current:
chunks.append(" ".join(current))
current = [para]
current_size = word_count
else:
current.append(para)
current_size += word_count
if current:
chunks.append(" ".join(current))
return chunks
नेपाली-specific सूचना: नेपाली शब्दहरू space-separated छन्, त्यसैले text.split() word count सन्तोषजनक रूपमा फर्काउँछ। थप-गुणस्तर word tokenisation लाई (indic-nlp-library) chunk बाउन्ड्रीहरू निर्धारण गर्न आवश्यक पर्दैन।
Overlap — एक चतुर सुधार:
def chunk_with_overlap(text: str, chunk_size: int = 500, overlap: int = 100) -> list[str]:
"""
Split into chunks with overlap between adjacent chunks.
"""
words = text.split()
chunks = []
step = chunk_size - overlap
for i in range(0, len(words), step):
chunk = " ".join(words[i:i + chunk_size])
if len(chunk.split()) < 50: # skip tiny trailing chunks
continue
chunks.append(chunk)
return chunks
एउटा chunk को अन्त्य र अर्कोको सुरुवात बीच 100-शब्द overlap ले सुनिश्चित गर्छ कि chunk बाउन्ड्रीहरू भर फैलिएको सन्दर्भ पूर्णतया गुम्दैन। यो production RAG मा standard प्रविधि हो।
चरण 4 — Embed (batched)
Chunks लाई batches मा embed गर्नुहोस् — यो per-request overhead घटाउँछ र काम छिटो हुन्छ:
def embed_chunks(chunks: list[str], batch_size: int = 100) -> list[list[float]]:
all_vectors = []
for i in range(0, len(chunks), batch_size):
batch = chunks[i:i + batch_size]
response = client.embeddings.create(
model="text-embedding-3-small",
input=batch,
)
all_vectors.extend([d.embedding for d in response.data])
print(f"Embedded {i + len(batch)} / {len(chunks)}")
return all_vectors
Realistic scale: 100 सरकारी PDFs × औसत 20 chunks per PDF = 2,000 chunks। 500 words per chunk × 2 tokens per word × 2000 chunks = ~2M tokens। लागत: text-embedding-3-small मा $0.04। Runtime: 1 minute भन्दा कम, batched मा।
एक पूर्ण उदाहरण — नागरिक-सेवा PDFs
सबैलाई एकैसाथ जोड्नुहोस्:
import os
from pathlib import Path
from dotenv import load_dotenv
from openai import OpenAI
import numpy as np
load_dotenv()
client = OpenAI()
def embed_chunks(chunks: list[str], batch_size: int = 100) -> list[list[float]]:
all_vectors = []
for i in range(0, len(chunks), batch_size):
batch = chunks[i:i + batch_size]
response = client.embeddings.create(
model="text-embedding-3-small",
input=batch,
)
all_vectors.extend([d.embedding for d in response.data])
return all_vectors
def build_index(pdf_paths: list[str]) -> list[dict]:
"""Build a search index over a set of PDFs."""
all_chunks = []
for path in pdf_paths:
text = extract_pdf_text(path)
text = clean_text(text)
doc_name = Path(path).stem
for i, chunk in enumerate(chunk_with_overlap(text)):
all_chunks.append({
"doc": doc_name,
"chunk_id": f"{doc_name}#{i}",
"text": chunk,
})
print(f"Total chunks: {len(all_chunks)}")
vectors = embed_chunks([c["text"] for c in all_chunks])
for chunk, vector in zip(all_chunks, vectors):
chunk["vector"] = vector
return all_chunks
def cosine(v1: list[float], v2: list[float]) -> float:
a, b = np.array(v1), np.array(v2)
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
def search_index(index: list[dict], query: str, top_k: int = 5) -> list[dict]:
q_vec = client.embeddings.create(
model="text-embedding-3-small",
input=query,
).data[0].embedding
scored = []
for chunk in index:
score = cosine(q_vec, chunk["vector"])
scored.append({
"chunk_id": chunk["chunk_id"],
"doc": chunk["doc"],
"score": score,
"text": chunk["text"],
})
scored.sort(key=lambda x: x["score"], reverse=True)
return scored[:top_k]
# Use it
pdfs = [
"docs/nagarita-niyamawali.pdf",
"docs/passport-guidelines.pdf",
"docs/driving-license-manual.pdf",
"docs/vehicle-registration-rules.pdf",
]
index = build_index(pdfs)
# Try queries
queries = [
"मैले नागरिकता कसरी नविकरण गर्ने?",
"पासपोर्ट सङ्ग नयाँ पसपोर्टको साथ पुरानो एकको साथ के गर्ने?",
"मलाई मेरो ड्राइभिङ लाइसेन्स कसरी नविकरण गर्ने?",
]
for q in queries:
print(f"\n=== {q}")
for hit in search_index(index, q, top_k=3):
print(f" [{hit['score']:.3f}] {hit['doc']} chunk {hit['chunk_id']}")
print(f" {hit['text'][:120]}...")
यसले वास्तवमा काम गर्छ। दस मिनेटको PDF ingestion + embedding एक system उत्पादन गर्छ जसले query लिन्छ र तपाईंलाई सबैभन्दा सान्दर्भिक अनुच्छेदहरू फर्काउँछ।
नेपाली-विशिष्ट अवलोकनहरू
आफ्नो पहिलो नेपाली-कागजात semantic search सिस्टम बनाउँदा:
- Devanagari र Roman-नेपाली मिलाइएका। केही queries
"nagarita kasari nabikaran garne"(Romanised नेपाली) मा आउनेछन् जब कागजातहरू Devanagari मा हुन्छन्।text-embedding-3-smallले यसलाई पर्याप्त राम्ररी सम्हाल्छ — cross-script similarity प्राय: 0.5-0.7 दायरामा हुन्छ, जुन काम गर्न पर्याप्त हो। यो critical छ भने, एउटा preprocessing चरण थप्नुहोस् जसले नेपालीलाई transliterate गर्छ (जस्तै,indic-transliterationlibrary प्रयोग गरेर) पहिले। - Nepali विराम चिह्न। डोरी
।(Devanagari पूर्ण विराम) र.(Latin) दुबै वाक्यको अन्त्य मार्क गर्छन्। सामान्यतया, तपाईंलाई pre-processing को लागि तिनीहरूलाई सम्हाल्नु पर्दैन — embedding मोडेल दुबै सम्हाल्न सक्छ — तर यदि तपाईं sentence-level chunking गर्दै हुनुहुन्छ भने, दुबैमा split गर्नुहोस्। - ट्रान्सलिट variants। Kathmandu
काठमाडौं,काठमाडौँ(म over चन्द्रबिन्दु), रकाठमाण्डौँ(retroflex ण)। सबै legitimate spellings हुन्; तिनको embedding-space distances ठिक साना हुन्छन्। तर यदि तपाईंले exact match check गर्न आवश्यक भएमा, unicode normalize गर्नुहोस्।
लागतको एउटा सूचना
100 कागजात × 20 chunks × 500 words / chunk = 1M शब्दहरू ≈ 2M tokens ≈ $0.04 embedding। खोज: प्रति query, 1 embedding = ~500 tokens = $0.00001। एक हजार queries को लागि, $0.01।
यसले तपाईंलाई मजाक होइन। नेपाली कागजातहरूको मध्यम collection मा semantic search कहिलेकाहीं एक कप coffee भन्दा कम खर्च गर्दछ per month, एक पटक तपाईंले यसलाई अप र चलिरहेको छ। लागत kingpin नहर, स्केल गर्न फ्री हो।
अब के आउँछ
तपाईंले Course 04 को खोज-र-प्रस्तुत भाग समाप्त गर्नुभयो। तपाईंसँग एक बलियो नेपाली-भाषा retrieval system छ। Chapter 5 अन्तिम piece राख्छ: retrieval-augmented generation (RAG), जहाँ retrieved कागजातहरू (यो chapter बाट) एक LLM (Chapters 1-3 बाट) लाई सान्दर्भिक, ग्राउन्डेड जवाफहरू उत्पादन गर्न पास हुन्छन्। यो modern LLM apps को standard पाठ्यक्रम हो।