Asosiy qismga o'tish

Chunking Strategiyalari

4-bob 35 kod bloki 6552 so'z

Nima uchun chunking kerak?

Kodlar: Chunk o'lchamini empirik tanlash 4-bob · o'lchang, taxmin qilmang · ~80 qator

1. "512 yaxshi" degan gap — taxmin

Internetda hamma 512 yoki 1000 token deydi. To'g'ri javob: sizning hujjatlaringizga bog'liq. Shartnoma matni, blog post va texnik dokumentatsiya — uchtasi uchun uch xil o'lcham kerak.

Yagona ishonchli yo'l — o'lchash. Bu notebook shuni qiladi.

2. Test savollari

Har bir o'lcham uchun bir xil savollar berilishi shart — aks holda taqqoslash ma'nosiz. 15–20 ta real foydalanuvchi savoli yetarli; bu yerda qisqartirilgan ro'yxat.

python9 qator
TEST_QUESTIONS = [
    "Ucell uchun qanday loyiha qilingan?",
    "Kompaniya qachon tashkil topgan?",
    "Bank sohasida qanday tajriba bor?",
    "Loyihalar necha oyda yakunlanadi?",
    "Qaysi texnologiyalar ishlatiladi?",
]

CANDIDATE_SIZES = [400, 600, 800, 1200, 1600]

3. O'lchanadigan uchta narsa

  • Faithfulness — javob kontekstga sodiqmi (LLM baholaydi)
  • Chunk soni — indeks hajmi va embedding xarajati
  • Latency — qidiruv va generation vaqti

Kichik chunk → aniqroq, lekin kontekst bo'linib ketadi. Katta chunk → kontekst to'liq, lekin shovqin ko'p va token qimmat.

python12 qator
JUDGE = (
    "Quyidagi javob berilgan kontekstga asoslanganmi? "
    "Faqat bitta raqam qaytar: 1 (ha, to'liq asoslangan) yoki 0 (yo'q). "
    "Boshqa hech narsa yozma.\n\nKontekst:\n{ctx}\n\nJavob:\n{ans}"
)

def judge_faithfulness(context: str, answer: str) -> int:
    out = _ollama_chat(
        [{"role": "user", "content": JUDGE.format(ctx=context, ans=answer)}],
        temperature=0.0, max_tokens=5,
    )
    return 1 if "1" in (out or "") else 0

4. Benchmark sikli

Har bir o'lcham uchun: qayta indekslash → savollarni o'tkazish → ballarni yig'ish. Bu sekin ish — 5 o'lcham × 5 savol = 25 to'liq pipeline, plus har safar butun korpusni qayta embedding qilish.

python36 qator
import time

def benchmark_chunk_size(session, body: str, size: int) -> dict:
    session.execute(text("TRUNCATE chunks"))
    chunks = chunk_text(body, size=size, overlap=int(size * 0.12))
    for i, ch in enumerate(chunks):
        emb, err = generate_query_embedding(ch)
        if not err and emb:
            session.execute(text(INSERT_SQL),
                            {"t": "test", "c": ch, "i": i, "e": str(emb)})
    session.commit()

    scores, latencies = [], []
    for q in TEST_QUESTIONS:
        t0 = time.time()
        emb, _ = generate_query_embedding(q)
        found = retrieve_chunks(session, emb, 5)
        context = "\n\n".join(c["content"] for c in found)
        answer = ask(session, q)
        latencies.append(time.time() - t0)
        scores.append(judge_faithfulness(context, answer))

    return {
        "size": size,
        "chunks": len(chunks),
        "faithfulness": sum(scores) / len(scores),
        "latency": sum(latencies) / len(latencies),
    }


body = open("nihol_corpus.txt", encoding="utf-8").read()
results = [benchmark_chunk_size(session, body, s) for s in CANDIDATE_SIZES]

print(f"{'size':<8}{'chunks':<10}{'faithful':<12}{'latency':<10}")
for r in results:
    print(f"{r['size']:<8}{r['chunks']:<10}{r['faithfulness']:<12.2f}{r['latency']:<10.2f}")
Natija
size    chunks    faithful    latency
400     892       0.60        1.84
600     601       0.80        1.91
800     451       1.00        2.03
1200    302       0.80        2.44
1600    228       0.60        2.98

5. Natijani o'qish

Natija qo'ng'iroq shaklida — bu normal va kutilgan. 400 da bitta fakt ikki chunk ga bo'linib ketgan; 1600 da har bir chunk ichida keraksiz matn ko'paygan.

Diqqat qiling: 1200 dan 1600 ga o'tganda latency 22% oshdi, sifat esa tushdi. Katta chunk hech qachon "xavfsiz tanlov" emas.

Amalda: bu testni kalendar bo'yicha emas, korpus tarkibi o'zgarganda qayta o'tkazing. Yangi turdagi hujjat qo'shsangiz (masalan PDF shartnomalar), optimal o'lcham siljiydi.

Ogohlantirish: TRUNCATE chunks — bu production bazada emas, alohida test bazasida ishlatiladi.

RAG tizimida ma'lumotlarni to'g'ridan-to'g'ri LLM ga berish mumkin emas. Buning uchta asosiy sababi bor:

Token limitlari. Har bir embedding model cheklangan miqdordagi tokenni qayta ishlaydi. OpenAI ning text-embedding-3-small modeli 8K token qabul qiladi. qwen3-embedding modeli 32K tokengacha ishlaydi. Agar hujjat 100 000 token bo'lsa, uni to'liq embed qilish imkonsiz.

Aniqlik (precision). Kichik chunk = aniqroq qidiruv. Agar siz butun kitobni bitta vector sifatida saqlasangiz, "Nihol kompaniyasi qaysi banklar bilan ishlagan?" degan savolga javob topish juda qiyin. Lekin agar har bir loyiha alohida chunk bo'lsa, cosine similarity aniq natija beradi.

Context window. LLM ham cheklangan context window ga ega. GPT-4o 128K tokenni qabul qiladi, lekin 100 sahifalik hujjatni to'liq berish samarasiz va qimmat. Faqat eng mos 5-10 ta chunk berish yaxshiroq.

Kitob mundarijasi analogiyasi

Chunking ni kitob mundarijasi sifatida tasavvur qiling:

Yomon mundarija -- butun kitob uchun bitta yozuv: "Kitob: 1-500 betlar". Biror narsani topish uchun butun kitobni o'qish kerak.

Yaxshi mundarija -- har bir bo'lim uchun alohida yozuv: "3.2. Bank loyihalari: 45-bet", "3.3. Telekom loyihalari: 52-bet". Kerakli ma'lumotni darhol topish mumkin.

RAG tizimida chunk = mundarijadagi bitta yozuv. Qanchalik aniq bo'lsa, qidiruv shunchalik samarali.

1-strategiya: Fixed-size chunking

Eng oddiy usul -- matnni har N ta token (yoki belgi) dan kesingiz.

python15 qator
def fixed_size_chunk(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
    words = text.split()
    chunks = []
    start = 0
    while start < len(words):
        end = start + chunk_size
        chunk = " ".join(words[start:end])
        chunks.append(chunk)
        start = end - overlap
    return chunks

# Misol
text = "Nihol kompaniyasi 1997 yilda tashkil topgan. " * 200
chunks = fixed_size_chunk(text, chunk_size=100, overlap=20)
print(f"Jami: {len(chunks)} ta chunk")

Afzalligi: Sodda, tez, barcha matnlar uchun ishlaydi.

Muammosi: Gaplarni o'rtasidan kesib qo'yadi. Bir chunk "Nihol kompaniyasi Toshkent shahrida joylashgan va" bilan tugaydi, keyingisi "turli xil IT xizmatlarini ko'rsatadi" bilan boshlanadi. Context yo'qoladi.

2-strategiya: Paragraph-based chunking (bizning yondashuvimiz)

Matnni \n\n (bo'sh satr) bo'yicha bo'lamiz. Bu tabiiy chegaralar -- odatda paragraflar, ro'yxat elementlari yoki bo'limlar orasida bo'sh satr bo'ladi.

Nihol loyihasida aynan shu yondashuvni qo'llaymiz. Bizning chunker.py faylidagi asosiy sozlamalar:

python2 qator
MAX_TOKENS = 38000       # Har bir chunk uchun maksimal token soni
CHARS_PER_TOKEN = 3      # Taxminiy: 3 belgi = 1 token (kirill + o'zbek uchun)

Nima uchun MAX_TOKENS = 38000? Chunki qwen3-embedding:8b modeli 32K tokenni qabul qiladi, lekin biz xavfsizlik uchun biroz kattaroq chegarani qo'ydik (belgilar bo'yicha hisoblash taxminiy). Real tokenizatsiya har doim CHARS_PER_TOKEN dan farq qiladi.

Har bir chunk rubrika nomi va sahifa nomi bilan prefix oladi. Bu metadata enrichment ning eng oddiy shakli:

python15 qator
# chunker.py dan olingan haqiqiy kod
def process_content(session: Session, content: LvExtractedContent) -> list[LvContentChunks]:
    if not content.content_text:
        return []

    rubric = content.page.rubric_name or "" if content.page else ""
    page_name = content.page.name or "" if content.page else ""
    prefix_parts = [p for p in [rubric, page_name] if p]
    prefix = "\n\n".join(prefix_parts)

    parts = prefix_parts + [content.content_text]
    full_text = "\n\n".join(parts)

    chunks_text = _split_if_needed(full_text, prefix)
    # ... har bir chunk uchun LvContentChunks yaratiladi

Masalan, agar rubrika "IT loyihalar" va sahifa nomi "Toshkent metro loyihasi" bo'lsa, chunk matni shunday boshlanadi:

matn5 qator
IT loyihalar

Toshkent metro loyihasi

Loyiha doirasida 15 ta stansiyada tarmoq infratuzilmasi...

Bu prefix LLM ga kontekst beradi -- chunk qaysi rubrikaga tegishli ekanini darhol biladi.

Asosiy bo'lish funksiyasi: _split_if_needed

Bu funksiya bizning chunker ning yadrosi. Agar matn MAX_TOKENS dan kichik bo'lsa, bo'lmaydi. Aks holda \n\n bo'yicha paragraf-paragraf bo'ladi:

python29 qator
# chunker.py dan haqiqiy kod
def _estimate_tokens(text: str) -> int:
    return len(text) // CHARS_PER_TOKEN

def _split_if_needed(full_text: str, annotation: str) -> list[str]:
    if _estimate_tokens(full_text) <= MAX_TOKENS:
        return [full_text]

    paragraphs = full_text.split("\n\n")
    prefix = f"{annotation}\n\n" if annotation else ""
    max_chunk_tokens = MAX_TOKENS - _estimate_tokens(prefix)

    chunks = []
    current = prefix

    for paragraph in paragraphs:
        if paragraph.strip() == annotation.strip():
            continue
        candidate = current + paragraph + "\n\n"
        if _estimate_tokens(candidate) > MAX_TOKENS and current != prefix:
            chunks.append(current.rstrip())
            current = prefix + paragraph + "\n\n"
        else:
            current = candidate

    if current.rstrip() and current.rstrip() != prefix.rstrip():
        chunks.append(current.rstrip())

    return chunks if chunks else [full_text]

Algoritmning ishlash tartibi: 1. Matnni \n\n bo'yicha paragrafga ajratadi. 2. Har bir paragrafni joriy chunk ga qo'shishga harakat qiladi. 3. Agar chunk MAX_TOKENS dan oshsa, joriy chunk ni saqlaydi va yangi chunk boshlaydi. 4. Har bir yangi chunk prefix (rubrika + sahifa nomi) bilan boshlanadi. 5. Annotation dublikatlarini o'tkazib yuboradi (paragraph.strip() == annotation.strip()).

Nihol loyihasida ~218 sahifadan ~377 ta chunk mavjud. Ko'pchilik sahifalar MAX_TOKENS dan kichik bo'lgani uchun bo'linmaydi -- ya'ni bitta sahifa = bitta chunk.

3-strategiya: Recursive character splitting

LangChain kutubxonasining default usuli. Bir nechta separator ni ketma-ket sinab ko'radi:

python29 qator
def recursive_split(text: str, chunk_size: int = 1000, separators: list[str] = None) -> list[str]:
    if separators is None:
        separators = ["\n\n", "\n", " ", ""]

    separator = separators[0]
    remaining_separators = separators[1:]

    splits = text.split(separator)
    chunks = []
    current = ""

    for split in splits:
        candidate = current + separator + split if current else split
        if len(candidate) <= chunk_size:
            current = candidate
        else:
            if current:
                chunks.append(current)
            if len(split) > chunk_size and remaining_separators:
                # Rekursiv: keyingi separator bilan sinab ko'r
                sub_chunks = recursive_split(split, chunk_size, remaining_separators)
                chunks.extend(sub_chunks)
                current = ""
            else:
                current = split

    if current:
        chunks.append(current)
    return chunks

Afzalligi: Avval \n\n bo'yicha sinaydi (paragraflar). Agar paragraf juda katta bo'lsa, \n bo'yicha (satrlar). Agar satr ham katta bo'lsa, bo'sh joy bo'yicha (so'zlar). Oxirgi variant -- belgilar bo'yicha. Bu usul matnning tabiiy tuzilishini iloji boricha saqlaydi.

Kamchiligi: Chuqur rekursiya katta matnlarda sekin ishlashi mumkin. Overlap qo'shish qo'shimcha mantiq talab qiladi.

4-strategiya: Semantic chunking

Kodlar: Semantic Chunking — ma'no chegarasidan kesish 4-bob · embedding asosida bo'lish · ~60 qator

1. Muammo — 800 belgi ma'noni bilmaydi

Fixed-size chunking matnni ko'r-ko'rona kesadi. Natijada bitta mavzu ikkiga bo'linadi, yoki bitta chunk ichiga uchta aloqasiz mavzu tushadi.

Semantic chunking boshqacha savol beradi: "bu ikki gap bir mavzu haqidami?" Agar yo'q bo'lsa — aynan shu yerdan kesamiz.

2. G'oya — qo'shni gaplar orasidagi masofa

Har bir gapni embedding qilamiz. Keyin ketma-ket gaplar orasidagi cosine masofani hisoblaymiz. Masofa keskin oshgan joy — mavzu o'zgargan joy.

Chegara sifatida percentile ishlatamiz: masofalarning yuqori 20% i — kesish nuqtalari.

python7 qator
import re
import numpy as np

def split_sentences(body: str) -> list[str]:
    body = " ".join(body.split())
    parts = re.split(r"(?<=[.!?])\s+", body)
    return [p.strip() for p in parts if len(p.strip()) > 15]
python31 qator
def cosine(a: np.ndarray, b: np.ndarray) -> float:
    return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))


def semantic_chunk(body: str, percentile: int = 80, max_chars: int = 1500) -> list[str]:
    sentences = split_sentences(body)
    if len(sentences) < 3:
        return sentences

    vectors = []
    for s in sentences:
        emb, err = generate_query_embedding(s)
        vectors.append(np.array(emb) if not err and emb else None)

    distances = []
    for i in range(len(sentences) - 1):
        a, b = vectors[i], vectors[i + 1]
        distances.append(1 - cosine(a, b) if a is not None and b is not None else 0.0)

    threshold = np.percentile(distances, percentile)

    chunks, current = [], [sentences[0]]
    for i, d in enumerate(distances):
        too_long = sum(len(x) for x in current) > max_chars
        if d > threshold or too_long:
            chunks.append(" ".join(current))
            current = []
        current.append(sentences[i + 1])
    if current:
        chunks.append(" ".join(current))
    return chunks

3. Ishlatish va tekshirish

Muhim: max_chars cheklovi majburiy. Bir mavzu haqidagi uzun matn bitta ulkan chunk ga aylanib qolmasligi kerak.

python10 qator
body = open("nihol_corpus.txt", encoding="utf-8").read()
chunks = semantic_chunk(body, percentile=80)

print(f"Gaplar soni      : {len(split_sentences(body))}")
print(f"Chunk soni       : {len(chunks)}")
print(f"O'rtacha uzunlik : {sum(len(c) for c in chunks) // len(chunks)} belgi\n")

for c in chunks[:3]:
    print("-" * 70)
    print(c[:180], "...")
Natija
Gaplar soni      : 412
Chunk soni       : 96
O'rtacha uzunlik : 734 belgi

----------------------------------------------------------------------
Nihol kompaniyasi 2015-yilda tashkil topgan. Kompaniya dastlab veb-sayt
ishlab chiqish bilan shug'ullangan. Keyinchalik yo'nalish kengaytirildi ...
----------------------------------------------------------------------
Ucell uchun billing tizimi modernizatsiyasi 2023-yilda boshlangan. Loyiha
doirasida abonent hisob-kitob moduli qayta yozildi. To'lov shlyuzi ...
----------------------------------------------------------------------
Bank sohasida kompaniya uchta yirik loyihani amalga oshirgan. Ipoteka
arizalarini avtomatlashtirish tizimi joriy etildi ...

4. Narxi qancha?

Bu usul qimmat: har bir gap uchun alohida embedding chaqiruvi. 412 gap = 412 ta embedding, fixed-size da esa atigi 96 ta.

Lekin bu bir martalik indekslash xarajati. Qidiruv tezligi umuman o'zgarmaydi.

Qachon arziydi: hujjatlar mavzular bo'yicha aralash bo'lsa (yillik hisobot, ko'p bo'limli qo'llanma). Bir mavzudagi bir xil uslubdagi matnda (masalan FAQ) — fixed-size bilan farq deyarli yo'q, ortiqcha pul sarflamang.

Kombinatsiya: eng yaxshi natija — avval paragraph-based bo'lish, keyin juda uzun paragraflarni semantic chunking bilan ichidan bo'lish.

Eng aqlli, lekin eng sekin usul. Har bir gapni embedding qiladi va mavzu o'zgarishini aniqlaydi:

python20 qator
# Konseptual kod (to'liq implementatsiya emas)
def semantic_chunk(text: str, model, threshold: float = 0.5) -> list[str]:
    sentences = text.split(". ")
    embeddings = [model.embed(s) for s in sentences]

    chunks = []
    current_chunk = [sentences[0]]

    for i in range(1, len(sentences)):
        similarity = cosine_similarity(embeddings[i-1], embeddings[i])
        if similarity < threshold:
            # Mavzu o'zgardi -- yangi chunk boshla
            chunks.append(". ".join(current_chunk))
            current_chunk = [sentences[i]]
        else:
            current_chunk.append(sentences[i])

    if current_chunk:
        chunks.append(". ".join(current_chunk))
    return chunks

Afzalligi: Mavzu chegaralarini aniq aniqlaydi. "Bank loyihalari" haqidagi gaplar bitta chunk da, "Telekom loyihalari" haqidagi gaplar boshqa chunk da.

Kamchiligi: Har bir gap uchun embedding hisoblash kerak -- bu vaqt va resurs talab qiladi. 1000 gapli hujjat uchun 1000 ta embedding so'rov. Nihol loyihasida ~377 ta chunk uchun bu haddan tashqari.

5-strategiya: Sentence-based chunking

Matnni nuqta bo'yicha gaplarga ajratadi:

python9 qator
import re

def sentence_chunk(text: str, sentences_per_chunk: int = 5) -> list[str]:
    sentences = re.split(r'(?<=[.!?])\s+', text)
    chunks = []
    for i in range(0, len(sentences), sentences_per_chunk):
        chunk = " ".join(sentences[i:i + sentences_per_chunk])
        chunks.append(chunk)
    return chunks

Afzalligi: Har bir chunk grammatik jihatdan to'liq.

Kamchiligi: Loyiha tavsiflari uchun juda mayda. "Nihol kompaniyasi 2020-yilda X bank uchun Y tizimini joriy qildi" -- bu bitta gap, lekin kontekstsiz foydasiz. FAQ (ko'p beriladigan savollar) uchun mos.

6-strategiya: Parent-document retrieval

Kodlar: Parent-document retrieval — kichik qidir, katta ber 4-bob · ikki darajali chunk · ~70 qator

1. Ziddiyat

Retrieval uchun kichik chunk yaxshi — aniq, shovqinsiz, embedding-i o'tkir. Generation uchun esa katta chunk yaxshi — LLM to'liq kontekstni ko'radi.

Ikkalasini birdaniga olish mumkin: kichik chunk bo'yicha qidiring, katta chunk ni qaytaring.

2. Struktura — parent va child

Har bir hujjatni ikki darajada saqlaymiz:

  • parent — 2000 belgili katta bo'lak, LLM ga beriladi
  • child — 400 belgili kichik bo'lak, faqat qidiruv uchun embedding qilinadi

Child o'z parent ining id sini saqlaydi. Qidiruvda child topiladi, javobga parent ketadi.

python29 qator
PARENT_SQL = (
    "INSERT INTO parent_chunks (title, content) "
    "VALUES (:t, :c) RETURNING id"
)
CHILD_SQL = (
    "INSERT INTO child_chunks (parent_id, content, embedding) "
    "VALUES (:p, :c, :e)"
)

def index_parent_child(session, title: str, body: str) -> tuple[int, int]:
    parents = chunk_text(body, size=2000, overlap=200)
    n_parent = n_child = 0

    for parent_text in parents:
        parent_id = session.execute(
            text(PARENT_SQL), {"t": title, "c": parent_text}
        ).scalar_one()
        n_parent += 1

        for child in chunk_text(parent_text, size=400, overlap=50):
            emb, err = generate_query_embedding(child)
            if err or emb is None:
                continue
            session.execute(text(CHILD_SQL),
                            {"p": parent_id, "c": child, "e": str(emb)})
            n_child += 1

    session.commit()
    return n_parent, n_child
Natija
(23, 118)

3. Qidiruv — child topamiz, parent qaytaramiz

Muhim nuqta: bir nechta child bitta parent ga tegishli bo'lishi mumkin. Shuning uchun natijada takrorlanishni olib tashlash kerak — aks holda LLM bir xil matnni ikki marta ko'radi.

python28 qator
LOOKUP_SQL = (
    "SELECT c.parent_id, p.title, p.content, "
    "       1 - (c.embedding <=> CAST(:e AS vector)) AS score "
    "FROM child_chunks c "
    "JOIN parent_chunks p ON p.id = c.parent_id "
    "ORDER BY c.embedding <=> CAST(:e AS vector) "
    "LIMIT :k"
)

def parent_retrieve(session, question: str, top_k: int = 4) -> list[dict]:
    emb, _ = generate_query_embedding(question)
    rows = session.execute(
        text(LOOKUP_SQL), {"e": str(emb), "k": top_k * 3}
    ).mappings().all()

    seen, out = set(), []
    for r in rows:
        if r["parent_id"] in seen:
            continue
        seen.add(r["parent_id"])
        out.append(dict(r))
        if len(out) == top_k:
            break
    return out


for c in parent_retrieve(session, "Ucell billing tizimi"):
    print(f"{c['score']:.3f}  {c['title']}  ({len(c['content'])} belgi)")
Natija
0.841  Ucell billing loyihasi  (1980 belgi)
0.792  Ucell SMS gateway       (2000 belgi)
0.744  Telekom yo'nalishi      (1870 belgi)
0.701  Sharq Telekom loyihasi  (1940 belgi)

4. Nima yutdik?

Similarity ball kichik child matnidan olinadi — ya'ni aniqlik yuqori. LLM ga esa katta parent boradi — ya'ni kontekst to'liq.

Oddiy 400-belgili chunking da LLM "billing moduli yangilandi" gapini ko'radi, lekin qaysi loyiha, qaysi yil ekanini ko'rmaydi. Parent-document da bularning hammasi bitta matnda keladi.

Xarajat: baza hajmi ~1.3× oshadi (parent matni ham saqlanadi), lekin embedding soni o'zgarmaydi — faqat child lar embedding qilinadi.

Ehtiyot bo'ling: parent juda katta bo'lsa (4000+ belgi), 4 ta parent = 16000 belgi kontekst. Bu token xarajati va "lost in the middle" muammosini keltiradi. top_k ni kichikroq oling.

Qidiruv uchun kichik chunk saqlaydi, lekin topilganda to'liq hujjatni qaytaradi:

python16 qator
# Konseptual tuzilma
parent_store = {}   # id -> to'liq hujjat
child_store = {}    # id -> kichik chunk + parent_id

# Indexlashda:
doc = "To'liq sahifa matni..."
parent_id = store_parent(doc)
small_chunks = split_into_sentences(doc)
for chunk in small_chunks:
    store_child(chunk, parent_id=parent_id, embedding=embed(chunk))

# Qidiruvda:
results = vector_search(query_embedding, child_store)
for result in results:
    full_doc = parent_store[result.parent_id]
    # LLM ga full_doc beriladi, chunk emas

Afzalligi: Aniq qidiruv (kichik chunk) + to'liq kontekst (katta hujjat). Eng yaxshi ikkalasidan.

Kamchiligi: Ikki xil saqlash kerak. LLM ga beriladigan kontekst kattaroq bo'ladi, tokenlar ko'proq sarflanadi.

Chunk overlap

Qo'shni chunklar orasida 10-20% overlap (qoplama) qo'yish -- chegaralarda kontekst yo'qolmasligini ta'minlaydi:

python10 qator
def chunk_with_overlap(text: str, chunk_size: int = 500, overlap: int = 100) -> list[str]:
    words = text.split()
    chunks = []
    start = 0
    while start < len(words):
        end = min(start + chunk_size, len(words))
        chunk = " ".join(words[start:end])
        chunks.append(chunk)
        start = end - overlap  # overlap ta so'z orqaga qaytadi
    return chunks

Tasavvur qiling: birinchi chunk "...va loyiha muvaffaqiyatli yakunlandi" bilan tugaydi, ikkinchi chunk "loyiha muvaffaqiyatli yakunlandi. Keyingi bosqichda..." bilan boshlanadi. Overlap tufayli kontekst uzilmaydi.

Haqiqiy tajriba: Overlap qanday yordam berdi

Nihol loyihasida overlap qo'shilganda natijalar keskin yaxshilandi:

Overlapsiz (283 chunk): - "Промстрой банк uchun serverlar" savoli → faqat 1 ta loyiha topildi - Sabab: serverlar haqidagi ma'lumot chunk chegarasida bo'linib qolgan edi - 8B model faqat 1 ta chunk ko'rdi va faqat 1 ta loyiha haqida javob berdi

Overlap bilan (323 chunk, 200 token overlap): - Xuddi shu savol → 5 ta loyiha topildi (2007, 2011, 2016, 2021, 2023) - Sabab: overlap tufayli har bir chunk qo'shni chunkdan kontekstni saqlaydi - Retrieval 7 ta tegishli chunk topdi (avval 3 ta edi) - Model to'liq javob berdi — har bir yil uchun aniq IBM server modellari bilan

Kod:

python30 qator
MAX_TOKENS = 1000
OVERLAP_TOKENS = 200  # ~20% overlap

def _split_by_separator(full_text, separator, prefix):
    parts = [p for p in full_text.split(separator) if p.strip()]
    chunks = []
    current_parts = []
    current_tokens = _estimate_tokens(prefix)

    for part in parts:
        part_tokens = _estimate_tokens(part)
        if current_tokens + part_tokens > MAX_TOKENS and current_parts:
            chunks.append(prefix + separator.join(current_parts))
            # Overlap: oxirgi qismlarni saqlab qolish
            overlap_parts = []
            overlap_tokens = 0
            for p in reversed(current_parts):
                if overlap_tokens + _estimate_tokens(p) > OVERLAP_TOKENS:
                    break
                overlap_parts.insert(0, p)
                overlap_tokens += _estimate_tokens(p)
            current_parts = overlap_parts + [part]
            current_tokens = _estimate_tokens(prefix) + overlap_tokens + part_tokens
        else:
            current_parts.append(part)
            current_tokens += part_tokens

    if current_parts:
        chunks.append(prefix + separator.join(current_parts))
    return chunks

Xulosa: Overlap — kichik o'zgartirish, lekin RAG sifatiga katta ta'sir qiladi. Ayniqsa murakkab ma'lumotlar (ko'p loyiha, ko'p kompaniya) bilan ishlaganda overlap muhim. LlamaIndex default holatda 200 token overlap ishlatadi — shu sababli ularning demo yaxshi ishlagan.

Metadata enrichment

Har bir chunk ga qo'shimcha metadata qo'shish qidiruv sifatini oshiradi:

Metadata Maqsad Misol
title Sahifa sarlavhasi "Toshkent metro loyihasi"
source_url Manba havolasi "https://nihol.uz/projects/metro"
rubric Kategoriya "IT loyihalar"
language Til kodi "UZ" yoki "RU"
date Sana "2023-01-15"

Nihol loyihasida biz annotation maydonida rubrika va sahifa nomini saqlaymiz. url maydoni manba havolasini saqlaydi. language_id orqali til bo'yicha filtrlash mumkin -- qidiruv so'rovida faqat O'zbek tilidagi chunklar qaytarilsin deb belgilash mumkin.

Taqqoslash jadvali

Strategiya Afzalligi Kamchiligi Eng mos holat
Fixed-size Sodda, tez Kontekstni buzadi Tuzilmagan matnlar
Paragraph-based Tabiiy chegaralar Katta paragraflar muammo Veb-sahifalar, maqolalar
Recursive Moslashuvchan Murakkab mantiq Turli formatdagi hujjatlar
Semantic Eng aniq Eng sekin, qimmat Ilmiy maqolalar
Sentence-based Grammatik to'liq Juda mayda FAQ, savol-javoblar
Parent-document Aniq qidiruv + to'liq kontekst Ikki xil saqlash Katta hujjatlar

Qaysi strategiyani tanlash kerak?

Qoidalar:

  1. Kichik loyiha (bizning holat, ~377 chunk) -- paragraph-based yetarli. Murakkab usullar keraksiz.
  2. Ko'p formatli hujjatlar (PDF, Word, HTML) -- recursive splitting yaxshi ishlaydi.
  3. Ilmiy maqolalar yoki uzun hujjatlar -- semantic chunking yoki parent-document retrieval.
  4. FAQ bazasi -- sentence-based chunking.
  5. Katta hajmli production tizim (100K+ hujjat) -- semantic chunking + parent-document retrieval + metadata filtering.

Nihol loyihasida paragraph-based chunking tanlanganligi to'g'ri qaror, chunki: - Har bir sahifa mustaqil loyiha yoki xizmat tavsifi. - Matn hajmi odatda MAX_TOKENS dan kichik -- bo'lish kerak emas. - Prefix (rubrika + nom) har bir chunk ga kontekst beradi. - Sodda implementatsiya -- debug qilish oson.


</> Loyihaning haqiqiy kodi — bu bob amalda qanday ishlaydi

extractor/chunker.py — paragraph-based chunking

python112 qator
def process_content(session: Session, content: LvExtractedContent) -> list[LvContentChunks]:
    if not content.content_text:
        return []

    existing = session.query(LvContentChunks).filter(
        LvContentChunks.content_id == content.id
    ).all()
    if existing:
        return existing

    start = time.perf_counter()
    total_chars = len(content.content_text)
    created = []
    status = "ok"
    error_message = None
    try:
        rubric = content.page.rubric_name or "" if content.page else ""
        page_name = content.page.name or "" if content.page else ""
        prefix_parts = [p for p in [rubric, page_name] if p]
        prefix = "\n\n".join(prefix_parts)

        parts = prefix_parts + [content.content_text]
        full_text = "\n\n".join(parts)

        chunks_text = _split_if_needed(full_text, prefix)

        for chunk_text in chunks_text:
            chunk = LvContentChunks(
                content_id=content.id,
                chunk_text=chunk_text,
                content_size_bytes=len(chunk_text.encode("utf-8")),
            )
            session.add(chunk)
            created.append(chunk)

        session.commit()
        for chunk in created:
            session.refresh(chunk)
    except Exception as e:
        status = "error"
        error_message = str(e)
        raise
    finally:
        duration_ms = int((time.perf_counter() - start) * 1000)
        _write_analytics_chunking(
            content_id=content.id,
            chunks_created=len(created),
            total_chars=total_chars,
            duration_ms=duration_ms,
            status=status,
            error_message=error_message,
        )

    return created


def _estimate_tokens(text: str) -> int:
    return len(text) // _chars_per_token()


def _split_if_needed(full_text: str, annotation: str) -> list[str]:
    max_tokens = _max_tokens()
    if _estimate_tokens(full_text) <= max_tokens:
        return [full_text]

    prefix = f"{annotation}\n\n" if annotation else ""
    result = _split_by_separator(full_text, "\n\n", prefix)

    final = []
    for chunk in result:
        if _estimate_tokens(chunk) > max_tokens:
            final.extend(_split_by_separator(chunk, "\n", prefix))
        else:
            final.append(chunk)

    return final if final else [full_text]


def _split_by_separator(full_text: str, separator: str, prefix: str) -> list[str]:
    max_tokens = _max_tokens()
    overlap_tokens_budget = _overlap_tokens()
    parts = full_text.split(separator)
    parts = [p for p in parts if p.strip()]
    chunks = []
    current_parts = []
    current_tokens = _estimate_tokens(prefix)

    for i, part in enumerate(parts):
        part_tokens = _estimate_tokens(part)
        if current_tokens + part_tokens > max_tokens and current_parts:
            chunk_text = prefix + separator.join(current_parts)
            chunks.append(chunk_text.rstrip())
            overlap_parts = []
            overlap_tokens = 0
            for p in reversed(current_parts):
                p_tokens = _estimate_tokens(p)
                if overlap_tokens + p_tokens > overlap_tokens_budget:
                    break
                overlap_parts.insert(0, p)
                overlap_tokens += p_tokens
            current_parts = overlap_parts + [part]
            current_tokens = _estimate_tokens(prefix) + overlap_tokens + part_tokens
        else:
            current_parts.append(part)
            current_tokens += part_tokens

    if current_parts:
        chunk_text = prefix + separator.join(current_parts)
        if chunk_text.rstrip() != prefix.rstrip():
            chunks.append(chunk_text.rstrip())

    return chunks if chunks else [full_text]

7-strategiya: Proposition chunking (atomik faktlar)

Kodlar: Proposition Chunking — atomik faktlarga bo'lish 4-bob · generatsiya + sifat nazorati · ~100 qator

1. Ikki bosqichli jarayon

Proposition chunking ikki qadamdan iborat, va ikkinchisi majburiy:

  • Generatsiya — LLM matnni atomik faktlarga bo'ladi
  • Sifat nazorati — har bir fakt tekshiriladi, yaroqsizlari tashlanadi

Nazoratsiz LLM yarim gap, takror va o'ylab topilgan fakt chiqaradi. Bu chunking-ni yaxshilash o'rniga buzadi.

python26 qator
PROPOSITION_PROMPT = (
    "Quyidagi matnni mustaqil faktlarga ajrat. Qoidalar:\n"
    "1. Har bir fakt alohida qatorda\n"
    "2. Har bir fakt o'zi tushunarli bo'lsin — olmoshlarni (u, bu, o'sha) "
    "aniq nom bilan almashtir\n"
    "3. Matnda yo'q ma'lumot qo'shma\n"
    "4. Raqamlama, izoh yozma\n\n"
    "Matn: {chunk}"
)

def generate_propositions(chunk: str) -> list[str]:
    out = _ollama_chat(
        [{"role": "user", "content": PROPOSITION_PROMPT.format(chunk=chunk)}],
        temperature=0.0, max_tokens=600,
    )
    items = [p.strip(" -•0123456789.") for p in (out or "").split("\n")]
    return [p for p in items if len(p) > 25]


sample = (
    "Nihol 2015-yilda tashkil topgan. U dastlab veb-sayt ishlab chiqish bilan "
    "shug'ullangan, keyin korporativ tizimlarga o'tgan. Hozirda kompaniyada "
    "40 dan ortiq mutaxassis ishlaydi."
)
for p in generate_propositions(sample):
    print("-", p)
Natija
- Nihol kompaniyasi 2015-yilda tashkil topgan.
- Nihol dastlab veb-sayt ishlab chiqish bilan shug'ullangan.
- Nihol keyinchalik korporativ tizimlar yo'nalishiga o'tgan.
- Nihol kompaniyasida hozirda 40 dan ortiq mutaxassis ishlaydi.

2. Sifat nazorati — to'rt mezon

Har bir proposition to'rtta savoldan o'tadi. Bittasi ham yiqilsa — tashlab yuboriladi.

  • Aniqlik — asl matnga mos keladimi?
  • Mustaqillik — kontekstsiz tushunarlimi?
  • To'liqlik — tugallangan fikrmi?
  • Qisqalik — bitta fakt, ikkita emasmi?
python27 qator
QUALITY_PROMPT = (
    "Quyidagi faktni 4 mezon bo'yicha bahola. Har biriga 1 (ha) yoki 0 (yo'q):\n"
    "aniqlik — asl matnga mos\n"
    "mustaqillik — kontekstsiz tushunarli, olmosh yo'q\n"
    "toliqlik — tugallangan fikr\n"
    "qisqalik — faqat bitta fakt\n\n"
    "Faqat 4 ta raqamni vergul bilan yoz, masalan: 1,1,1,0\n\n"
    "Asl matn: {source}\n\nFakt: {prop}"
)

def check_quality(proposition: str, source: str) -> dict:
    out = _ollama_chat(
        [{"role": "user", "content": QUALITY_PROMPT.format(source=source[:800],
                                                            prop=proposition)}],
        temperature=0.0, max_tokens=20,
    )
    digits = [c for c in (out or "") if c in "01"][:4]
    if len(digits) < 4:
        return {"passed": False, "scores": [0, 0, 0, 0]}
    scores = [int(d) for d in digits]
    return {"passed": all(s == 1 for s in scores), "scores": scores}


for p in generate_propositions(sample):
    r = check_quality(p, sample)
    mark = "OK  " if r["passed"] else "RAD "
    print(f"{mark} {r['scores']}  {p[:60]}")
Natija
OK   [1, 1, 1, 1]  Nihol kompaniyasi 2015-yilda tashkil topgan.
OK   [1, 1, 1, 1]  Nihol dastlab veb-sayt ishlab chiqish bilan shug'ullangan.
OK   [1, 1, 1, 1]  Nihol keyinchalik korporativ tizimlar yo'nalishiga o'tgan.
OK   [1, 1, 1, 1]  Nihol kompaniyasida hozirda 40 dan ortiq mutaxassis ishlaydi.

3. Parent bilan bog'lash

Proposition mustaqil, lekin juda qisqa. LLM ga faqat propositionlarni bersangiz, javob quruq va tafsilotsiz chiqadi.

Shuning uchun har bir proposition asl chunk ga ishora qiladi: proposition bo'yicha qidiramiz, asl matnni qaytaramiz.

python33 qator
PROP_SQL = (
    "INSERT INTO propositions (parent_id, content, embedding) "
    "VALUES (:p, :c, :e)"
)

def index_propositions(session, title: str, body: str) -> dict:
    stats = {"chunks": 0, "generated": 0, "passed": 0}

    for chunk in chunk_text(body, size=800):
        parent_id = session.execute(
            text(PARENT_SQL), {"t": title, "c": chunk}
        ).scalar_one()
        stats["chunks"] += 1

        for prop in generate_propositions(chunk):
            stats["generated"] += 1
            if not check_quality(prop, chunk)["passed"]:
                continue
            emb, err = generate_query_embedding(prop)
            if err or emb is None:
                continue
            session.execute(text(PROP_SQL),
                            {"p": parent_id, "c": prop, "e": str(emb)})
            stats["passed"] += 1

    session.commit()
    return stats


body = open("nihol_corpus.txt", encoding="utf-8").read()
stats = index_propositions(session, "Nihol korpusi", body)
print(stats)
print(f"O'tish darajasi: {stats['passed'] / stats['generated']:.0%}")
Natija
{'chunks': 451, 'generated': 1834, 'passed': 1502}
O'tish darajasi: 82%

4. 18% rad etildi — bu normal

Rad etilganlarning aksariyati ikki sababdan: olmosh qolib ketgan ("u 2015-yilda ochilgan") yoki ikkita fakt bitta gapga tiqilgan.

Agar o'tish darajasi 60% dan past bo'lsa — model kichik yoki prompt yomon. 95% dan yuqori bo'lsa — nazorat juda yumshoq, tekshiruv ishlamayapti.

python18 qator
PROP_SEARCH = (
    "SELECT p.content AS proposition, pc.content AS parent, pc.title, "
    "       1 - (p.embedding <=> CAST(:e AS vector)) AS score "
    "FROM propositions p "
    "JOIN parent_chunks pc ON pc.id = p.parent_id "
    "ORDER BY p.embedding <=> CAST(:e AS vector) "
    "LIMIT :k"
)

def proposition_search(session, question: str, top_k: int = 3) -> list[dict]:
    emb, _ = generate_query_embedding(question)
    rows = session.execute(text(PROP_SEARCH),
                           {"e": str(emb), "k": top_k}).mappings().all()
    return [dict(r) for r in rows]


for r in proposition_search(session, "Nihol qachon tashkil topgan?"):
    print(f"{r['score']:.3f}  {r['proposition'][:65]}")
Natija
0.891  Nihol kompaniyasi 2015-yilda tashkil topgan.
0.744  Nihol dastlab veb-sayt ishlab chiqish bilan shug'ullangan.
0.703  Nihol kompaniyasida hozirda 40 dan ortiq mutaxassis ishlaydi.
0.891 — oddiy chunking bilan bu savol 0.71 berardi. Farq katta, chunki proposition embedding-i faqat bitta faktni ifodalaydi.

Narxi: 451 chunk uchun 451 generatsiya + 1834 sifat tekshiruvi = 2285 LLM chaqiruvi. Lokal Qwen da bu bir necha soat. Katta korpusda bu strategiyani tanlab qo'llang — eng muhim hujjatlarga.

Yuqoridagi barcha strategiyalar matnni joylashuvi bo'yicha bo'ladi — belgi soni, paragraf chegarasi, gap oxiri. Proposition chunking butunlay boshqa savol beradi: bu matnda nechta mustaqil fakt bor?

Har bir chunk bitta to'liq, mustaqil fakt bo'ladi. "Proposition" — mantiqdan olingan atama: o'zi yetarli, rost yoki yolg'on deb baholash mumkin bo'lgan gap.

python8 qator
Asl matn:
  "Nihol 2015-yilda tashkil topgan. U dastlab veb-sayt ishlab chiqish
   bilan shug'ullangan, keyin korporativ tizimlarga o'tgan."

Proposition lar:
  1. Nihol kompaniyasi 2015-yilda tashkil topgan.
  2. Nihol dastlab veb-sayt ishlab chiqish bilan shug'ullangan.
  3. Nihol keyinchalik korporativ tizimlar yo'nalishiga o'tgan.

Diqqat qiling: uchinchi propositionda "u" olmoshi "Nihol" bilan almashtirildi. Bu majburiy — chunk mustaqil bo'lishi kerak, chunki retrieval uni kontekstsiz qaytaradi.

Nega bu retrieval ni yaxshilaydi?

800 belgili chunk ichida 5-6 ta fakt bo'ladi. Uning embedding-i — o'sha 6 faktning o'rtachasi, ya'ni hech qaysi biriga aniq mos kelmaydi. Proposition da esa embedding bitta faktni ifodalaydi — vektor o'tkir bo'ladi.

Amalda bu shunday ko'rinadi: "Nihol qachon tashkil topgan?" savoli 800-belgili chunk bilan 0.71 similarity beradi, proposition bilan 0.89.

Narxi va kamchiligi

Bu eng qimmat chunking strategiyasi: har bir matn bo'lagi uchun LLM chaqiruvi kerak, ustiga har bir proposition sifat tekshiruvidan o'tadi. 400 chunk o'rniga 1200 proposition chiqadi — embedding xarajati ham uch barobar.

Ikkinchi muammo — kontekst yo'qoladi. Proposition mustaqil, lekin u qaysi hujjatdan, qaysi bo'limdan kelgani bilinmaydi. Shuning uchun uni odatda parent-document retrieval bilan birga ishlatiladi: proposition bo'yicha qidirib, asl paragrafni qaytarish.

8-strategiya: Contextual Chunk Headers (CCH)

Kodlar: Contextual Chunk Headers — eng arzon yutuq 4-bob · LLM chaqiruvisiz · ~70 qator

1. Sarlavhani strukturadan olish

Eng yaxshi sarlavha — bepul sarlavha. HTML da h1/h2, PDF da bookmark, Markdown da #. LLM umuman kerak emas.

python27 qator
from bs4 import BeautifulSoup

def chunks_with_headers_from_html(html: str, doc_title: str,
                                  size: int = 800) -> list[dict]:
    soup = BeautifulSoup(html, "html.parser")
    out = []
    current_section = ""

    for el in soup.find_all(["h1", "h2", "h3", "p", "li"]):
        if el.name in ("h1", "h2", "h3"):
            current_section = el.get_text(strip=True)
            continue

        body = el.get_text(" ", strip=True)
        if len(body) < 60:
            continue

        for piece in chunk_text(body, size=size):
            header = f"Hujjat: {doc_title}"
            if current_section:
                header += f"\nBo'lim: {current_section}"
            out.append({
                "header": header,
                "body": piece,
                "embed_text": f"{header}\n---\n{piece}",
            })
    return out

2. Muhim nuqta — nimani embedding qilamiz

embed_text (sarlavha + matn) embedding qilinadi, lekin LLM ga ikkalasi ham beriladi. Sarlavha faqat qidiruv uchun emas — u LLM ga ham kontekst beradi.

python20 qator
CCH_SQL = (
    "INSERT INTO chunks (title, header, content, embedding) "
    "VALUES (:t, :h, :c, :e)"
)

def index_with_headers(session, html: str, doc_title: str) -> int:
    saved = 0
    for item in chunks_with_headers_from_html(html, doc_title):
        emb, err = generate_query_embedding(item["embed_text"])
        if err or emb is None:
            continue
        session.execute(text(CCH_SQL), {
            "t": doc_title,
            "h": item["header"],
            "c": item["body"],
            "e": str(emb),
        })
        saved += 1
    session.commit()
    return saved

3. Ta'sirni o'lchash

Bir xil savol, bir xil chunk — faqat sarlavha bor/yo'q. Farqni raqamda ko'ramiz.

python11 qator
body = "Ikkinchi bosqichda to'lov shlyuzi ulandi va hisobot moduli qo'shildi."
header = "Hujjat: Ucell billing tizimi modernizatsiyasi\nBo'lim: Loyiha bosqichlari"

question = "Ucell to'lov shlyuzi qachon ulangan?"
q_emb, _ = generate_query_embedding(question)

plain_emb, _ = generate_query_embedding(body)
cch_emb, _ = generate_query_embedding(f"{header}\n---\n{body}")

print(f"CCH siz  : {cosine_similarity(q_emb, plain_emb):.4f}")
print(f"CCH bilan: {cosine_similarity(q_emb, cch_emb):.4f}")
Natija
CCH siz  : 0.4127
CCH bilan: 0.7893

4. 0.41 → 0.79

Bu ulkan farq, va u bir qator koddan keldi. Sababi oddiy: savolda "Ucell" bor, chunk matnida "Ucell" yo'q, sarlavhada bor.

0.41 — ko'p tizimlarda threshold dan past, ya'ni bu chunk umuman qaytarilmagan bo'lardi.

5. LLM bilan sarlavha — faqat zarurat bo'lsa

Struktura yo'q bo'lsa (masalan skanerlangan PDF), LLM dan sarlavha so'rash mumkin. Lekin hujjatga bir marta, har chunk uchun emas.

python17 qator
TITLE_PROMPT = (
    "Quyidagi hujjat boshidan uning mavzusini bitta qisqa jumlada yoz. "
    "Maksimal 10 so'z. Faqat sarlavhani yoz.\n\n{head}"
)

_title_cache: dict[str, str] = {}

def infer_title(doc_id: str, body: str) -> str:
    if doc_id in _title_cache:
        return _title_cache[doc_id]
    out = _ollama_chat(
        [{"role": "user", "content": TITLE_PROMPT.format(head=body[:1500])}],
        temperature=0.2, max_tokens=30,
    )
    title = (out or "").strip().strip('"')
    _title_cache[doc_id] = title
    return title
Bu kitobdagi eng yuqori foyda/xarajat nisbatiga ega texnika. Agar faqat bittasini tanlashingiz kerak bo'lsa — shuni tanlang.

Ehtiyot bo'ling: sarlavha uzun bo'lsa teskari ta'sir beradi. Barcha chunk lar bir xil uzun sarlavha bilan boshlansa, ularning embedding lari bir-biriga yaqinlashib ketadi va retrieval yomonlashadi. 40-60 belgi — xavfsiz oraliq.

Bu kitobdagi eng arzon va eng samarali texnikalardan biri. G'oya bir jumlada: har bir chunk boshiga u qayerdan kelganini yozib qo'ying.

python8 qator
CCH siz chunk:
  "Ikkinchi bosqichda to'lov shlyuzi ulandi va hisobot moduli qo'shildi."

CCH bilan chunk:
  "Hujjat: Ucell billing tizimi modernizatsiyasi loyihasi
   Bo'lim: Loyiha bosqichlari
   ---
   Ikkinchi bosqichda to'lov shlyuzi ulandi va hisobot moduli qo'shildi."

Birinchi variantda "Ucell" so'zi umuman yo'q. Foydalanuvchi "Ucell to'lov shlyuzi" deb qidirsa — bu chunk topilmaydi, garchi javob aynan shu yerda bo'lsa ham.

Ikkinchi variantda sarlavha matnga qo'shilgan, ya'ni embedding ham uni ko'radi. Endi chunk topiladi.

Sarlavhani qayerdan olish kerak?

Uch manba bor, sifat tartibida:

  • Struktura — HTML h1/h2, PDF bookmark, Markdown sarlavha. Bepul va aniq. Imkon bo'lsa har doim shu.
  • Metadata — sayt URL i, fayl nomi, baza maydoni. Ham bepul.
  • LLM — hujjat boshidan sarlavha yozdirish. Faqat yuqoridagilar bo'lmaganda; bir hujjatga bir marta, har chunk uchun emas.

CCH narxi deyarli nol

Qo'shimcha LLM chaqiruvi yo'q (struktura ishlatilsa), qo'shimcha saqlash ~5%. Retrieval tezligi umuman o'zgarmaydi. Shu sababli bu texnikani birinchi qo'llash kerak — foyda/xarajat nisbati bo'yicha tengi yo'q.

Yagona ehtiyotkorlik: sarlavha juda uzun bo'lmasin. 40-60 belgi yetarli, aks holda u chunk mazmunini "bosib" ketadi va barcha chunk lar bir-biriga o'xshab qoladi.

9-strategiya: Document Augmentation (savol generatsiyasi)

Kodlar: Document Augmentation — chunk dan savol yasash 4-bob · indeks vaqtida · ~80 qator

1. Ko'p kirish nuqtasi

Bitta chunk — bitta vektor — bitta kirish nuqtasi. Agar foydalanuvchining savoli o'sha nuqtaga tushmasa, chunk topilmaydi.

Yechim: bitta chunk ga bir nechta vektor bog'lash. Har bir vektor — boshqa savol shakli.

python20 qator
QUESTIONS_PROMPT = (
    "Quyidagi matn qaysi savollarga javob beradi? "
    "3 ta savol yoz. Har birini yangi qatorda, raqamlamasdan. "
    "Savollar bir-biridan farq qilsin.\n\nMatn: {chunk}"
)

def generate_questions(chunk: str, limit: int = 3) -> list[str]:
    out = _ollama_chat(
        [{"role": "user", "content": QUESTIONS_PROMPT.format(chunk=chunk)}],
        temperature=0.5, max_tokens=250,
    )
    items = [q.strip(" -•0123456789.") for q in (out or "").split("\n")]
    items = [q for q in items if q.endswith("?") and len(q) > 15]
    return items[:limit]


chunk = ("Billing moduli 2023-yil iyunda Click va Payme bilan integratsiya "
         "qilindi. Ulanish REST API orqali amalga oshirilgan.")
for q in generate_questions(chunk):
    print("-", q)
Natija
- Billing moduliga qaysi to'lov tizimlari ulangan?
- Click integratsiyasi qachon amalga oshirilgan?
- To'lov shlyuzlari qanday protokol orqali ulangan?

2. Savollarni tozalash

LLM ba'zan yaroqsiz savol chiqaradi: juda umumiy ("Bu matn nima haqida?"), takroriy, yoki matnda javobi yo'q savol.

Ikki filtr qo'yamiz: umumiy iboralar va o'zaro o'xshashlik.

python16 qator
GENERIC = ("bu matn", "ushbu matn", "matnda nima", "nima haqida", "qanday ma'lumot")

def filter_questions(questions: list[str], max_similarity: float = 0.9) -> list[str]:
    kept, vectors = [], []
    for q in questions:
        low = q.lower()
        if any(g in low for g in GENERIC):
            continue
        emb, err = generate_query_embedding(q)
        if err or emb is None:
            continue
        if any(cosine_similarity(emb, v) > max_similarity for v in vectors):
            continue
        kept.append(q)
        vectors.append(emb)
    return kept
python17 qator
QUESTION_SQL = (
    "INSERT INTO chunk_questions (chunk_id, question, embedding) "
    "VALUES (:c, :q, :e)"
)

def augment_chunk(session, chunk_id: int, chunk: str) -> int:
    questions = filter_questions(generate_questions(chunk))
    saved = 0
    for q in questions:
        emb, err = generate_query_embedding(q)
        if err or emb is None:
            continue
        session.execute(text(QUESTION_SQL),
                        {"c": chunk_id, "q": q, "e": str(emb)})
        saved += 1
    session.commit()
    return saved

3. Ikki yo'lli qidiruv

Endi qidiruvda ikki manba bor: chunk matni va savollar. Ikkalasidan ham qidiramiz va RRF bilan birlashtiramiz.

python27 qator
Q_SEARCH = (
    "SELECT c.id, c.title, c.content, q.question, "
    "       1 - (q.embedding <=> CAST(:e AS vector)) AS score "
    "FROM chunk_questions q JOIN chunks c ON c.id = q.chunk_id "
    "ORDER BY q.embedding <=> CAST(:e AS vector) "
    "LIMIT :k"
)

def augmented_search(session, question: str, top_k: int = 5) -> list[dict]:
    emb, _ = generate_query_embedding(question)

    by_text = retrieve_chunks(session, emb, top_k=10)
    rows = session.execute(text(Q_SEARCH),
                           {"e": str(emb), "k": 10}).mappings().all()
    by_question = [dict(r) for r in rows]

    fused = rrf_fuse(by_text, by_question, top_k=top_k)

    matched = {r["id"]: r["question"] for r in by_question}
    for row in fused:
        row["matched_question"] = matched.get(row["id"], "")
    return fused


for c in augmented_search(session, "Click qachon ulangan?"):
    hint = c["matched_question"] or "(matn bo'yicha)"
    print(f"{c['rrf_score']:.5f}  {hint[:55]}")
Natija
0.03252  Click integratsiyasi qachon amalga oshirilgan?
0.01639  To'lov shlyuzlari qanday protokol orqali ulangan?
0.01613  (matn bo'yicha)
0.01587  Billing moduliga qaysi to'lov tizimlari ulangan?
0.01562  (matn bo'yicha)

4. Nima ko'rinyapti

Birinchi natija deyarli aynan mos savol orqali topildi. Foydalanuvchi "Click qachon ulangan?" deb yozdi, indeksda "Click integratsiyasi qachon amalga oshirilgan?" savoli turgan edi.

Faqat matn bo'yicha qidirilganda bu chunk 3-o'rinda edi.

Xarajat: har chunk uchun 1 ta LLM chaqiruvi + 3 ta embedding. Indeks hajmi ~4 barobar oshadi (1 matn + 3 savol vektori).

Qidiruv tezligiga ta'siri yo'q — bu HyDE dan asosiy afzalligi. HyDE har so'rovda LLM chaqiradi, bu texnika esa faqat indekslashda.

Maslahat: temperature=0.5 — savollar xilma-xil bo'lishi uchun. 0.0 da uchta deyarli bir xil savol chiqadi va filtr ularni tashlab yuboradi.

Oldingi strategiyalar chunk ni qanday bo'lishni o'zgartirdi. Bu strategiya boshqa yo'ldan boradi: chunk ni o'zgartirmaydi, unga qo'shimcha kirish nuqtalari yasaydi.

Har bir chunk uchun LLM 3-5 ta savol yozadi — "bu matn qaysi savollarga javob beradi?". Keyin savollar ham embedding qilinadi va chunk ga ishora qilib saqlanadi.

yaml9 qator
Chunk: "Billing moduli 2023-yil iyunda Click va Payme bilan
        integratsiya qilindi. Ulanish REST API orqali."

Generatsiya qilingan savollar:
  - Billing moduliga qaysi to'lov tizimlari ulangan?
  - Click integratsiyasi qachon amalga oshirilgan?
  - To'lov shlyuzlari qanday protokol orqali ulangan?

Bazada: 1 ta chunk + 3 ta savol vektori = 4 ta kirish nuqtasi

Nega savol-savolga yaxshi mos keladi?

Bu kitobda HyDE bo'limida ko'rgan muammoning teskari yechimi. HyDE savolni javobga o'xshatadi (qidiruv paytida). Document Augmentation esa javobdan savol yasaydi (indekslash paytida).

Foydalanuvchining savoli va generatsiya qilingan savol — ikkalasi ham savol shaklida. Grammatik struktura bir xil, shuning uchun embedding lar juda yaqin bo'ladi.

HyPE bilan farqi

Keyingi bobda ko'radigan HyPE texnikasi aynan shu g'oyani ishlatadi. Farqi: HyPE faqat savol vektorini saqlaydi, Document Augmentation esa savol va matn vektorini — ya'ni ikki xil qidiruv yo'li qoladi.

Amaliy farq kichik. Muhimi — ikkalasi ham indekslash vaqtida ishlaydi, ya'ni qidiruv tezligiga ta'sir qilmaydi. HyDE dan asosiy afzalligi shu.

Bu bobni bepul o'qidingiz.

Kitob o'rgatadi, lekin sizning kodingiz nega ishlamaganini ayta olmaydi. Kursda uy vazifangiz tekshiriladi.

Telegramda yozish Narxlar