Chunking Strategiyalari
Nima uchun chunking kerak?
Kodlar: Chunk o'lchamini empirik tanlash
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.
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.
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 04. 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.
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}")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.
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.
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:
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:
# 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:
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:
# 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:
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
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.
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]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 chunks3. Ishlatish va tekshirish
Muhim: max_chars cheklovi majburiy. Bir mavzu haqidagi uzun matn bitta ulkan chunk ga aylanib qolmasligi kerak.
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], "...")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.
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:
# 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:
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
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.
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(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.
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)")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.
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:
# 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:
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:
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:
- Kichik loyiha (bizning holat, ~377 chunk) -- paragraph-based yetarli. Murakkab usullar keraksiz.
- Ko'p formatli hujjatlar (PDF, Word, HTML) -- recursive splitting yaxshi ishlaydi.
- Ilmiy maqolalar yoki uzun hujjatlar -- semantic chunking yoki parent-document retrieval.
- FAQ bazasi -- sentence-based chunking.
- 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
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
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.
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)- 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?
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]}")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.
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%}"){'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.
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]}")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.
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.
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
1. Sarlavhani strukturadan olish
Eng yaxshi sarlavha — bepul sarlavha. HTML da h1/h2, PDF da bookmark, Markdown da #. LLM umuman kerak emas.
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 out2. 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.
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 saved3. Ta'sirni o'lchash
Bir xil savol, bir xil chunk — faqat sarlavha bor/yo'q. Farqni raqamda ko'ramiz.
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}")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.
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 titleEhtiyot 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.
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
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.
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)- 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.
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 keptQUESTION_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 saved3. Ikki yo'lli qidiruv
Endi qidiruvda ikki manba bor: chunk matni va savollar. Ikkalasidan ham qidiramiz va RRF bilan birlashtiramiz.
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]}")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.
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.
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.