Asosiy qismga o'tish

Retrieval (Qidirish)

7-bob 57 kod bloki 9753 so'z

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 7-bob · HNSW index · ~70 qator

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.

python13 qator
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}")
Natija
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.

python15 qator
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)
Natija
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 ms

4. Index Scan ko'rinishi shart

Agar Seq Scan yozilgan bo'lsa — index ishlamayapti. Eng ko'p uchraydigan uchta sabab:

  • ORDER BY da boshqa operator ishlatilgan (index vector_cosine_ops bilan qurilgan, so'rov <-> ishlatyapti)
  • Jadval juda kichik — planner index ni foydasiz deb hisoblaydi (bu normal)
  • LIMIT yo'q — HNSW faqat top-N uchun ishlaydi
python10 qator
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")
Natija
Topildi: 0 ta chunk
Eng muhim qator — threshold. Vector search doim natija qaytaradi, hatto savol korpusga umuman aloqasiz bo'lsa ham. Yuqoridagi "kosmik raketa" savoliga ham eng yaqin 5 ta chunk qaytardi — ballari 0.12 atrofida.

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.

python15 qator
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.

sql12 qator
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:

python12 qator
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:

python4 qator
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 7-bob · hybrid search · ~90 qator

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).

python14 qator
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.

python20 qator
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 out
python10 qator
def 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]}")
Natija
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.

k parametri: 60 — Cormack (2009) tavsiya qilgan standart. Kichikroq k (masalan 10) birinchi o'rinlarga ko'proq og'irlik beradi; kattaroq k ro'yxatlarni tekislaydi. O'zgartirishdan oldin o'lchang.

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

python1 qator
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:

python4 qator
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):

python4 qator
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 7-bob · uchta mustaqil usul · ~80 qator

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.

python23 qator
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))
Natija
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.

python17 qator
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))
Natija
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.

python17 qator
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)
Natija
- 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.
Amaliy qoida: rewriting ni pipeline ga doimiy qo'shing (u deyarli har doim foyda beradi), qolgan ikkitasini shartli ishga tushiring. Uchalasi birga ishlaganda javob 4–6 soniya kechikadi — foydalanuvchi buni sezadi.

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):

python6 qator
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:

python24 qator
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 7-bob · recall oshirish · ~60 qator

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.

python18 qator
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)
Natija
- 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.

python29 qator
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]}")
Natija
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.

Narxi: 1 ta LLM chaqiruvi (variantlar) + 5 ta embedding + 5 ta SQL so'rov. Oddiy retrieval-ga nisbatan taxminan 3–4 barobar sekin.

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:

python25 qator
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:

python6 qator
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:

sql9 qator
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 7.7-bo'lim · Nihol stack: Ollama + pgvector · ~90 qator

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.

python12 qator
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".

python12 qator
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))
Natija
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.

python11 qator
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.

python22 qator
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?")
Natija
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.

Qachon ishlatish kerak? HyDE qo'shimcha LLM chaqiruvi — 1–3 soniya va qo'shimcha token xarajati. Uni har bir so'rovga qo'shmang.

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.

python17 qator
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 7-bob · structured + semantic · ~80 qator

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.

python35 qator
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"))
Natija
{'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.

python14 qator
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"), params
python23 qator
def 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]}")
Natija
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.

Xavfsizlik — eng muhim nuqta. 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:

python16 qator
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:

python3 qator
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

python73 qator
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 7-bob · qidiruvda nol qo'shimcha xarajat · ~70 qator

1. Chunk dan savollar

HyDE dan farqli — bu funksiya indekslashda ishlaydi, qidiruvda emas. Shuning uchun uning sekinligi foydalanuvchiga ko'rinmaydi.

python13 qator
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.

python29 qator
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))
Natija
{'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.

python29 qator
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]}")
Natija
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.

Asosiy xavf: chunk matni embedding qilinmagani uchun, agar generatsiya qilingan savollar chunk ning biror faktini qamrab olmasa — o'sha faktga yo'l umuman qolmaydi.

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.

python5 qator
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 7-bob · greedy tanlash · ~80 qator

1. Ortiqchalikni o'lchash

Avval oddiy retrieval natijasida qancha takror borligini ko'raylik. Buning uchun tanlangan chunk larning o'zaro o'xshashligini hisoblaymiz.

python19 qator
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}")
Natija
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.

python45 qator
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 selected
python8 qator
candidates = 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}")
Natija
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.

python5 qator
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}")
Natija
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
Jadvalni o'qing — bu savdolashuv. Lambda oshgan sari ortiqchalik tushadi, lekin relevantlik ham tushadi. 0.4 da ortiqchalik 30% kamaydi, relevantlik esa atigi 4% — eng yaxshi nuqta.

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.

python15 qator
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:

python2 qator
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 7-bob · xulosa → chunk · ~70 qator

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.

python30 qator
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 saved

2. Ikki bosqichli qidiruv

Birinchi so'rov xulosalar jadvalidan, ikkinchisi chunk lar jadvalidan — lekin WHERE doc_id = ANY(...) bilan cheklangan.

python38 qator
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]}...")
Natija
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.

python8 qator
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")
Natija
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.

Indeks xarajati: har bir hujjat uchun 1 ta LLM chaqiruvi. 400 hujjat = 400 chaqiruv, bir martalik.

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
python5 qator
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 7-bob · ball zonalari · ~80 qator

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.

python20 qator
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}")
Natija
  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.

python28 qator
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}")
Natija
chunk 3-5  qiymat=1.234

4. Segmentni matnga aylantirish

Chunk lar tartib bo'yicha birlashtiriladi. Natija — asl hujjatdagi uzluksiz parcha.

python23 qator
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)")
Natija
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.

Threshold eng muhim parametr. Juda past (0.2) — butun hujjat bitta segment bo'lib ketadi. Juda yuqori (0.7) — hech qanday segment topilmaydi.

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.

python8 qator
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 7-bob · token tejash · ~70 qator

1. Siqish funksiyasi

Prompt dagi ikkita qoida majburiy: "faqat ko'chir" va "qo'shma". Ularsiz LLM matnni qayta yozadi va faktlarni buzadi.

python20 qator
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 compressed

2. 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.

python31 qator
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%}")
Natija
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.

python4 qator
for c in r["chunks"]:
    print(f"--- {c['title'][:45]}")
    print(f"    asl      : {len(c['original'])} belgi")
    print(f"    siqilgan : {c['content'][:110]}")
Natija
--- 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.

python13 qator
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"])
Eng katta xavf — sokin ma'lumot yo'qotish. LLM kerakli gapni tashlab yuborsa, javob noto'g'ri chiqadi va xato izi qolmaydi.

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.

python12 qator
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.

Telegramda yozish Narxlar