ailiteracynepal 🇳🇵
पाठ आकार

अध्याय ४ · खण्ड 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 एउटा nep language 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-transliteration library प्रयोग गरेर) पहिले।
  • 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 पाठ्यक्रम हो।