Retrieval (Qidirish)
RAG arxitekturasida R harfi Retrieval degan ma'noni bildiradi. Bu eng muhim qadam. Agar retrieval noto'g'ri chunk-larni qaytarsa, reranking ham, generation ham yaxshi natija bermaydi. Kutubxonadan kitob izlayotgan odamni tasavvur qiling: agar kutubxonachi noto'g'ri javondan kitob olib kelsa, qanchalik yaxshi o'qimasligingiz farqi yo'q -- noto'g'ri kitob noto'g'ri javob beradi.
Bu bobda biz barcha retrieval usullarini ko'rib chiqamiz: semantic search, keyword search, hybrid search, query expansion, multi-query retrieval, language filtering, HyDE va self-query. Har bir usul uchun Nihol loyihasidan real kod misollar beramiz.
7.1 Semantic Search (Semantik Qidirish)
Kodlar: Semantic Search — pgvector ichida nima bo'ladi
1. Cosine similarity — formulaning o'zi
Ikki vektor orasidagi burchak o'lchanadi, uzunlik emas. Shuning uchun uzun hujjat va qisqa savol adolatli taqqoslanadi.
Natija −1 dan 1 gacha. Amalda embedding modellar deyarli har doim 0 dan 1 gacha qiymat beradi.
import numpy as np
def cosine_similarity(a: list[float], b: list[float]) -> float:
a, b = np.array(a), np.array(b)
return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))
q, _ = generate_query_embedding("Ucell loyihasi")
d1, _ = generate_query_embedding("Ucell uchun billing tizimi modernizatsiyasi")
d2, _ = generate_query_embedding("Bugun ob-havo issiq bo'ladi")
print(f"Tegishli : {cosine_similarity(q, d1):.4f}")
print(f"Tegishli emas: {cosine_similarity(q, d2):.4f}")Tegishli : 0.8214 Tegishli emas: 0.1073
2. pgvector uchta operator beradi
<=>— cosine masofa (1 − similarity). Bizga shu kerak.<->— Euclidean (L2) masofa<#>— inner product (manfiy)
Embedding modellar normalizatsiya qilingan vektor beradi, shuning uchun cosine va inner product bir xil tartib beradi — lekin cosine o'qishga qulayroq.
3. Index bo'lmasa — sequential scan
Indexsiz PostgreSQL har bir qatorni tekshiradi. 377 chunk da bu sezilmaydi (~15 ms), 100 000 chunk da esa halokat (~2 soniya).
HNSW index buni logarifmik qiladi. To'lov: index qurish vaqti va xotira.
session.execute(text(
"CREATE INDEX IF NOT EXISTS chunks_embedding_hnsw "
"ON chunks USING hnsw (embedding vector_cosine_ops) "
"WITH (m = 16, ef_construction = 64)"
))
session.commit()
plan = session.execute(text(
"EXPLAIN ANALYZE "
"SELECT id FROM chunks "
"ORDER BY embedding <=> CAST(:e AS vector) LIMIT 5"
), {"e": str(q)}).scalars().all()
for line in plan:
print(line)Limit (cost=0.42..8.61 rows=5 width=12) (actual time=0.312..0.418 rows=5 loops=1)
-> Index Scan using chunks_embedding_hnsw on chunks
(actual time=0.310..0.414 rows=5 loops=1)
Order By: (embedding <=> '[...]'::vector)
Planning Time: 0.196 ms
Execution Time: 0.482 ms4. Index Scan ko'rinishi shart
Agar Seq Scan yozilgan bo'lsa — index ishlamayapti. Eng ko'p uchraydigan uchta sabab:
ORDER BYda boshqa operator ishlatilgan (indexvector_cosine_opsbilan qurilgan, so'rov<->ishlatyapti)- Jadval juda kichik — planner index ni foydasiz deb hisoblaydi (bu normal)
LIMITyo'q — HNSW faqat top-N uchun ishlaydi
def search_with_threshold(session, question: str, top_k: int = 5,
min_score: float = 0.35) -> list[dict]:
emb, _ = generate_query_embedding(question)
rows = session.execute(text(SEARCH_SQL),
{"e": str(emb), "k": top_k}).mappings().all()
return [dict(r) for r in rows if r["score"] >= min_score]
hits = search_with_threshold(session, "kosmik raketa dvigateli")
print(f"Topildi: {len(hits)} ta chunk")Topildi: 0 ta chunk
Thresholdsiz LLM shu axlat kontekst bilan javob yozadi va hallucination boshlanadi.
min_score ni o'z korpusingizda o'lchang — universal qiymat yo'q, lekin 0.30–0.40 oralig'i ko'p tizimlarda ishlaydi.Semantic search -- matn ma'nosiga asoslangan qidirish. Aniq so'zlar mos kelishi shart emas, ma'no yaqinligi kifoya. "Banklarning kompyuter tizimlari" degan so'rov "Orient Finans Bank uchun giperkonvergent yechim" chunk-ini topa oladi, chunki ikkalasi ham banking + IT haqida.
Qanday ishlaydi -- qadam-baqadam
1-qadam: User savoli vektorga aylanadi
Foydalanuvchi savol yozadi, masalan: "Ucell uchun qanday loyiha qilingan?" Bu matn embedding model orqali sonlar ro'yxatiga (vektorga) aylantiriladi.
def generate_query_embedding(text_input: str) -> list[float] | None: settings = get_settings() for attempt in range(3): try: resp = httpx.post( f"{settings.OLLAMA_URL}/api/embed", json={"model": settings.EMBED_MODEL, "input": text_input}, timeout=60, ) resp.raise_for_status() return resp.json()["embeddings"][0] except Exception: if attempt < 2: time.sleep(1) return None
Nihol loyihasida biz qwen3-embedding:8b modelidan foydalanamiz. Bu model 4096 o'lchamli vektor qaytaradi. Ya'ni har bir matn 4096 ta sondan iborat nuqtaga aylanadi.
2-qadam: Barcha saqlangan vektorlar bilan taqqoslanadi
PostgreSQL + pgvector orqali bazadagi barcha chunk-lar embedding-lari bilan cosine distance hisoblanadi. Cosine distance ikki vektor orasidagi burchakni o'lchaydi. Burchak kichik bo'lsa -- ma'no yaqin.
SELECT ch.id, ch.content_id, ch.chunk_text, c.title, c.url, 1 - (ch.embedding_local <=> :vec) AS similarity FROM wde.lv_content_chunks ch JOIN wde.lv_extracted_content c ON c.id = ch.content_id WHERE ch.embedding_local IS NOT NULL ORDER BY ch.embedding_local <=> :vec LIMIT :lim
Bu yerda <=> operatori pgvector-ning cosine distance operatori. 1 - distance formula orqali similarity olamiz: 1.0 = mukammal mos, 0.0 = umuman boshqa.
3-qadam: Top K eng o'xshash chunk-lar qaytariladi
LIMIT :lim orqali faqat eng yaxshi K ta natija olinadi. Nihol loyihasida RAG_TOP_K = 10 deb belgilangan.
4-qadam: Threshold bilan filtrlash
Qaytgan chunk-lardan similarity qiymati past bo'lganlarini olib tashlaymiz:
return [ { "chunk_id": r[0], "content_id": r[1], "chunk_text": r[2], "title": r[3], "url": r[4], "similarity": float(r[5]), } for r in rows if float(r[5]) >= threshold ]
Bizning RAG_SIMILARITY_THRESHOLD admin paneldan sozlanadi, joriy default 0.0 -- ya'ni filtr o'chirilgan. Nega? Chunki top_k=15 chegarasi va keyingi heuristik tanlash shovqinni o'zi kamaytiradi. Chegara qo'ymoqchi bo'lsangiz: juda yuqori (masalan 0.7) -- ko'p foydali chunk yo'qoladi; o'rtacha (0.3) -- aloqasiz natijalarni kesadi; juda past (0.1) -- deyarli filtrsiz.
Analogiya: Bozorda olma izlayapsiz. Similarity threshold -- bu "kamida 30% olma bo'lishi kerak" degan talab. 100% olma -- ideal. 50% olma -- qabul qilinadi. 10% olma -- bu allaqachon boshqa narsa.
7.2 Keyword Search (Kalit So'z Qidirish) va BM25
Semantic search har doim ham eng yaxshi emas. Ba'zi hollarda aniq so'z qidirish samaraliroq.
Misol: foydalanuvchi "IBM Power S822" deb yozdi. Bu aniq mahsulot nomi. Semantic search bu so'zlarni "server uskunalari" kontekstiga joylaydi va umuman boshqa server chunk-larini ham qaytarishi mumkin. Keyword search esa aniq "IBM Power S822" yozilgan chunk-larni topadi.
BM25 (Best Matching 25) -- eng mashhur keyword search algoritmi. Ikki asosiy tushuncha:
- Term Frequency (TF): so'z chunk ichida qancha marta uchragan. Ko'p uchrasa -- muhimroq.
- Inverse Document Frequency (IDF): so'z barcha chunk-larda qanchalik kam uchragan. Kam uchrasa -- ko'proq farqlovchi.
Formula soddalashtirilgan ko'rinishda:
BM25(chunk, query) = sum(
IDF(term) * (TF(term, chunk) * (k1 + 1)) / (TF(term, chunk) + k1)
for term in query
)
Bu yerda k1 -- odatda 1.2 qilib belgilanadi. "the", "va", "bu" kabi har joyda uchraydigan so'zlar IDF past bo'lgani uchun past ball oladi. "IBM", "S822" kabi noyob so'zlar yuqori ball oladi.
PostgreSQL-da BM25-ni tsvector va tsquery orqali yoki pg_bm25 extension bilan amalga oshirish mumkin.
7.3 Hybrid Search va Reciprocal Rank Fusion (RRF)
Kodlar: Fusion Retrieval — vector + BM25, RRF bilan
1. Nega bitta usul yetarli emas
Vector search ma'noni topadi, lekin aniq atamani o'tkazib yuboradi. Keyword search aniq so'zni topadi, lekin sinonimni tushunmaydi.
- "MPLS-TE konfiguratsiyasi" — vector search "tarmoq sozlamalari" ni ham qaytaradi, kerakli hujjat 5-o'rinda
- "tarmoqni tezlashtirish" — keyword search hech narsa topmaydi, chunki hujjatda "optimizatsiya" deb yozilgan
Yechim — ikkalasini birga ishlatish va natijalarni birlashtirish.
2. PostgreSQL da keyword search
Alohida kutubxona kerak emas — PostgreSQL da ts_rank bor. Rus va ingliz tili uchun tayyor konfiguratsiya mavjud; o'zbek tili uchun simple ishlatamiz (stemming yo'q, lekin ishlaydi).
KEYWORD_SQL = (
"SELECT id, title, content, "
" ts_rank(to_tsvector('simple', content), "
" plainto_tsquery('simple', :q)) AS score "
"FROM chunks "
"WHERE to_tsvector('simple', content) @@ plainto_tsquery('simple', :q) "
"ORDER BY score DESC "
"LIMIT :k"
)
def keyword_search(session, question: str, top_k: int = 20) -> list[dict]:
rows = session.execute(text(KEYWORD_SQL),
{"q": question, "k": top_k}).mappings().all()
return [dict(r) for r in rows]3. RRF — ballarni emas, o'rinlarni qo'shish
Bu eng muhim g'oya. Vector search 0.82 beradi, ts_rank 0.043 beradi — bu ikki son umuman taqqoslanmaydi. Ularni qo'shish yoki o'rtacha olish matematik jihatdan ma'nosiz.
RRF ballarni tashlab yuboradi va faqat o'rinni ishlatadi:
score = Σ 1 / (k + rank)
k = 60 — standart qiymat. U birinchi o'rinlar orasidagi farqni yumshatadi: 1-o'rin 0.0164, 2-o'rin 0.0161 — ya'ni bitta ro'yxatdagi 1-o'rin boshqasidagi 3-o'rinni avtomatik bosib ketmaydi.
def rrf_fuse(vector_hits: list[dict], keyword_hits: list[dict],
k: int = 60, top_k: int = 10) -> list[dict]:
scores: dict[int, float] = {}
store: dict[int, dict] = {}
for rank, hit in enumerate(vector_hits, start=1):
scores[hit["id"]] = scores.get(hit["id"], 0.0) + 1.0 / (k + rank)
store[hit["id"]] = hit
for rank, hit in enumerate(keyword_hits, start=1):
scores[hit["id"]] = scores.get(hit["id"], 0.0) + 1.0 / (k + rank)
store.setdefault(hit["id"], hit)
ordered = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
out = []
for chunk_id, score in ordered[:top_k]:
row = dict(store[chunk_id])
row["rrf_score"] = score
out.append(row)
return outdef hybrid_search(session, question: str, top_k: int = 10) -> list[dict]:
emb, _ = generate_query_embedding(question)
vector_hits = retrieve_chunks(session, emb, top_k=20)
keyword_hits = keyword_search(session, question, top_k=20)
return rrf_fuse(vector_hits, keyword_hits, top_k=top_k)
question = "MPLS-TE konfiguratsiyasi"
for i, c in enumerate(hybrid_search(session, question, top_k=5), 1):
print(f"{i}. {c['rrf_score']:.5f} {c['title'][:45]}")1. 0.03252 Sharq Telekom MPLS-TE sozlamalari 2. 0.01639 Tarmoq optimizatsiyasi bo'yicha hisobot 3. 0.01613 Ucell tarmoq monitoringi loyihasi 4. 0.01587 Telekom yo'nalishidagi tajribamiz 5. 0.01562 Router konfiguratsiya shabloni
4. Nima sodir bo'ldi?
1-o'rindagi hujjat ikkala ro'yxatda ham yuqori bo'lgan — shuning uchun ball ikki barobar (0.0325 ≈ 0.0164 × 2). Qolganlar faqat bitta ro'yxatda uchragan.
Bu RRF ning asosiy xususiyati: ikkala usul ham rozi bo'lgan natija yuqoriga ko'tariladi. Faqat bitta usul topgan natija ham yo'qolmaydi, lekin pastroqda turadi.
Weighted variant: agar vector search sizning domeningizda aniq yaxshiroq ishlasa,
1.5 / (k + rank) qilib og'irlik bering — lekin buni ham taxmin bilan emas, evaluation bilan tasdiqlang.Eng yaxshi yondashuv -- ikkalasini birlashtirish. Vector search ma'noni tushunadi, keyword search aniq so'zlarni topadi. Ularni birlashtirish uchun Reciprocal Rank Fusion (RRF) formulasi ishlatiladi.
RRF formulasi
rrf_score = 1/(k + rank_vector) + 1/(k + rank_keyword)
Bu yerda k = 60 (standart qiymat). rank_vector -- chunk-ning vector search natijalaridagi o'rni. rank_keyword -- keyword search natijalaridagi o'rni.
Misol bilan tushuntirish
Faraz qiling, bitta chunk vector search-da 3-o'rinda, keyword search-da 5-o'rinda topildi:
rrf_score = 1/(60 + 3) + 1/(60 + 5)
= 1/63 + 1/65
= 0.01587 + 0.01538
= 0.03125
Boshqa chunk vector search-da 1-o'rinda, lekin keyword search-da topilmagan (rank = 1000):
rrf_score = 1/(60 + 1) + 1/(60 + 1000)
= 1/61 + 1/1060
= 0.01639 + 0.00094
= 0.01733
Birinchi chunk (0.03125) ikkinchisidan (0.01733) yuqori ball oldi, chunki u ikkala usulda ham yaxshi natija berdi. RRF ikki ro'yxatning "konsensus"ini topadi.
Analogiya: Ikki hakam bor -- biri ovozni baholaydi, biri raqsni. Ikkalasida ham yuqori o'rin olgan ishtirokchi eng kuchli.
7.4 Query Expansion (So'rov Kengaytirish)
Kodlar: Query Transformations — savolni qayta yozish
1. Savol yomon bo'lsa, retrieval ham yomon
Foydalanuvchi "u qancha turadi?" deb yozadi. Bu savolda hech qanday qidiriladigan so'z yo'q. Embedding ham bo'sh chiqadi.
Uchta mustaqil yechim bor. Ular bir-birini almashtirmaydi — har biri boshqa muammoni hal qiladi.
2. Usul 1 — Query Rewriting
Suhbat tarixidan foydalanib, savolni o'zi yetarli holga keltiramiz. "u" nima ekanini oldingi xabarlardan topamiz.
REWRITE = (
"Quyidagi suhbat asosida oxirgi savolni to'liq, mustaqil savolga aylantir. "
"Olmoshlarni (u, bu, o'sha) aniq nom bilan almashtir. "
"Faqat yangi savolni qaytar, izoh yozma.\n\n"
"Suhbat:\n{history}\n\nOxirgi savol: {question}"
)
def rewrite_query(question: str, history: list[dict]) -> str:
if not history:
return question
lines = "\n".join(f"{m['role']}: {m['content'][:200]}" for m in history[-4:])
out = _ollama_chat(
[{"role": "user", "content": REWRITE.format(history=lines, question=question)}],
temperature=0.0, max_tokens=80,
)
return (out or question).strip()
history = [
{"role": "user", "content": "Ucell billing loyihasi haqida gapirib bering"},
{"role": "assistant", "content": "Nihol Ucell uchun billing tizimini yangilagan..."},
]
print(rewrite_query("u qancha vaqt oldi?", history))Ucell billing tizimi modernizatsiyasi loyihasi qancha vaqt olgan?
3. Usul 2 — Step-back Prompting
Savol juda tor bo'lsa, retrieval kontekstni topolmaydi. Step-back bir qadam orqaga chekinadi va umumiyroq savol beradi.
Ikkala savol bilan ham qidiriladi: tor savol aniq faktni, keng savol umumiy kontekstni topadi.
STEP_BACK = (
"Quyidagi savoldan bir pog'ona umumiyroq savol yoz. "
"Maqsad — kerakli fon ma'lumotini topish. "
"Faqat savolni qaytar.\n\nSavol: {question}"
)
def step_back_query(question: str) -> str:
out = _ollama_chat(
[{"role": "user", "content": STEP_BACK.format(question=question)}],
temperature=0.2, max_tokens=60,
)
return (out or question).strip()
q = "Ucell billing modulida qaysi to'lov shlyuzi ishlatilgan?"
print("Tor :", q)
print("Keng :", step_back_query(q))Tor : Ucell billing modulida qaysi to'lov shlyuzi ishlatilgan? Keng : Ucell billing tizimi qanday komponentlardan tashkil topgan?
4. Usul 3 — Sub-query Decomposition
Savolda bir nechta talab bo'lsa, uni bo'laklarga ajratamiz. "Ucell va Sharq Telekom loyihalarini solishtiring" — bu ikkita alohida qidiruv.
DECOMPOSE = (
"Quyidagi savolni mustaqil qidiriladigan kichik savollarga ajrat. "
"Har birini yangi qatorda yoz, raqamlama, izoh yozma. "
"Agar savol allaqachon oddiy bo'lsa — o'zini qaytar.\n\nSavol: {question}"
)
def decompose_query(question: str, max_parts: int = 4) -> list[str]:
out = _ollama_chat(
[{"role": "user", "content": DECOMPOSE.format(question=question)}],
temperature=0.2, max_tokens=150,
)
parts = [p.strip(" -•") for p in (out or "").split("\n") if len(p.strip()) > 10]
return parts[:max_parts] or [question]
for p in decompose_query("Ucell va Sharq Telekom loyihalarini narx bo'yicha solishtiring"):
print("-", p)- Ucell loyihasi qancha turgan? - Sharq Telekom loyihasi qancha turgan? - Ikkala loyihaning ko'lami qanday farq qilgan?
5. Qaysi biri qachon?
Uchalasini har doim ishlatmang — bu 3 ta qo'shimcha LLM chaqiruvi degani.
- Rewriting — suhbat tarixi bo'lsa, doim. Eng arzon va eng foydali.
- Step-back — retrieval past ball qaytarganda (fallback).
- Decomposition — savolda "va", "solishtiring", "farqi nima" bo'lsa.
Foydalanuvchilar ko'pincha slang yoki qisqartma ishlatadi. "Железка" desa, "temir yo'l loyihalari" ni nazarda tutadi. Bunga ikki xil yondashuv bor.
1-yondashuv: statik dictionary mapping (loyihaning oldingi versiyasida ishlatilgan):
SLANG_MAP = { "железка": "железная дорога TEMIR YOLLARI железнодорожные проекты", "телеком": "телекоммуникации Ucell COSCOM Beeline связь телеком Sharq Telekom", "банки": "банковские проекты банк Узпромстройбанк Orient Finans Sanoat Qurilish HUMO", "свифт": "SWIFT международная платежная система банковские переводы", }
Tez (0 ms) va arzon, lekin lug'atni qo'lda yuritish kerak -- yangi slang paydo bo'lsa, kod o'zgartiriladi.
2-yondashuv: LLM-based expansion (joriy versiyada ishlatiladi). Savol LLM-ga beriladi va undan qidiruvga mos kengaytirilgan so'rov so'raladi -- sinonimlar, ochilgan qisqartmalar, kalit terminlar bilan:
def expand_query(question: str) -> str: prompt = ( "Перепиши вопрос пользователя в развёрнутый поисковый запрос " "для векторного поиска по документам. Раскрой сокращения, " "добавь синонимы и ключевые термины. Сохрани язык вопроса. " "Верни только переписанный запрос одной строкой.\n\n" f"Вопрос: {question}" ) try: resp = httpx.post( f"{settings.OLLAMA_URL}/api/generate", json={ "model": settings.CHAT_MODEL, "prompt": prompt, "stream": False, "think": False, "options": {"temperature": 0.0, "num_predict": 160}, }, timeout=120, ) out = resp.json().get("response", "").strip() return out or question except Exception: return question
Nega statik mapping emas, LLM? Chunki: 1. Qamrov: lug'atda yo'q slang, xato yozilgan so'z, yangi atama -- LLM hammasini tushunadi. Statik lug'at faqat oldindan yozilganini biladi. 2. Til mustaqilligi: rus va o'zbek aralash savollar ham to'g'ri kengaytiriladi. 3. Xizmat qilish oson: lug'atni qo'lda yuritish shart emas.
Narxi: har savolga bitta qo'shimcha LLM chaqiruv (temperature=0, qisqa javob -- odatda 1 soniyadan kam). Xato bo'lsa graceful fallback: original savol bilan davom etiladi.
Analogiya: Kichik lug'at kitobcha o'rniga tarjimon yolladik -- chunki foydalanuvchilar lug'atda yo'q so'zlarni ham ishlatishadi.
7.5 Multi-Query Retrieval
Kodlar: Multi-Query Retrieval — bitta savol, besh xil ifoda
1. Muammo — bitta embedding bitta nuqta
Savolning embedding-i vektor fazoda bitta nuqta. Agar shu nuqta biroz noto'g'ri joyda tursa — kerakli hujjat top-5 ga tushmaydi va siz buni hech qachon bilmaysiz.
Multi-query yechimi: bitta savolni bir necha xil ifodalab, bir nechta nuqtadan qidirish.
VARIANTS = (
"Quyidagi savolni 4 xil usulda qayta yoz. "
"Har biri boshqa so'zlar va boshqa uslub ishlatsin, lekin ma'no bir xil qolsin. "
"Har birini yangi qatorda yoz, raqamlama.\n\nSavol: {question}"
)
def generate_variants(question: str, n: int = 4) -> list[str]:
out = _ollama_chat(
[{"role": "user", "content": VARIANTS.format(question=question)}],
temperature=0.7, max_tokens=200,
)
variants = [v.strip(" -•0123456789.") for v in (out or "").split("\n")]
variants = [v for v in variants if len(v) > 10]
return [question] + variants[:n]
for v in generate_variants("Ucell uchun nima qilingan?"):
print("-", v)- Ucell uchun nima qilingan? - Ucell kompaniyasi bilan qanday loyiha amalga oshirilgan? - Ucell telekom operatoriga ko'rsatilgan xizmatlar qanday? - Ucell bilan hamkorlikda bajarilgan ishlar ro'yxati - Ucell uchun ishlab chiqilgan texnik yechimlar
2. Diqqat — temperature=0.7
Bu yerda past temperature zarar qiladi. Bizga xilma-xillik kerak: 5 ta bir xil variant hech narsa bermaydi. Shuning uchun aynan shu joyda temperature yuqori.
3. Natijalarni birlashtirish
Har bir variant bo'yicha alohida qidiramiz, keyin RRF bilan birlashtiramiz. Bir nechta variantda uchragan chunk — ishonchli chunk.
def multi_query_retrieve(session, question: str, top_k: int = 10) -> list[dict]:
variants = generate_variants(question)
scores: dict[int, float] = {}
store: dict[int, dict] = {}
hit_count: dict[int, int] = {}
for variant in variants:
emb, err = generate_query_embedding(variant)
if err or emb is None:
continue
for rank, hit in enumerate(retrieve_chunks(session, emb, top_k=10), start=1):
cid = hit["id"]
scores[cid] = scores.get(cid, 0.0) + 1.0 / (60 + rank)
hit_count[cid] = hit_count.get(cid, 0) + 1
store[cid] = hit
ordered = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
out = []
for cid, score in ordered[:top_k]:
row = dict(store[cid])
row["rrf_score"] = score
row["found_in"] = hit_count[cid]
out.append(row)
return out
for i, c in enumerate(multi_query_retrieve(session, "Ucell uchun nima qilingan?", 5), 1):
print(f"{i}. {c['rrf_score']:.5f} ({c['found_in']}/5 variantda) {c['title'][:40]}")1. 0.08130 (5/5 variantda) Ucell billing tizimi modernizatsiyasi 2. 0.06421 (4/5 variantda) Ucell SMS gateway integratsiyasi 3. 0.04762 (3/5 variantda) Ucell tarmoq monitoringi loyihasi 4. 0.01639 (1/5 variantda) Sharq Telekom tarmoq kengaytirish 5. 0.01587 (1/5 variantda) Telekom yo'nalishidagi tajribamiz
4. found_in ustuni — bepul ishonch o'lchovi
5/5 variantda chiqqan chunk — deyarli aniq to'g'ri. 1/5 da chiqqan — ehtimol tasodif.
Buni filtr sifatida ishlatish mumkin: found_in >= 2 shartini qo'ysangiz, oxirgi ikki qator (Sharq Telekom va umumiy sahifa) tushib qoladi.
Qachon arziydi: recall muhim bo'lganda — huquqiy qidiruv, tibbiy ma'lumot, audit. Chat-bot da real vaqt javob kerak bo'lsa — bu texnika og'irlik qiladi.
Bitta savol uchun bir nechta qidirish o'tkazish va natijalarni birlashtirish. Har bir query alohida embedding-ga aylantiriladi, har biri bilan qidirish o'tkaziladi, keyin natijalar birlashtiriladi -- har bir chunk uchun eng yuqori similarity saqlanadi.
Eslatma: loyihaning oldingi versiyasida aynan shu yondashuv ishlatilgan (multi_retrieve). Joriy versiyada esa LLM kengaytirgan BITTA so'rov bilan qidiriladi -- bitta embedding, bitta SQL so'rov. Bu soddaroq va tezroq; sifat yo'qotishi kuzatilmadi, chunki kengaytirilgan so'rov bir o'zida bir nechta query vazifasini bajaradi. Multi-query texnikasi baribir bilishga arziydi:
def multi_retrieve( session: Session, queries: list[str], top_k: int | None = None, threshold: float | None = None, broad: bool = False, lang_code: str | None = None, ) -> list[dict]: settings = get_settings() top_k = top_k or settings.RAG_TOP_K result_limit = top_k * 2 if broad else top_k all_chunks = {} for query in queries: embedding = generate_query_embedding(query) if embedding is None: continue chunks = retrieve_chunks(session, embedding, top_k, threshold, lang_code) for c in chunks: cid = c["chunk_id"] if cid not in all_chunks or c["similarity"] > all_chunks[cid]["similarity"]: all_chunks[cid] = c merged = sorted(all_chunks.values(), key=lambda x: x["similarity"], reverse=True) return merged[:result_limit]
Muhim nuqtalar:
- all_chunks dictionary sifatida -- chunk_id kalit. Agar bitta chunk ikki xil query-dan topilsa, eng yuqori similarity saqlanadi.
- broad=True bo'lganda result_limit = top_k * 2. Keng so'rovlar (masalan "2010 yilgacha bo'lgan loyihalar") ko'proq chunk talab qiladi.
- Natijalar similarity bo'yicha kamayish tartibida tartiblangan.
Misol (multi-query yondashuvida): foydalanuvchi "железка" deb yozdi. Ikkita query hosil bo'ladi: 1. "железка" (original) 2. "железка железная дорога TEMIR YOLLARI железнодорожные проекты" (expanded)
Har biri bilan 10 tadan chunk olinadi. Takroriy chunk-lar birlashtiriladi. Natijada 10-15 ta unikal chunk qoladi.
Joriy versiyada esa xuddi shu natijaga bitta qidiruv bilan erishiladi: LLM "железка" ni "железная дорога, темир йўллари, железнодорожные проекты" kabi kengaytirilgan so'rovga aylantiradi va o'sha BITTA so'rov embedding qilinadi.
7.6 Language Filtering (Til bo'yicha Filtrlash)
Nihol loyihasida kontent ikki tilda: rus va o'zbek. Agar foydalanuvchi ruscha yozsa, o'zbekcha chunk-lar shovqin bo'ladi. Til aniqlash oddiy:
def detect_lang(text: str) -> str: cyrillic = sum(1 for c in text if "\u0400" <= c <= "\u04ff") latin = sum(1 for c in text if "a" <= c.lower() <= "z") if cyrillic > latin: return "RU" return "UZ"
Kirill harflari ko'p bo'lsa -- rus tili. Lotin harflari ko'p bo'lsa -- o'zbek tili. Bu sodda, lekin samarali.
Filtrlash SQL darajasida amalga oshiriladi:
SELECT ch.id, ch.chunk_text, 1 - (ch.embedding_local <=> :vec) AS similarity FROM wde.lv_content_chunks ch JOIN wde.lv_extracted_content c ON c.id = ch.content_id JOIN wde.lv_target_pages p ON p.id = c.page_id JOIN wde.lv_languages l ON l.id = p.language_id WHERE ch.embedding_local IS NOT NULL AND l.code = :lang ORDER BY ch.embedding_local <=> :vec LIMIT :lim
JOIN lv_languages WHERE code = :lang -- bu universal yondashuv. Tillar bazada saqlangan, hardcode qilinmagan. Yangi til qo'shish uchun faqat bazaga qo'shish kifoya.
7.7 HyDE (Hypothetical Document Embeddings)
Kodlar: HyDE — Hypothetical Document Embeddings
1. Muammo — savol va hujjat bir xil tilda yozilmaydi
Semantic search savolning embedding-ini hujjatlar embedding-i bilan taqqoslaydi. Lekin savol va javob grammatik jihatdan butunlay boshqacha bo'ladi.
- Savol: "Ucell uchun nima qilingan?" — qisqa, so'roq shaklida, fe'l oxirida.
- Hujjat: "Ucell uchun billing tizimi modernizatsiyasi amalga oshirildi..." — uzun, darak gap, atamalar bilan to'la.
Ikkalasining ma'nosi bir xil, lekin vektor fazoda ular uzoqroq turadi. Chunki embedding model gap strukturasini ham hisobga oladi, faqat ma'noni emas.
2. G'oya — savolni emas, faraziy javobni qidiring
HyDE bir og'iz gapda: avval LLM ga soxta javob yozdiring, keyin o'sha soxta javobni embedding qilib qidiring.
Soxta javob haqiqiy hujjatlar bilan bir xil uslubda yoziladi — darak gap, xuddi shu atamalar, xuddi shu uzunlik. Shuning uchun uning vektori haqiqiy hujjat vektorlariga ancha yaqin tushadi.
Analogiya: Do'kondan kitob izlayapsiz, nomini bilmaysiz. "Qizil muqovali, tarix haqida" desangiz — sotuvchi topolmaydi. Lekin "Bu kitobda O'zbekiston tarixi XVI asrdan boshlanadi" deb kitobning o'zidek gapirsangiz — darrov topadi. HyDE aynan shuni qiladi.
from sqlalchemy.orm import Session
from app.services.chat import _ollama_chat, generate_query_embedding
from app.services.retrieval import retrieve_chunks
HYDE_PROMPT = (
"Quyidagi savolga javob bo'la oladigan qisqa, faktik paragraf yoz. "
"Bilmasang ham 'bilmayman' deb yozma — ishonarli javob yozaver. "
"Faqat paragrafni qaytar, boshqa hech narsa yozma.
"
"Savol: {question}"
)3. Faraziy javobni yaratish
Birinchi qadam — LLM dan soxta javob olish. Temperature past (0.3) bo'lishi kerak: bizga ijod emas, hujjatga o'xshash quruq matn kerak. max_tokens ham kichik (200) — uzun matn embedding-ni "suyultiradi".
def generate_hypothetical_answer(question: str) -> str:
prompt = HYDE_PROMPT.format(question=question)
answer = _ollama_chat(
[{"role": "user", "content": prompt}],
temperature=0.3,
max_tokens=200,
)
return (answer or "").strip()
q = "Nihol kompaniyasi Ucell uchun qanday loyiha qilgan?"
print(generate_hypothetical_answer(q))Nihol kompaniyasi Ucell telekommunikatsiya operatori uchun billing tizimini modernizatsiya qilish loyihasini amalga oshirdi. Loyiha doirasida abonentlarni hisob-kitob qilish moduli yangilandi, to'lov shlyuzi integratsiya qilindi va tizim unumdorligi oshirildi. Loyiha 2023-yilda yakunlandi.
4. To'liq HyDE retrieval
Endi uchta qadamni birlashtiramiz: faraziy javob → embedding → pgvector qidiruv. Diqqat qiling — embedding savoldan emas, faraziy javobdan olinadi.
def hyde_retrieve(session: Session, question: str, top_k: int = 10) -> list[dict]:
hypothetical = generate_hypothetical_answer(question)
if not hypothetical:
embedding, _ = generate_query_embedding(question)
return retrieve_chunks(session, embedding, top_k)
embedding, err = generate_query_embedding(hypothetical)
if err or embedding is None:
embedding, _ = generate_query_embedding(question)
return retrieve_chunks(session, embedding, top_k)5. Haqiqatan ishlayaptimi? — A/B taqqoslash
Texnikani ko'r-ko'rona ishonmang. Oddiy retrieval va HyDE ni bir xil savolda yonma-yon solishtiramiz.
def compare_hyde(session: Session, question: str, top_k: int = 5):
plain_emb, _ = generate_query_embedding(question)
plain = retrieve_chunks(session, plain_emb, top_k)
hyde = hyde_retrieve(session, question, top_k)
print(f"SAVOL: {question}
")
print(f"{'#':<3} {'ODDIY':<42} {'HyDE':<42}")
print("-" * 90)
for i in range(top_k):
a = plain[i]["title"][:40] if i < len(plain) else "-"
b = hyde[i]["title"][:40] if i < len(hyde) else "-"
print(f"{i+1:<3} {a:<42} {b:<42}")
plain_ids = {c["id"] for c in plain}
hyde_ids = {c["id"] for c in hyde}
overlap = len(plain_ids & hyde_ids) / max(1, len(plain_ids))
print(f"
Ust-ust tushishi: {overlap:.0%}")
compare_hyde(session, "Ucell uchun nima qilingan?")SAVOL: Ucell uchun nima qilingan? # ODDIY HyDE ------------------------------------------------------------------------------------------ 1 Sharq Telekom tarmoq kengaytirish Ucell billing tizimi modernizatsiyasi 2 Ucell billing tizimi modernizatsiyasi Ucell SMS gateway integratsiyasi 3 EMAN GROUP telekom yechimlari Ucell tarmoq monitoringi loyihasi 4 Ucell SMS gateway integratsiyasi Ucell billing hisobot moduli 5 Telekom sohasidagi tajribamiz Sharq Telekom tarmoq kengaytirish Ust-ust tushishi: 60%
6. Natijani o'qish
Oddiy retrieval-da 1-o'rinni Sharq Telekom egallagan — chunki "telekom" mavzusi yaqin, lekin savolga aloqasi yo'q. HyDE dan keyin birinchi uchta natija ham Ucell haqida.
Sabab: faraziy javobda "billing", "abonent", "to'lov shlyuzi" kabi aniq atamalar paydo bo'ldi. Bu atamalar haqiqiy Ucell hujjatlarida bor, Sharq Telekom hujjatlarida yo'q.
Ishlating: savol qisqa va noaniq bo'lganda; foydalanuvchi domen atamalarini bilmaganda; oddiy retrieval past similarity (< 0.7) qaytarganda — ya'ni fallback sifatida.
Ishlatmang: savol allaqachon uzun va atamalarga boy bo'lsa; streaming javobda birinchi token tezligi muhim bo'lsa; LLM kichik model bo'lsa (soxta javob sifatsiz chiqadi va retrieval-ni buzadi).
Oddiy semantic search-da savol va hujjat embedding-lari taqqoslanadi. Muammo: savol va javob farqli tilda yoziladi. "Ucell uchun nima qilingan?" savoli va "Ucell uchun bilingov tizim joriy etildi" hujjati -- ma'no yaqin, lekin grammatik struktura boshqa.
HyDE yondashuvi: savolni to'g'ridan-to'g'ri embedding qilish o'rniga, LLM-dan faraziy javob yozishni so'rang, keyin o'sha javobni embedding qiling.
def hyde_retrieve(session, question, top_k=10): # 1-qadam: LLM faraziy javob yozadi hyde_prompt = ( "Write a short factual paragraph that would answer this question. " "Do not say you don't know. Just write a plausible answer.\n\n" f"Question: {question}" ) hypothetical_answer = _ollama_chat( [{"role": "user", "content": hyde_prompt}], temperature=0.3, max_tokens=200, ) # 2-qadam: Faraziy javobni embedding qilamiz embedding = generate_query_embedding(hypothetical_answer) # 3-qadam: Embedding bilan qidiramiz return retrieve_chunks(session, embedding, top_k)
Nega ishlaydi? Faraziy javob haqiqiy hujjatlarga o'xshash formatda yozilgan. Shuning uchun uning embedding-i hujjat embedding-lariga yaqinroq bo'ladi.
Analogiya: Do'kondan kitob izlayapsiz, lekin nomini bilmaysiz. Faqat "qizil muqovali, qalin, tarix haqida" desangiz -- qiyin. Lekin "Bu kitobda O'zbekiston tarixi XVI asrdan XX asrgacha yoritilgan" desangiz -- topish osonroq. HyDE aynan shuni qiladi.
Kamchilik: qo'shimcha LLM chaqiruv -- 1-3 soniya qo'shiladi. Shuning uchun faqat oddiy retrieval yaxshi natija bermaganda ishlatiladi.
7.8 Self-Query (O'z-o'zini So'rash)
Kodlar: Self-Query — savoldan SQL filtr ajratish
1. Savol ichida ikki xil talab bor
"2023 yildan keyingi bank loyihalari" — bu bitta savol emas, ikkitasi:
- Semantik: "bank loyihalari" → embedding qidiruvi
- Strukturaviy: yil > 2023 → SQL
WHERE
Embedding sanani tushunmaydi. "2023" so'zi vektorda bor, lekin "2024 > 2023" degan mantiq yo'q. Shuning uchun sanani SQL ga bermaymiz — retrieval xato qiladi.
2. LLM ni parser sifatida ishlatish
Muhim: LLM ga qanday maydonlar borligini aytish shart. Aks holda u o'zi maydon o'ylab topadi va SQL sinadi.
import json
SCHEMA = {
"year": "integer — loyiha yili, masalan 2023",
"sector": "string — soha: bank, telekom, davlat, ta'lim, tibbiyot",
"status": "string — holat: tugagan, jarayonda",
}
PARSE = (
"Savoldan qidiruv qismini va filtrlarni ajrat.\n"
"Mavjud maydonlar:\n{schema}\n\n"
"JSON qaytar, boshqa hech narsa yozma:\n"
'{{"query": "semantik qism", "filters": {{"maydon": ["operator", qiymat]}}}}\n'
"Operatorlar: eq, gt, gte, lt, lte\n"
"Filtr yo'q bo'lsa filters bo'sh obyekt bo'lsin.\n\n"
"Savol: {question}"
)
def parse_self_query(question: str) -> dict:
schema_text = "\n".join(f"- {k}: {v}" for k, v in SCHEMA.items())
out = _ollama_chat(
[{"role": "user", "content": PARSE.format(schema=schema_text, question=question)}],
temperature=0.0, max_tokens=200,
)
try:
start, end = out.index("{"), out.rindex("}") + 1
parsed = json.loads(out[start:end])
except (ValueError, json.JSONDecodeError):
return {"query": question, "filters": {}}
clean = {k: v for k, v in parsed.get("filters", {}).items() if k in SCHEMA}
return {"query": parsed.get("query") or question, "filters": clean}
print(parse_self_query("2023 yildan keyingi tugagan bank loyihalari")){'query': 'bank loyihalari',
'filters': {'year': ['gt', 2023], 'sector': ['eq', 'bank'], 'status': ['eq', 'tugagan']}}3. Filtrni xavfsiz SQL ga aylantirish
Eng muhim qism. LLM chiqargan matnni to'g'ridan-to'g'ri SQL ga qo'shish — SQL injection. Biz faqat oldindan ruxsat etilgan maydon nomlari va operatorlarni ishlatamiz, qiymatlar esa har doim parametr orqali uzatiladi.
OPERATORS = {"eq": "=", "gt": ">", "gte": ">=", "lt": "<", "lte": "<="}
def build_where(filters: dict) -> tuple[str, dict]:
clauses, params = [], {}
for i, (field, rule) in enumerate(filters.items()):
if field not in SCHEMA or not isinstance(rule, list) or len(rule) != 2:
continue
op, value = rule
if op not in OPERATORS:
continue
key = f"p{i}"
clauses.append(f"{field} {OPERATORS[op]} :{key}")
params[key] = value
return (" AND ".join(clauses) or "TRUE"), paramsdef self_query_search(session, question: str, top_k: int = 5) -> list[dict]:
parsed = parse_self_query(question)
where, params = build_where(parsed["filters"])
emb, err = generate_query_embedding(parsed["query"])
if err or emb is None:
return []
sql = (
"SELECT id, title, content, year, sector, "
" 1 - (embedding <=> CAST(:e AS vector)) AS score "
"FROM chunks "
f"WHERE {where} "
"ORDER BY embedding <=> CAST(:e AS vector) "
"LIMIT :k"
)
params.update({"e": str(emb), "k": top_k})
rows = session.execute(text(sql), params).mappings().all()
return [dict(r) for r in rows]
for c in self_query_search(session, "2023 yildan keyingi bank loyihalari"):
print(f"{c['year']} {c['sector']:<10} {c['score']:.3f} {c['title'][:40]}")2024 bank 0.812 Ipoteka arizalarini avtomatlashtirish 2024 bank 0.779 Mobil banking ilovasi yangilanishi 2025 bank 0.741 Kredit skoring moduli
4. Nima yutdik
Filtrsiz bu savol 2019-yilgi bank loyihalarini ham qaytarardi — chunki ular ham "bank loyihasi" ga semantik o'xshash. Endi ular SQL darajasida kesilib tashlanadi.
Yana bir foyda: filtr qidiruv maydonini kichraytiradi, ya'ni qolgan natijalar sifati oshadi.
build_where funksiyasidagi if field not in SCHEMA va if op not in OPERATORS tekshiruvlarini hech qachon olib tashlamang. Ularsiz LLM chiqargan matn to'g'ridan-to'g'ri SQL ga tushadi.
Ehtiyot bo'ling: kichik modellar JSON ni noto'g'ri chiqaradi.
try/except fallback (filtrsiz oddiy qidiruv) — majburiy.Foydalanuvchi tabiiy tilda strukturalangan filtrlarni berishi mumkin. Masalan: "2020 yildan keyingi bank loyihalari". Bu yerda ikki narsa bor: 1. Semantik qism: "bank loyihalari" 2. Filtr qism: yil > 2020
Self-query LLM yordamida bu ikki qismni ajratadi:
def self_query_parse(question: str) -> dict: prompt = ( "Extract structured filters from this question. " "Return JSON with 'query' (semantic part) and 'filters' (structured part).\n\n" f"Question: {question}\n\n" "Example output: " '{"query": "bank projects", "filters": {"year": ">2020", "topic": "banking"}}' ) result = _ollama_chat( [{"role": "user", "content": prompt}], temperature=0.0, max_tokens=150, ) try: return json.loads(result) except json.JSONDecodeError: return {"query": question, "filters": {}}
Nihol loyihasida biz buning o'rniga regex-asoslangan yondashuvni tanladik -- tezroq va LLM chaqiruv kerak emas:
YEAR_RANGE_PATTERN = re.compile(r"(?:до|before)\s+(\d{4})", re.IGNORECASE) YEAR_RANGE_AFTER = re.compile(r"(?:после|after|с|from)\s+(\d{4})", re.IGNORECASE) YEAR_EXACT = re.compile(r"\b(19\d{2}|20[0-2]\d)\b")
"2010 yilgacha bo'lgan loyihalar" -- regex yilni ajratadi, keyin expand_query yil bo'yicha qo'shimcha query-lar yaratadi. LLM-ga murojaat qilmasdan, millisekundlarda ishlaydi.
7.9 Retrieval Strategiyalarini Tanlash
| Usul | Qachon ishlatiladi | Tezlik | Aniqlik |
|---|---|---|---|
| Semantic search | Umumiy savollar | Tez | O'rta-yuqori |
| Keyword search | Aniq nomlar, kodlar | Tez | Aniq moslik uchun yuqori |
| Hybrid search | Ko'p hollarda eng yaxshi | O'rta | Yuqori |
| Query expansion | Slang, qisqartmalar | Tez | Yuqori |
| Multi-query | Keng savollar | Sekin (n * embedding) | Yuqori |
| Language filtering | Ko'p tilli kontent | Tez (SQL filtr) | Shovqinni kamaytiradi |
| HyDE | Qisqa/noaniq savollar | Sekin (LLM + embedding) | Ba'zan yuqori |
| Self-query | Strukturalangan filtrlar | O'rta | Aniq filtrlar uchun yuqori |
Nihol loyihasidagi joriy tanlov: LLM query expansion + language filtering + rubrika filtri + sof vector search (top_k=15). Bu kombinatsiya bizning use case uchun sifat va tezlikning eng yaxshi balansini berdi. Multi-query, HyDE va self-query qo'shimcha murakkablik va kechikish qo'shadi -- bizning kontent hajmi uchun kengaytirilgan bitta so'rov yetarli.
Xulosa: Retrieval -- butun RAG pipeline-ning fundamenti. Yaxshi retrieval -- yaxshi javob. Yomon retrieval -- hech narsa tuzatolmaydi. Shuning uchun har bir yangi RAG loyiha retrieval-dan boshlang va uni puxta sinab ko'ring.
</> Loyihaning haqiqiy kodi — bu bob amalda qanday ishlaydi
app/services/chat.py — to'liq retrieval zanjiri
def detect_lang(text_input: str) -> str:
if any(c in _UZ_CYRILLIC for c in text_input):
return "UZ"
if any("\u0400" <= c <= "\u04ff" for c in text_input):
return "RU"
return "UZ"
def expand_query(question: str) -> str:
settings = get_settings()
prompt = (
"Перепиши вопрос пользователя в развёрнутый поисковый запрос для векторного поиска по документам. "
"Раскрой сокращения, добавь синонимы и ключевые термины. Сохрани язык вопроса. "
"Верни только переписанный запрос одной строкой, без пояснений и кавычек.\n\n"
f"Вопрос: {question}"
)
try:
resp = httpx.post(
f"{settings.OLLAMA_URL}/api/generate",
json={
"model": settings.CHAT_MODEL,
"prompt": prompt,
"stream": False,
"think": False,
"options": {
"temperature": 0.0,
"num_predict": 160,
"repeat_penalty": 1.3,
"repeat_last_n": 64,
},
},
timeout=120,
)
resp.raise_for_status()
out = resp.json().get("response", "").strip()
out = re.sub(r"<think>[\s\S]*?</think>", "", out).strip()
if out:
seen = []
for part in (p.strip() for p in out.split(",")):
if part and part.lower() not in {s.lower() for s in seen}:
seen.append(part)
out = ", ".join(seen[:12])
return out or question
except Exception:
return question
def generate_query_embedding(text_input: str) -> tuple[list[float] | None, str | None]:
settings = get_settings()
last_error = None
for attempt in range(3):
try:
resp = httpx.post(
f"{settings.EMBED_OLLAMA_URL}/api/embed",
json={"model": settings.EMBED_MODEL, "input": text_input, "keep_alive": -1},
timeout=60,
)
resp.raise_for_status()
return resp.json()["embeddings"][0], None
except Exception as e:
last_error = f"{type(e).__name__}: {e}"
if attempt < 2:
time.sleep(2 ** attempt)
return None, last_error
def retrieve_chunks(
session: Session,
query_embedding: list[float],
top_k: int | None = None,
threshold: float | None = None,
lang_code: str | None = None,
rubrics: list[str] | None = None,7.10 HyPE — HyDE ning teskarisi
Kodlar: HyPE — indeks vaqtida savol yasash
1. Chunk dan savollar
HyDE dan farqli — bu funksiya indekslashda ishlaydi, qidiruvda emas. Shuning uchun uning sekinligi foydalanuvchiga ko'rinmaydi.
HYPE_PROMPT = (
"Quyidagi matn qaysi savollarga javob beradi? "
"{n} ta aniq savol yoz. Har birini yangi qatorda, raqamlamasdan. "
"Savollar matndagi turli faktlarga tegsin.\n\nMatn: {chunk}"
)
def generate_hypothetical_prompts(chunk: str, n: int = 4) -> list[str]:
out = _ollama_chat(
[{"role": "user", "content": HYPE_PROMPT.format(n=n, chunk=chunk)}],
temperature=0.4, max_tokens=300,
)
items = [q.strip(" -•0123456789.") for q in (out or "").split("\n")]
return [q for q in items if q.endswith("?") and len(q) > 15][:n]2. Chunk matnini embedding qilmaymiz
Mana HyPE ning asosiy qarori: vector ustunida savol embedding-i turadi, chunk matni esa oddiy matn ustunida saqlanadi.
Bitta chunk uchun 4 ta yozuv — 4 ta turli savol, hammasi bir xil chunk_id ga ishora qiladi.
HYPE_SQL = (
"INSERT INTO hype_index (chunk_id, question, chunk_text, embedding) "
"VALUES (:cid, :q, :body, :e)"
)
def index_hype(session, title: str, body: str) -> dict:
stats = {"chunks": 0, "questions": 0}
for chunk in chunk_text(body, size=800):
chunk_id = session.execute(
text(PARENT_SQL), {"t": title, "c": chunk}
).scalar_one()
stats["chunks"] += 1
for question in generate_hypothetical_prompts(chunk):
emb, err = generate_query_embedding(question)
if err or emb is None:
continue
session.execute(text(HYPE_SQL), {
"cid": chunk_id, "q": question, "body": chunk, "e": str(emb),
})
stats["questions"] += 1
session.commit()
return stats
body = open("nihol_corpus.txt", encoding="utf-8").read()
print(index_hype(session, "Nihol korpusi", body)){'chunks': 451, 'questions': 1763}3. Qidiruv — hech qanday LLM chaqiruvisiz
Diqqat qiling: bu funksiyada _ollama_chat yo'q. Faqat embedding va SQL. HyDE da esa har so'rovda LLM chaqiriladi.
Bitta chunk bir nechta savol orqali topilishi mumkin, shuning uchun chunk_id bo'yicha dedup qilamiz.
HYPE_SEARCH = (
"SELECT chunk_id, question, chunk_text, "
" 1 - (embedding <=> CAST(:e AS vector)) AS score "
"FROM hype_index "
"ORDER BY embedding <=> CAST(:e AS vector) "
"LIMIT :k"
)
def hype_retrieve(session, question: str, top_k: int = 5) -> list[dict]:
emb, err = generate_query_embedding(question)
if err or emb is None:
return []
rows = session.execute(text(HYPE_SEARCH),
{"e": str(emb), "k": top_k * 4}).mappings().all()
seen, out = set(), []
for r in rows:
if r["chunk_id"] in seen:
continue
seen.add(r["chunk_id"])
out.append(dict(r))
if len(out) == top_k:
break
return out
for r in hype_retrieve(session, "To'lov shlyuzi qachon ulangan?", top_k=3):
print(f"{r['score']:.3f} mos savol: {r['question'][:55]}")0.912 mos savol: To'lov shlyuzlari qachon integratsiya qilingan? 0.834 mos savol: Billing moduliga qaysi to'lov tizimlari ulangan? 0.771 mos savol: Loyihaning ikkinchi bosqichida nima qilingan?
4. 0.912 — juda yuqori ball
Savol-savol taqqoslash shunday natija beradi. Savol-matn taqqoslashda bu ko'rsatkich odatda 0.6–0.8 oralig'ida bo'ladi.
Va bu ball bepul keldi — qidiruvda hech qanday qo'shimcha chaqiruv bo'lmadi.
Himoya:
n ni 4-5 qiling (kam emas), va indekslashdan keyin bir necha chunk ni qo'lda tekshiring — savollar asosiy faktlarni qoplayaptimi?
Xavfsizroq variant: chunk matnini ham alohida yozuv sifatida indekslang. Indeks 25% kattalashadi, lekin zaxira yo'l paydo bo'ladi — bu aslida Document Augmentation ga aylanadi.
HyDE bo'limida ko'rdik: savol va hujjat har xil tilda yoziladi, shuning uchun ularning embedding lari uzoq turadi. HyDE buni qidiruv paytida hal qiladi — savoldan soxta javob yasab.
HyPE aynan shu muammoni indekslash paytida hal qiladi. Har bir chunk uchun LLM "bu matn qaysi savolga javob beradi?" deb savol yozadi, va chunk o'rniga o'sha savol embedding qilinadi.
HyDE (qidiruvda): savol --> soxta javob --> embedding --> qidir
har so'rovda +1 LLM chaqiruv, +1-3 soniya
HyPE (indeksda): chunk --> faraziy savol --> embedding --> saqla
qidiruvda 0 qo'shimcha chaqiruv, 0 qo'shimcha vaqt
Natijada qidiruv paytida savol-savol taqqoslanadi. Ikkalasi ham so'roq shaklida, ikkalasi ham qisqa — embedding lar tabiiy ravishda yaqin bo'ladi.
HyPE ning asosiy afzalligi
Qidiruv tezligi umuman o'zgarmaydi. Bu production uchun juda muhim: HyDE har savolga 1-3 soniya qo'shadi, HyPE esa nol.
Barcha xarajat indekslashga o'tadi — u esa bir marta, tunda, foydalanuvchi kutmay turib bajariladi.
Document Augmentation bilan farqi
Ikkalasi ham chunk dan savol yasaydi. Farq — nima saqlanadi:
- Document Augmentation — chunk matni va savollar, ikkala vektor ham qoladi
- HyPE — faqat savol vektorlari; chunk matni saqlanadi, lekin embedding qilinmaydi
HyPE indeks hajmi bo'yicha tejamkorroq, lekin xavfliroq: agar generatsiya qilingan savollar yomon bo'lsa, chunk ga boshqa yo'l qolmaydi. Document Augmentation da matn bo'yicha qidiruv zaxira sifatida qoladi.
7.11 Dartboard Retrieval — takrorni jazolash
Kodlar: Dartboard — relevantlik minus ortiqchalik
1. Ortiqchalikni o'lchash
Avval oddiy retrieval natijasida qancha takror borligini ko'raylik. Buning uchun tanlangan chunk larning o'zaro o'xshashligini hisoblaymiz.
import numpy as np
def measure_redundancy(chunks: list[dict]) -> float:
vectors = []
for c in chunks:
emb, err = generate_query_embedding(c["content"][:500])
if not err and emb:
vectors.append(emb)
pairs = []
for i in range(len(vectors)):
for j in range(i + 1, len(vectors)):
pairs.append(cosine_similarity(vectors[i], vectors[j]))
return sum(pairs) / len(pairs) if pairs else 0.0
emb, _ = generate_query_embedding("Ucell loyihasi haqida")
plain = retrieve_chunks(session, emb, top_k=5)
print(f"Oddiy top-5 o'zaro o'xshashlik: {measure_redundancy(plain):.3f}")Oddiy top-5 o'zaro o'xshashlik: 0.874
2. 0.874 — bu juda yomon
Tanlangan 5 ta chunk bir-biriga 87% o'xshash. Ya'ni LLM ga deyarli bir xil matn besh marta berildi.
Ideal ko'rsatkich 0.5–0.65 atrofida: chunk lar bitta mavzuda, lekin turli faktlar haqida.
3. Greedy tanlash algoritmi
Har qadamda: qolgan nomzodlar orasidan eng yuqori ball ni oluvchini tanlaymiz, va uni tanlanganlar ro'yxatiga qo'shamiz. Keyingi qadamda ballar qayta hisoblanadi — chunki tanlanganlar to'plami o'zgardi.
def dartboard_select(question: str, candidates: list[dict],
top_k: int = 5, lam: float = 0.4) -> list[dict]:
q_emb, err = generate_query_embedding(question)
if err or q_emb is None:
return candidates[:top_k]
vectors = []
for c in candidates:
emb, e = generate_query_embedding(c["content"][:500])
vectors.append(emb if not e and emb else None)
relevance = [
cosine_similarity(q_emb, v) if v is not None else 0.0
for v in vectors
]
selected, chosen_idx = [], []
remaining = set(range(len(candidates)))
while remaining and len(selected) < top_k:
best_idx, best_score = None, -1e9
for i in remaining:
if vectors[i] is None:
continue
penalty = 0.0
if chosen_idx:
penalty = max(
cosine_similarity(vectors[i], vectors[j]) for j in chosen_idx
)
score = relevance[i] - lam * penalty
if score > best_score:
best_idx, best_score = i, score
if best_idx is None:
break
row = dict(candidates[best_idx])
row["dartboard_score"] = round(best_score, 4)
row["relevance"] = round(relevance[best_idx], 4)
selected.append(row)
chosen_idx.append(best_idx)
remaining.discard(best_idx)
return selectedcandidates = retrieve_chunks(session, emb, top_k=20)
picked = dartboard_select("Ucell loyihasi haqida", candidates, top_k=5, lam=0.4)
print(f"{'rel':<8}{'ball':<9}{'sarlavha'}")
for c in picked:
print(f"{c['relevance']:<8.3f}{c['dartboard_score']:<9.3f}{c['title'][:45]}")
print(f"\nDartboard o'zaro o'xshashlik: {measure_redundancy(picked):.3f}")rel ball sarlavha 0.841 0.841 Ucell billing tizimi modernizatsiyasi 0.792 0.461 Ucell SMS gateway integratsiyasi 0.774 0.402 Ucell tarmoq monitoringi loyihasi 0.741 0.389 Ucell loyihasi jamoasi va muddatlari 0.712 0.361 Ucell bilan hamkorlik tarixi Dartboard o'zaro o'xshashlik: 0.612
4. 0.874 → 0.612
Birinchi tanlov o'zgarmadi — jazo yo'q, chunki tanlanganlar ro'yxati bo'sh edi (ball = relevantlik).
Ikkinchi tanlovdan boshlab jazo ishga tushdi: relevantligi 0.83 bo'lgan "billing moduli yangilandi" chunki tashlab yuborildi, chunki u birinchi tanlovga 0.92 o'xshash edi.
5. Lambda ni sozlash
Bitta parametr butun xatti-harakatni boshqaradi. Uni o'z korpusingizda sinab ko'ring.
for lam in (0.0, 0.2, 0.4, 0.6, 0.8):
result = dartboard_select("Ucell loyihasi haqida", candidates, 5, lam)
avg_rel = sum(c["relevance"] for c in result) / len(result)
print(f"lambda={lam} o'rtacha relevantlik={avg_rel:.3f} "
f"ortiqchalik={measure_redundancy(result):.3f}")lambda=0.0 o'rtacha relevantlik=0.804 ortiqchalik=0.874 lambda=0.2 o'rtacha relevantlik=0.789 ortiqchalik=0.731 lambda=0.4 o'rtacha relevantlik=0.772 ortiqchalik=0.612 lambda=0.6 o'rtacha relevantlik=0.734 ortiqchalik=0.548 lambda=0.8 o'rtacha relevantlik=0.681 ortiqchalik=0.502
Xarajat: 20 ta nomzod uchun 20 ta embedding, keyin O(k×n) taqqoslash. Embedding larni bazadan o'qing (ular allaqachon saqlangan) — qayta hisoblash bu funksiyani 10 barobar sekinlashtiradi.
Barcha retrieval usullari bitta savolga javob beradi: qaysi chunk savolga eng o'xshash? Dartboard boshqa savol beradi: qaysi 5 ta chunk birgalikda eng ko'p ma'lumot beradi?
Farq nozik, lekin oqibati katta. Oddiy top-5 ko'pincha bir xil narsani besh marta qaytaradi.
Savol: "Ucell loyihasi haqida" Oddiy top-5 (similarity bo'yicha): 0.84 Ucell billing tizimi modernizatsiya qilindi... 0.83 Ucell uchun billing moduli yangilandi... <-- deyarli aynan 0.82 Billing tizimi Ucell uchun qayta yozildi... <-- yana o'sha 0.79 Ucell SMS gateway integratsiyasi... 0.77 Ucell tarmoq monitoringi... Dartboard top-5: 0.84 Ucell billing tizimi modernizatsiya qilindi... 0.79 Ucell SMS gateway integratsiyasi... 0.77 Ucell tarmoq monitoringi... 0.74 Ucell loyihasi jamoasi va muddatlari... 0.71 Ucell bilan hamkorlik tarixi...
Oddiy usulda birinchi uchta natija bir xil faktni takrorlaydi. LLM ga 5 ta chunk berildi, lekin u faqat 3 ta turli fakt oldi. Dartboard da 5 ta chunk — 5 ta turli fakt.
Formula: relevantlik minus ortiqchalik
Har bir nomzod uchun ball hisoblanadi:
ball = relevantlik(savol, nomzod)
- lambda * max(o'xshashlik(nomzod, allaqachon_tanlangan))
Ya'ni: savolga qanchalik mos ekani qo'shiladi, allaqachon tanlangan chunk larga qanchalik o'xshashligi ayiriladi. Tanlash ochko'zlik (greedy) bilan ketadi — har qadamda eng yuqori ballni oluvchi tanlanadi.
lambda ikkalasi orasidagi muvozanatni boshqaradi. 0 bo'lsa — oddiy top-k. 1 dan katta bo'lsa — tizim relevantlikni unutib, faqat xilma-xillikni quvadi va aloqasiz chunk lar chiqadi. Amalda 0.3–0.5 yaxshi ishlaydi.
MMR bilan aloqasi
Bu g'oya yangi emas — u Maximal Marginal Relevance (MMR) nomi bilan 1998-yildan beri ma'lum. Dartboard uni ehtimollik nuqtai nazaridan qayta ifodalaydi, lekin amaliy natija juda o'xshash.
7.12 Hierarchical Indices — avval hujjat, keyin chunk
Kodlar: Hierarchical Indices — ikki bosqichli qidiruv
1. Hujjat xulosalarini tayyorlash
Har bir hujjat uchun bitta xulosa. Bu indekslashda bir marta bajariladi.
Xulosa uzun bo'lmasin — 3-4 gap yetarli. Uning vazifasi mavzuni ifodalash, tafsilot berish emas.
DOC_SUMMARY = (
"Quyidagi hujjatning mavzusini 3-4 gapda yoz. "
"Asosiy mavzular, mijoz nomi va yo'nalishni ko'rsat. "
"Tafsilotlarga kirma.\n\n{body}"
)
SUMMARY_SQL = (
"INSERT INTO doc_summaries (doc_id, title, summary, embedding) "
"VALUES (:d, :t, :s, :e)"
)
def index_summaries(session, documents: list[dict]) -> int:
saved = 0
for doc in documents:
summary = _ollama_chat(
[{"role": "user", "content": DOC_SUMMARY.format(body=doc["body"][:4000])}],
temperature=0.3, max_tokens=200,
)
summary = (summary or "").strip()
if len(summary) < 50:
continue
emb, err = generate_query_embedding(summary)
if err or emb is None:
continue
session.execute(text(SUMMARY_SQL), {
"d": doc["id"], "t": doc["title"], "s": summary, "e": str(emb),
})
saved += 1
session.commit()
return saved2. Ikki bosqichli qidiruv
Birinchi so'rov xulosalar jadvalidan, ikkinchisi chunk lar jadvalidan — lekin WHERE doc_id = ANY(...) bilan cheklangan.
SUMMARY_SEARCH = (
"SELECT doc_id, title, 1 - (embedding <=> CAST(:e AS vector)) AS score "
"FROM doc_summaries "
"ORDER BY embedding <=> CAST(:e AS vector) LIMIT :k"
)
SCOPED_SEARCH = (
"SELECT id, doc_id, title, content, "
" 1 - (embedding <=> CAST(:e AS vector)) AS score "
"FROM chunks WHERE doc_id = ANY(:docs) "
"ORDER BY embedding <=> CAST(:e AS vector) LIMIT :k"
)
def hierarchical_retrieve(session, question: str,
n_docs: int = 3, top_k: int = 5) -> list[dict]:
emb, err = generate_query_embedding(question)
if err or emb is None:
return []
docs = session.execute(text(SUMMARY_SEARCH),
{"e": str(emb), "k": n_docs}).mappings().all()
if not docs:
return retrieve_chunks(session, emb, top_k)
doc_ids = [d["doc_id"] for d in docs]
print("Tanlangan hujjatlar:")
for d in docs:
print(f" {d['score']:.3f} {d['title'][:50]}")
rows = session.execute(text(SCOPED_SEARCH),
{"e": str(emb), "docs": doc_ids, "k": top_k}).mappings().all()
return [dict(r) for r in rows]
hits = hierarchical_retrieve(session, "billing tizimidagi to'lov integratsiyasi")
print()
for c in hits:
print(f" {c['score']:.3f} {c['content'][:60]}...")Tanlangan hujjatlar: 0.867 Ucell billing tizimi modernizatsiyasi hisoboti 0.723 Mobil banking ilovasi texnik hujjati 0.681 To'lov tizimlari integratsiyasi bo'yicha qo'llanma 0.842 Ikkinchi bosqichda Click va Payme to'lov shlyuzlari REST... 0.798 To'lov shlyuzi bilan ulanish uchun webhook manzili sozla... 0.771 Integratsiya testlari 2023-yil iyul oyida o'tkazildi va... 0.744 Billing moduli to'lov holatini real vaqtda kuzatadi va... 0.712 Xatolik yuz berganda tranzaksiya qayta urinish navbatig...
3. Taqqoslash
Bir bosqichli qidiruv bilan yonma-yon solishtiramiz — qancha begona hujjat chiqib ketganini ko'rish uchun.
emb, _ = generate_query_embedding("billing tizimidagi to'lov integratsiyasi")
flat = retrieve_chunks(session, emb, top_k=5)
flat_docs = {c.get("doc_id") for c in flat}
hier_docs = {c.get("doc_id") for c in hits}
print(f"Bir bosqichli : {len(flat_docs)} xil hujjatdan")
print(f"Ikki bosqichli: {len(hier_docs)} xil hujjatdan")Bir bosqichli : 5 xil hujjatdan Ikki bosqichli: 2 xil hujjatdan
4. Bu yaxshimi yoki yomonmi?
Bu savolga bitta javob yo'q. Ikki bosqichli qidiruv chuqurroq, lekin torroq.
- Yaxshi — savol bitta mavzuga oid bo'lsa. Chuqur, izchil kontekst.
- Yomon — savol ko'p hujjatni qamrashi kerak bo'lsa. Masalan "barcha mijozlar ro'yxati" — 3 ta hujjat bilan chegaralanish xato.
Shuning uchun n_docs ni juda kichik qilmang. 3-5 xavfsiz oraliq.
Yangilanish muammosi: hujjat o'zgarsa xulosani qayta yaratish kerak. Buni unutish oson — natijada xulosa eski, chunk lar yangi bo'lib qoladi va qidiruv adashadi. Re-embedding pipeline ingizga xulosalarni ham qo'shing.
Katta korpusda muammo shunday ko'rinadi: 50 000 chunk ichida qidiryapsiz, va ularning ko'pchiligi bir-biriga o'xshash. Retrieval adashadi.
Hierarchical Indices qidiruvni ikki bosqichga bo'ladi:
- 1-bosqich: hujjat xulosalari ichidan qidirish — qaysi 3 ta hujjat tegishli?
- 2-bosqich: faqat o'sha hujjatlarning chunk lari ichidan qidirish
50 000 chunk, 400 hujjat
Bir bosqichli: 50 000 chunk ichidan top-5 <-- shovqin ko'p
Ikki bosqichli: 400 xulosa ichidan top-3 hujjat
--> ~600 chunk ichidan top-5 <-- ancha aniq
Nega bu ishlaydi?
Hujjat xulosasi butun hujjatning mavzusini ifodalaydi. "Ucell billing loyihasi hisoboti" degan xulosa — aniq signal. Uning ichidagi 40-chunk esa ("ikkinchi bosqichda modul yangilandi") — kontekstsiz, adashtiruvchi.
Ya'ni birinchi bosqich qo'pol lekin ishonchli filtr vazifasini bajaradi.
Qachon kerak emas
Korpus kichik bo'lsa (5000 chunk gacha) — bu qatlam faqat murakkablik qo'shadi. Bir bosqichli qidiruv yetarli.
Shuningdek, hujjatlar juda qisqa bo'lsa (har biri 2-3 chunk) — xulosa va chunk orasida farq qolmaydi, texnika ma'nosini yo'qotadi.
RAPTOR bilan farqi: RAPTOR rekursiv daraxt quradi (xulosaning xulosasi), bu esa atigi ikki qatlam. Hierarchical Indices ancha arzon va amalda ko'pincha yetarli.
7.13 Relevant Segment Extraction — qo'shnilarni yig'ish
Kodlar: RSE — qo'shni chunk larni segmentga yig'ish
1. Hujjat bo'ylab ball taqsimoti
RSE ishlashi uchun chunk lar tartib raqami bilan saqlangan bo'lishi shart (chunk_index). Busiz qo'shnini topib bo'lmaydi.
Avval bitta hujjatdagi barcha chunk larning ballarini olamiz.
DOC_SCORES = (
"SELECT id, chunk_index, content, "
" 1 - (embedding <=> CAST(:e AS vector)) AS score "
"FROM chunks WHERE doc_id = :d "
"ORDER BY chunk_index"
)
def score_document(session, question: str, doc_id: int) -> list[dict]:
emb, err = generate_query_embedding(question)
if err or emb is None:
return []
rows = session.execute(text(DOC_SCORES),
{"e": str(emb), "d": doc_id}).mappings().all()
return [dict(r) for r in rows]
scored = score_document(session, "to'lov shlyuzi integratsiyasi", doc_id=12)
for c in scored[:8]:
bar = "#" * int(c["score"] * 40)
print(f"{c['chunk_index']:>3} {c['score']:.3f} {bar}")0 0.214 ######## 1 0.287 ########### 2 0.331 ############# 3 0.812 ################################ 4 0.879 ################################### 5 0.743 ############################# 6 0.298 ########### 7 0.187 #######
2. Zona ko'rinib turibdi
3, 4, 5-chunk lar yuqori ball olgan — bu uzluksiz zona. Oddiy top-1 faqat 4-chunk ni qaytarardi, va gapning boshi (3) va oxiri (5) yo'qolardi.
3. Segment topish algoritmi
Har bir chunk ballidan chegara (threshold) ayiriladi. Natija musbat bo'lsa — chunk segmentga hissa qo'shadi, manfiy bo'lsa — jazo.
Keyin eng yuqori yig'indiga ega uzluksiz oraliqni qidiramiz. Bu klassik "maximum subarray" masalasi.
def find_segments(scored: list[dict], threshold: float = 0.4,
max_len: int = 5, max_segments: int = 2) -> list[dict]:
values = [c["score"] - threshold for c in scored]
segments = []
for start in range(len(values)):
total = 0.0
for end in range(start, min(start + max_len, len(values))):
total += values[end]
if total > 0:
segments.append({"start": start, "end": end, "value": total})
segments.sort(key=lambda s: s["value"], reverse=True)
picked, used = [], set()
for seg in segments:
span = set(range(seg["start"], seg["end"] + 1))
if span & used:
continue
used |= span
picked.append(seg)
if len(picked) == max_segments:
break
return picked
for seg in find_segments(scored):
print(f"chunk {seg['start']}-{seg['end']} qiymat={seg['value']:.3f}")chunk 3-5 qiymat=1.234
4. Segmentni matnga aylantirish
Chunk lar tartib bo'yicha birlashtiriladi. Natija — asl hujjatdagi uzluksiz parcha.
def rse_retrieve(session, question: str, doc_ids: list[int],
threshold: float = 0.4) -> list[dict]:
results = []
for doc_id in doc_ids:
scored = score_document(session, question, doc_id)
if not scored:
continue
for seg in find_segments(scored, threshold=threshold):
pieces = scored[seg["start"]: seg["end"] + 1]
results.append({
"doc_id": doc_id,
"range": f"{seg['start']}-{seg['end']}",
"chunks": len(pieces),
"value": round(seg["value"], 3),
"content": " ".join(p["content"] for p in pieces),
})
results.sort(key=lambda r: r["value"], reverse=True)
return results
for seg in rse_retrieve(session, "to'lov shlyuzi integratsiyasi", [12, 18, 24]):
print(f"hujjat {seg['doc_id']} chunk {seg['range']} "
f"({seg['chunks']} ta, {len(seg['content'])} belgi)")hujjat 12 chunk 3-5 (3 ta, 2287 belgi) hujjat 18 chunk 7-8 (2 ta, 1504 belgi) hujjat 24 chunk 1-1 (1 ta, 782 belgi)
5. O'zgaruvchan uzunlik — bu xususiyat, xato emas
Uchta hujjatdan uch xil uzunlikdagi segment keldi: 3 ta chunk, 2 ta, 1 ta. RSE o'zi qaror qildi — qayerda mavzu uzun davom etgan, qayerda qisqa.
Belgilangan top_k bunday moslashuvchanlikni bera olmaydi.
0.4 dan boshlang, keyin korpusingizdagi tipik ball taqsimotiga qarab sozlang. Yuqoridagi ASCII grafik aynan shu sozlash uchun foydali — uni debug vositasi sifatida saqlab qo'ying.
Shart:
chunk_index va doc_id ustunlari bo'lishi kerak. Agar sizning jadvalingizda ular yo'q bo'lsa — RSE ishlamaydi.Chunking hujjatni bo'lakladi, lekin ma'lumot chegaradan oshib turadi. Bir mavzu 3-4 ta ketma-ket chunk da davom etadi.
Oddiy retrieval ulardan bittasini qaytaradi — o'rtasidagini. LLM gapning boshini ham, oxirini ham ko'rmaydi.
RSE boshqacha ishlaydi: chunk larni alohida emas, segment sifatida qaraydi. Agar 12-, 13- va 14-chunk lar yuqori ball olgan bo'lsa — ular bitta uzluksiz bo'lak sifatida qaytariladi.
Chunk ballari (hujjat bo'ylab):
10 11 12 13 14 15 16 17
0.2 0.3 0.8 0.9 0.7 0.2 0.1 0.6
^^^^^^^^^^^^^^
uzluksiz yuqori zona = segment
Natija: 12-14 chunk lar birlashtirilib, bitta matn sifatida qaytariladi
Context Window Enhancement bilan farqi
Context Window texnikasi har bir topilgan chunk atrofidan belgilangan miqdorda qo'shni oladi — masalan ±1. Bu qo'pol: ba'zan kerak, ba'zan ortiqcha.
RSE esa ballar asosida o'zi qaror qiladi: qayerda to'xtash kerakligini ma'lumotning o'zi ko'rsatadi. Yuqoridagi misolda 15- va 16-chunk lar past ball olgan, shuning uchun segment 14 da tugaydi.
Amaliy ahamiyati
Bu texnika ayniqsa uzun tushuntirishlar bor hujjatlarda kuchli: qo'llanmalar, protsedura tavsiflari, bosqichma-bosqich hisobotlar. Qisqa, mustaqil yozuvlarda (FAQ, jadval, yangiliklar) foydasi kam.
7.14 Contextual Compression — keraksiz matnni kesish
Kodlar: Contextual Compression — kontekstni siqish
1. Siqish funksiyasi
Prompt dagi ikkita qoida majburiy: "faqat ko'chir" va "qo'shma". Ularsiz LLM matnni qayta yozadi va faktlarni buzadi.
COMPRESS = (
"Quyidagi matndan FAQAT savolga tegishli gaplarni ko'chir.\n"
"Qoidalar:\n"
"- Gaplarni o'zgartirmasdan, aynan ko'chir\n"
"- Hech narsa qo'shma, xulosa yozma\n"
"- Tegishli gap bo'lmasa — BO'SH qaytar\n\n"
"Savol: {question}\n\nMatn: {chunk}"
)
def compress_chunk(question: str, chunk: str, min_len: int = 40) -> str:
out = _ollama_chat(
[{"role": "user", "content": COMPRESS.format(question=question, chunk=chunk)}],
temperature=0.0, max_tokens=400,
)
compressed = (out or "").strip()
if len(compressed) < min_len:
return ""
if len(compressed) > len(chunk):
return chunk
return compressed2. Ikkita himoya qatlami
min_len — juda qisqa natija "hech narsa topilmadi" degani, bo'sh qaytaramiz.
Ikkinchi tekshiruv — siqilgan matn aslidan uzunroq bo'lsa, demak LLM qo'shimcha matn yozgan. Bunday holatda asl chunk ni qaytaramiz.
def compressed_retrieve(session, question: str, top_k: int = 5) -> dict:
emb, _ = generate_query_embedding(question)
chunks = retrieve_chunks(session, emb, top_k=top_k)
original_len = sum(len(c["content"]) for c in chunks)
kept = []
for c in chunks:
compressed = compress_chunk(question, c["content"])
if not compressed:
continue
row = dict(c)
row["original"] = c["content"]
row["content"] = compressed
kept.append(row)
new_len = sum(len(c["content"]) for c in kept)
return {
"chunks": kept,
"before": original_len,
"after": new_len,
"dropped": len(chunks) - len(kept),
"ratio": new_len / original_len if original_len else 0,
}
r = compressed_retrieve(session, "To'lov shlyuzi qachon ulangan?")
print(f"Oldin : {r['before']} belgi ({len(r['chunks']) + r['dropped']} chunk)")
print(f"Keyin : {r['after']} belgi ({len(r['chunks'])} chunk)")
print(f"Tashlandi: {r['dropped']} ta chunk")
print(f"Siqilish : {r['ratio']:.0%}")Oldin : 3918 belgi (5 chunk) Keyin : 641 belgi (3 chunk) Tashlandi: 2 ta chunk Siqilish : 16%
3. 3918 → 641 belgi
Kontekst 6 barobar kichraydi. Ikkita chunk umuman tashlandi — ularda savolga tegishli hech narsa yo'q edi, garchi similarity ballari yuqori bo'lsa ham.
Bu retrieval xatosini generation dan oldin tuzatish demakdir.
for c in r["chunks"]:
print(f"--- {c['title'][:45]}")
print(f" asl : {len(c['original'])} belgi")
print(f" siqilgan : {c['content'][:110]}")--- Ucell billing tizimi modernizatsiyasi
asl : 812 belgi
siqilgan : Ikkinchi bosqichda, iyun oyida, Click va Payme to'lov shlyuzlari REST API orqali ulandi.
--- Billing moduli texnik tavsifi
asl : 784 belgi
siqilgan : To'lov shlyuzi bilan ulanish webhook orqali amalga oshiriladi.
--- Integratsiya testlari hisoboti
asl : 803 belgi
siqilgan : To'lov shlyuzi integratsiyasi testlari 2023-yil iyul oyida o'tkazildi.4. Qachon ishlatmaslik kerak
Siqish har bir chunk uchun LLM chaqiruvi degani. 5 ta chunk = 5 ta chaqiruv = ~4 soniya qo'shimcha.
Shuning uchun uni shartli qo'llang: kontekst uzunligi chegaradan oshgandagina.
def smart_context(session, question: str, top_k: int = 5,
limit: int = 3000) -> str:
emb, _ = generate_query_embedding(question)
chunks = retrieve_chunks(session, emb, top_k=top_k)
total = sum(len(c["content"]) for c in chunks)
if total <= limit:
print(f"Siqish kerak emas ({total} belgi)")
return "\n\n".join(c["content"] for c in chunks)
print(f"Siqilmoqda ({total} belgi > {limit})")
result = compressed_retrieve(session, question, top_k)
return "\n\n".join(c["content"] for c in result["chunks"])Shuning uchun: asl matnni
original maydonida saqlang (yuqoridagi kod shunday qiladi) va log ga yozing. Muammo chiqqanda nima tashlanganini ko'ra olasiz.
Arzonroq muqobil: siqish o'rniga chunk ni kichikroq qiling yoki reranker bilan
top_k ni 5 dan 3 ga tushiring. Ko'p holatda natija bir xil, LLM chaqiruvi esa nol.Topilgan chunk 800 belgi. Savolga tegishli qismi — atigi 2 ta gap, 150 belgi. Qolgan 650 belgi LLM uchun shovqin, siz uchun token xarajati.
Contextual Compression chunk ni LLM ga berishdan oldin uni siqadi: faqat savolga tegishli gaplarni qoldiradi.
Savol: "To'lov shlyuzi qachon ulangan?" Asl chunk (780 belgi): "Loyiha 2023-yil yanvarda boshlandi. Jamoa 6 kishidan iborat edi. Birinchi bosqichda talablar to'plandi va arxitektura ishlab chiqildi. Ikkinchi bosqichda, iyun oyida, Click va Payme to'lov shlyuzlari REST API orqali ulandi. Uchinchi bosqichda hisobot moduli qo'shildi. Loyiha dekabr oyida yakunlandi va topshirildi." Siqilgandan keyin (95 belgi): "Ikkinchi bosqichda, iyun oyida, Click va Payme to'lov shlyuzlari REST API orqali ulandi."
Nima uchun bu sifatni oshiradi?
Ikkita sabab bor, va ikkinchisi muhimroq:
- Token tejash — 5 ta chunk 4000 token o'rniga 800 token. To'g'ridan-to'g'ri pul.
- "Lost in the middle" — LLM uzun kontekstning o'rtasidagi ma'lumotni yomon ko'radi. Kontekst qisqarsa, hamma narsa "boshida" bo'lib qoladi.
Asosiy xavf: ma'lumotni yo'qotish
Siqish — qaytarib bo'lmaydigan amal. LLM kerakli gapni tashlab yuborsa, javob noto'g'ri chiqadi va siz buni bilmaysiz ham.
Shuning uchun ikkita ehtiyot chorasi majburiy: siqish promptida "hech narsa qo'shma va o'zgartirma" deb yozish, va siqilgan matn juda qisqa chiqsa (masalan 40 belgidan kam) — asl chunk ni ishlatish.
Shuningdek, har bir chunk uchun alohida LLM chaqiruvi kerak. 5 ta chunk = 5 ta chaqiruv. Bu latency ni sezilarli oshiradi, shuning uchun siqishni faqat kontekst haqiqatan uzun bo'lganda qo'llang.
Bu bobni bepul o'qidingiz.
Kitob o'rgatadi, lekin sizning kodingiz nega ishlamaganini ayta olmaydi. Kursda uy vazifangiz tekshiriladi.