Знакът на КАГАМИ КАГАМИ
kagami.bg/academy · lesson · machine-readable viewVERIFIED 2026-10-01 · UPDATED 2026-10-01
IDENTITY
module
01-04a · Advanced RAG: chunking, hybrid search, reranking
series
Blocks 0–10 · Block 4 — RAG and knowledge systems · Part 1/2
level
Advanced
duration
4–6 h
prerequisites
Blocks 0–3 (LLM basics, inference, prompting, agents); a basic RAG pipeline; Python ≥ 3.10; Docker
trust_label
VERIFIED 2026-10-01 (package versions on PyPI, Qdrant release, Qdrant Query API / RRF / IDF docs, fastembed model list, reranker licences, all source links; code executed with in-memory Qdrant, real BM25 and mocked dense embeddings) · UPDATED 2026-10-01 · NOT end-to-end tested with a real LLM, real nomic-embed-text or bge-reranker-v2-m3; docker healthcheck not run
versions
Qdrant server v1.19.x · qdrant-client 1.19.x · fastembed 0.8.x · langchain-text-splitters 1.1.x · langchain-ollama 1.1.x · sentence-transformers 6.x
language
human view: bg · english edition: /en/academy/blokove/moduli/01-04a_Блок_4_Част_1_Advanced_RAG.html
next
01-04b_Блок_4_Част_2_GraphRAG_RAGAS.html · GraphRAG + RAGAS evaluation
PURPOSE

Fix the three failure points of basic RAG (split → embed → retrieve → generate): bad chunks, missed exact terms, weak ranking. Chunk by document structure (one law article = one chunk), combine dense and sparse (BM25) retrieval in Qdrant with Reciprocal Rank Fusion, restrict results with payload filters, then rerank the top 20 candidates with a multilingual cross-encoder and pass the top 5 to the LLM.

KEY CONCEPTS
COMMANDS / PATHS
CHECKLIST
NEXT MODULE

01-04b_Блок_4_Част_2_GraphRAG_RAGAS.html · GraphRAG, a production RAG system and RAGAS evaluation · offer: Quick experiment (kagami.bg/stalbata/)

SOURCES
TAGS
ragchunkinghybrid-searchbm25qdrantrrfrerankingcross-encodermetadata-filteringollama
ПРОВЕРЕНО · 01.10.2026 ОБНОВЕНО · 01.10.2026

Advanced RAG: чънкове, хибридно търсене, преранкиране

Базовият RAG (раздели → вгради → намери → отговори) работи в повечето случаи. Проваля се на три места: лошо нарязан текст, пропуснати точни термини и слабо подреждане. В този урок поправяме и трите — с Qdrant на собствената машина и модели, които вървят локално.

⏱ 4–6 ч Напреднало Блок 4 · Част 1/2 RAG · търсене · точност
Qdrant (векторна база)🔒 локално Ollama · nomic-embed-text🔒 локално fastembed (BM25)🔒 локално bge-reranker-v2-m3🔒 локално Hugging Face (сваляне на модели)🌐 глобален
🔄
ОБНОВЕНО · 01.10.2026 — какво
Кодът е пренаписан и пуснат срещу qdrant-client 1.19, fastembed 0.8 и langchain-text-splitters 1.1 (сверени в PyPI) — с Qdrant в паметта, истински BM25 и заместени dense вектори; без истински LLM и reranker. Образът на Qdrant е вдигнат от v1.9.0 на v1.19.1. Поправени грешки в стария урок: Query API със сливане (RRF) идва от Qdrant v1.10, не от v1.9; за BM25 липсваше modifier=IDF; Qdrant/bm25 реже думите по английски правила — за български стемерът се изключва; моделът BAAI/bge-reranker-v2-m3 не се поддържа от fastembed — старият код спираше с грешка, сега минава през sentence-transformers; rr'…' беше синтактична грешка; breakpoint_threshold_amount=0.85 при перцентил трябваше да е около 95; recreate_collection е остарял; /api/embeddings на Ollama е заменен от /api/embed; в образа на Qdrant няма curl, затова старата проверка на здравето винаги падаше. Пакетът langchain-experimental (със SemanticChunker) е спрян от май 2026 — даваме собствена кратка реализация. Примерите с „чл. 45 КСО“ и „клинична пътека № 45“ бяха неточни — заменени с проверени членове (чл. 40–41 КСО, чл. 110 ЗЗД). Числата „+31% recall“, „+74% точност“, „23% → 97%“, „48 ms / 312 ms“ и „10M документа в 8 GB“ нямаха източник — махнати или означени като илюстрация. Грешната връзка към статия в arXiv е поправена.

01Какво ще научиш

02Преди да започнеш

bash · инсталация (версии към 01.10.2026)
python3 -m venv .venv && source .venv/bin/activate
pip install -U qdrant-client fastembed langchain-text-splitters langchain-ollama sentence-transformers numpy requests
# проверено с: qdrant-client 1.19.1 · fastembed 0.8.1 · langchain-text-splitters 1.1.2 · langchain-ollama 1.1.0
ollama pull nomic-embed-text
ollama pull llama3.1:8b

03Стъпки

  1. Защо базовото нарязване се проваля

    Нарязването по фиксиран размер (например на всеки 500 знака) не знае къде свършва една мисъл. Член от закона може да попадне наполовина в края на един чънк и наполовина в началото на следващия — тогава никой чънк не съдържа цялото правило, търсенето връща парче и моделът отговаря непълно или грешно. Защо това е важно? Защото никакъв по-добър модел не поправя информация, която не е стигнала до него.

    пример · един член, прерязан на две (учебен)
    Чънк 1: …Чл. 40. (5) Работодателят изплаща първите два работни дни от
            временната неработоспособност в размер 70 на сто от среднодневното
    Чънк 2: брутно възнаграждение… Чл. 41. (1) Паричното обезщетение е 80 на сто
            от среднодневното брутно възнаграждение…
    
    Въпрос: „Колко плаща работодателят за първите дни от болничния?“
    → Чънк 1 няма базата („брутно възнаграждение“), Чънк 2 започва с чужд член.
    💡
    Фактите в примера (сверени с НОИ)
    От 2024 г. работодателят плаща първите два работни дни по 70%, а НОИ — обезщетение от 80% от среднодневното брутно възнаграждение (чл. 40–41 КСО). Текстът по-горе е съкратен за урока, не е дословен цитат.
  2. Пет стратегии за нарязване

    Няма една правилна стратегия — зависи от документа. Започни с рекурсивното нарязване и мини на по-сложно само когато проверката (раздел „Проверка“ и урок 01-04b) покаже, че не стига.

    СтратегияКак работиПодходяща за
    Фиксиран размер + застъпванеN знака, K застъпване. Бързо и просто.Обща проза, новини. Лошо за закони.
    Рекурсивно ★Реже по празен ред → нов ред → „. “ — пази абзаците.Повечето документи. Добро начало.
    СемантичноСравнява съседни изречения и реже там, където смисълът „скача“.Смесени текстове без ясна структура: доклади, становища.
    Родител–детеМалки чънкове за търсене, голям „родител“ за контекст на модела.Дълги технически документи.
    По структура ★Разпознава заглавия (##), членове („Чл. N.“), таблици.Закони, наредби, стандарти (ЗЗД, КСО, КТ).
    Python · advanced_chunking.py
    import re
    import numpy as np
    import requests
    from langchain_text_splitters import RecursiveCharacterTextSplitter, MarkdownHeaderTextSplitter
    
    OLLAMA = "http://localhost:11434"
    
    # ── 1. Recursive: добро начало за повечето текстове на български ──
    def recursive_chunks(text: str, chunk_size: int = 700, overlap: int = 120) -> list[str]:
        """Реже по \n\n → \n → ". " → интервал. Размерът е в символи, не в токени."""
        splitter = RecursiveCharacterTextSplitter(
            chunk_size=chunk_size,
            chunk_overlap=overlap,
            separators=["\n\n", "\n", ". ", " ", ""],
        )
        return splitter.split_text(text)
    
    # ── 2. По структура: закон → един член = един чънк ──
    ARTICLE = re.compile(r"^(Чл\.\s*(\d+[а-я]?)\..*?)(?=^Чл\.\s*\d+[а-я]?\.|\Z)", re.S | re.M)
    
    def legal_law_chunks(law_text: str) -> list[dict]:
        """Всеки член от нов ред („Чл. 110.“) става отделен чънк с номер в metadata."""
        return [{"text": m.group(1).strip(), "article": m.group(2)}
                for m in ARTICLE.finditer(law_text) if len(m.group(1).strip()) > 20]
    
    # ── 3. Семантично: реже там, където смисълът „скача“ ──
    def embed(texts: list[str], prefix: str = "search_document: ") -> np.ndarray:
        """nomic-embed-text през Ollama /api/embed (партида)."""
        r = requests.post(f"{OLLAMA}/api/embed", timeout=120,
                          json={"model": "nomic-embed-text", "input": [prefix + t for t in texts]})
        r.raise_for_status()
        return np.array(r.json()["embeddings"])
    
    def semantic_chunks(text: str, percentile: float = 95, embed_fn=embed) -> list[str]:
        """Разрез между две изречения, когато разстоянието им е над дадения перцентил.
        По-висок перцентил = по-малко разрези = по-големи чънкове."""
        sentences = [s for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()]
        if len(sentences) < 3:
            return [text]
        v = embed_fn(sentences)
        v = v / np.linalg.norm(v, axis=1, keepdims=True)
        dist = 1 - (v[:-1] * v[1:]).sum(axis=1)          # косинусово разстояние между съседни изречения
        cut = np.percentile(dist, percentile)
        chunks, current = [], [sentences[0]]
        for sentence, d in zip(sentences[1:], dist):
            if d > cut:
                chunks.append(" ".join(current))
                current = [sentence]
            else:
                current.append(sentence)
        chunks.append(" ".join(current))
        return chunks
    
    # ── 4. Родител–дете: търсиш по малкото, подаваш голямото ──
    def hierarchical_chunks(text: str) -> list[dict]:
        parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1500, chunk_overlap=200)
        child_splitter = RecursiveCharacterTextSplitter(chunk_size=300, chunk_overlap=50)
        result = []
        for i, parent in enumerate(parent_splitter.split_text(text)):
            for child in child_splitter.split_text(parent):
                result.append({"child_text": child,     # малък → за embedding и търсене
                               "parent_text": parent,   # голям → за модела
                               "parent_id": i})
        return result
    
    # ── 5. Markdown: реже по заглавия и ги пази като metadata ──
    def markdown_chunks(md_text: str) -> list[dict]:
        splitter = MarkdownHeaderTextSplitter(
            headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")],
            strip_headers=False,
        )
        return [{"text": d.page_content, **d.metadata} for d in splitter.split_text(md_text)]
    ⚠️
    Знаци, не токени — и кратък контекст
    chunk_size в RecursiveCharacterTextSplitter брои знаци. Кирилицата обикновено се разбива на повече токени от английския, затова не мери чънковете „на око“. В библиотеката на Ollama nomic-embed-text е с прозорец от 2K токена (самият модел поддържа до 8192) — дръж чънковете малки, иначе краят им се губи.
    ⚠️
    Стар код в интернет
    Много примери ползват SemanticChunker от langchain-experimental. Пакетът е спрян от май 2026 и не се поддържа — затова по-горе семантичното нарязване е собствена функция от 20 реда. При перцентилния режим прагът е число от 0 до 100 (по подразбиране 95), не 0.85.
    ⚖️
    Илюстративен сценарий: закон по членове
    Представи си база знания със ЗЗД, нарязана на парчета от по 500 знака. На въпроса „Какъв е общият давностен срок?“ търсенето връща края на чл. 109 и началото на чл. 110 — без правилото, че с изтичането на петгодишна давност се погасяват вземанията, за които законът не предвижда друг срок. С нарязване по членове чл. 110 е цял и носи article="110" в metadata, така че отговорът може и да го цитира. Старият урок даваше „23% → 97% точност“ — не можахме да проверим източника, затова числата са махнати. Измервай на собствените си документи.
  3. Хибридно търсене: смисъл + точни думи

    Смисловото търсене (dense вектори) е отлично за въпроси като „кой плаща, когато съм болен“, но пропуска точни означения: „чл. 41“, „КСО“, номер на заповед. BM25 (sparse) намира точно тези думи, но не разбира перифраза. Хибридното търсене пуска и двете и слива резултатите.

    Заявка: „обезщетение болничен чл. 41 КСО“Какво намираКакво пропуска
    Dense (nomic-embed-text)„временна неработоспособност“, „парично обезщетение“ — сродни по смисълТочното „чл. 41“
    Sparse (BM25)Точно „чл. 41“, „КСО“Текстове със същия смисъл, но други думи
    Хибридно (RRF)Документите, които стоят високо и в двата списъка—
    ✅
    Как RRF слива двата списъка
    Reciprocal Rank Fusion гледа само позициите, не оценките: всеки документ получава Σ 1/(k + позиция) от всеки списък. Затова няма значение, че оценките на dense и BM25 са в различни мащаби. В Qdrant позициите започват от 0, а по подразбиране k = 2; от v1.16 можеш да зададеш k (класическата статия ползва 60), а от v1.17 — и тегла на всеки списък.

    Пусни Qdrant в Docker. Портовете са вързани само за локалния интерфейс, а ключът идва от .env.

    YAML · docker-compose.yml
    # docker-compose.yml — Qdrant на собствената машина
    services:
      qdrant:
        image: qdrant/qdrant:v1.19.1          # закована версия (последна към 01.10.2026)
        restart: unless-stopped
        ports:
          - "127.0.0.1:6333:6333"             # REST — само локално
          - "127.0.0.1:6334:6334"             # gRPC
        volumes:
          - ./qdrant_storage:/qdrant/storage
        environment:
          QDRANT__SERVICE__API_KEY: ${QDRANT_API_KEY}   # от .env
        healthcheck:                          # в образа няма curl и wget
          test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/6333 && printf 'GET /readyz HTTP/1.0\\r\\n\\r\\n' >&3 && grep -q ' 200 ' <&3"]
          interval: 15s
          timeout: 5s
          retries: 5
    ⚠️
    В образа на Qdrant няма curl
    Старата проверка curl -f …/healthz винаги пада, защото образът е изчистен от curl и wget. Проверката по-горе ползва bash и /readyz — той отговаря 200 едва когато Qdrant може да обслужва заявки. Не сме я пускали на жива машина — провери с docker ps, че статусът е healthy.
    Python · qdrant_hybrid.py
    import os
    import requests
    from fastembed import SparseTextEmbedding
    from qdrant_client import QdrantClient, models
    
    OLLAMA = "http://localhost:11434"
    COLLECTION = "academy_bg"
    
    client = QdrantClient(url=os.getenv("QDRANT_URL", "http://localhost:6333"),
                          api_key=os.getenv("QDRANT_API_KEY"))    # ключът — от .env, не в кода
    
    # ── 1. Колекция с два вида вектори ──
    if not client.collection_exists(COLLECTION):
        client.create_collection(
            collection_name=COLLECTION,
            vectors_config={
                "dense": models.VectorParams(size=768, distance=models.Distance.COSINE,
                                             on_disk=True),          # nomic-embed-text = 768
            },
            sparse_vectors_config={
                "sparse": models.SparseVectorParams(
                    index=models.SparseIndexParams(on_disk=True),
                    modifier=models.Modifier.IDF,                    # задължително за BM25
                ),
            },
        )
        # индекс върху полетата, по които филтрираш — иначе филтърът е бавен
        client.create_payload_index(COLLECTION, "course_id",
                                    field_schema=models.KeywordIndexParams(type="keyword", is_tenant=True))
    
    # ── 2. Embedding-и ──
    def get_dense(text: str, kind: str = "document") -> list[float]:
        """nomic-embed-text иска префикс: search_document: при запис, search_query: при търсене."""
        prefix = "search_query: " if kind == "query" else "search_document: "
        r = requests.post(f"{OLLAMA}/api/embed", timeout=60,
                          json={"model": "nomic-embed-text", "input": prefix + text})
        r.raise_for_status()
        return r.json()["embeddings"][0]
    
    # Qdrant/bm25 няма български стемер → изключваме го (иначе реже думите по английски правила)
    bm25 = SparseTextEmbedding(model_name="Qdrant/bm25", disable_stemmer=True)
    
    def get_sparse(text: str, kind: str = "document") -> models.SparseVector:
        emb = next(bm25.query_embed(text) if kind == "query" else bm25.embed([text]))
        return models.SparseVector(indices=emb.indices.tolist(), values=emb.values.tolist())
    
    # ── 3. Запис ──
    def index_document(doc_id: int, text: str, metadata: dict):
        client.upsert(COLLECTION, points=[models.PointStruct(
            id=doc_id,
            vector={"dense": get_dense(text), "sparse": get_sparse(text)},
            payload={"text": text, **metadata},
        )])
    
    # ── 4. Хибридно търсене: два prefetch-а + RRF в самия Qdrant ──
    def hybrid_search(query: str, top_k: int = 5, filters: dict | None = None) -> list[dict]:
        flt = None
        if filters:
            flt = models.Filter(must=[models.FieldCondition(key=k, match=models.MatchValue(value=v))
                                      for k, v in filters.items()])
        res = client.query_points(
            collection_name=COLLECTION,
            prefetch=[
                models.Prefetch(query=get_dense(query, "query"), using="dense", limit=20, filter=flt),
                models.Prefetch(query=get_sparse(query, "query"), using="sparse", limit=20, filter=flt),
            ],
            query=models.FusionQuery(fusion=models.Fusion.RRF),   # или models.RrfQuery(rrf=models.Rrf(k=60))
            limit=top_k,
            with_payload=True,
        )
        return [{"text": p.payload.get("text", ""), "score": p.score,
                 "metadata": {k: v for k, v in p.payload.items() if k != "text"}}
                for p in res.points]
    
    if __name__ == "__main__":
        for r in hybrid_search("обезщетение за болничен чл. 41 КСО", top_k=5, filters={"course_id": "42"}):
            print(f"{r['score']:.3f} | {r['text'][:100]}")
    ⚠️
    Три капана в хибридното търсене
    1. Без modifier=IDF в колекцията BM25 не отчита колко рядка е думата и губи смисъла си. 2. Qdrant/bm25 по подразбиране реже думите с английски стемер и маха английски стоп-думи; български стемер няма, затова disable_stemmer=True. За заявката ползвай query_embed, за документите — embed. 3. nomic-embed-text иска префикс search_document: при запис и search_query: при търсене — без тях точността пада.
    💾
    Памет: on_disk=True
    10 милиона вектора × 768 измерения × 4 байта са около 31 GB само за суровите вектори. С on_disk=True Qdrant ги държи във файлове, картографирани в паметта, и в RAM стоят предимно често търсените. Колко точно RAM ще ти трябва зависи от натоварването — измери, преди да купуваш хардуер. Старото „8 GB вместо 30 GB“ нямаше източник.
  4. Филтри по metadata: кой какво може да види

    В академия с много курсове студентът от курс А не бива да вижда материалите на курс Б. Филтърът в Qdrant се прилага по време на търсенето, не след него — затова върнатите 20 кандидата вече са само от правилния курс. Слагай филтъра във всеки Prefetch и прави индекс върху полетата, по които филтрираш (за ключа на „наемателя“ — is_tenant=True).

    Python · metadata_filters.py
    import time
    from qdrant_client import models
    
    # 1. Само един курс и само платени материали
    paid_course = models.Filter(must=[
        models.FieldCondition(key="course_id", match=models.MatchValue(value="42")),
        models.FieldCondition(key="price_tier", match=models.MatchValue(value="paid")),
    ])
    
    # 2. Само няколко закона, без отменените
    laws_in_force = models.Filter(
        must=[models.FieldCondition(key="domain", match=models.MatchAny(any=["ЗЗД", "ТЗ", "КТ", "КСО"]))],
        must_not=[models.FieldCondition(key="status", match=models.MatchValue(value="отменен"))],
    )
    
    # 3. Обновени през последните 30 дни (Unix време в полето updated_ts)
    recent = models.Filter(must=[
        models.FieldCondition(key="updated_ts", range=models.Range(gte=int(time.time()) - 30 * 24 * 3600)),
    ])
    
    # Употреба: client.query_points(..., query_filter=paid_course)  или във всеки Prefetch(filter=...)
    ⛔
    Филтърът е сигурност, не удобство
    Ако филтрираш след търсенето (в Python), грешка в кода показва чужди материали. Ако забравиш филтъра в един от двата Prefetch, сливането вкарва чужди документи. Тествай с потребител, който не бива да вижда даден документ.
  5. Преранкиране: 20 кандидата → 5 най-добри

    Векторното търсене изчислява заявката и документа поотделно и сравнява векторите. Cross-encoder чете заявката и документа заедно и преценява доколко документът отговаря именно на този въпрос. Това е по-точно, но и много по-бавно — затова го пускаш само върху 20-те кандидата от хибридното търсене.

    Документ (илюстративни стойности)Място след хибридноОценка cross-encoderМясто след преранкиране
    Чл. 40 КСО — първите дни плаща работодателят20.94🥇 1
    Чл. 41 КСО — размер на обезщетението10.712
    Общ преглед на осигурителното право30.185 ↓

    На въпроса „Кой плаща първите дни от болничния?“ общият преглед е близък по смисъл, но не отговаря — преранкирането го сваля надолу.

    Python · reranker_pipeline.py
    import time
    from sentence_transformers import CrossEncoder
    from qdrant_hybrid import hybrid_search
    
    # bge-reranker-v2-m3: многоезичен, лиценз Apache-2.0, ~570M параметъра; първото пускане сваля модела
    reranker = CrossEncoder("BAAI/bge-reranker-v2-m3", max_length=512)
    
    def rerank(query: str, candidates: list[dict], top_k: int = 5) -> list[dict]:
        """Cross-encoder чете заявката и всеки документ ЗАЕДНО и дава нова оценка."""
        if not candidates:
            return []
        ranked = reranker.rank(query, [c["text"] for c in candidates], top_k=top_k)
        return [{**candidates[r["corpus_id"]], "rerank_score": float(r["score"])} for r in ranked]
    
    def advanced_retrieve(query: str, course_id: str | None = None,
                          top_k: int = 5, candidate_k: int = 20) -> list[dict]:
        """Хибридно търсене → 20 кандидата → преранкиране → 5 за модела."""
        filters = {"course_id": course_id} if course_id else None
        return rerank(query, hybrid_search(query, top_k=candidate_k, filters=filters), top_k=top_k)
    
    if __name__ == "__main__":
        q = "кой плаща първите дни от болничния"
        t0 = time.perf_counter(); plain = hybrid_search(q, top_k=5)
        t1 = time.perf_counter(); best = advanced_retrieve(q, top_k=5)
        t2 = time.perf_counter()
        print(f"без reranker: {(t1 - t0) * 1000:.0f} ms · с reranker: {(t2 - t1) * 1000:.0f} ms")
        for d in best:
            print(f"{d['rerank_score']:.3f} | {d['text'][:90]}")
    ⚠️
    Кой модел и с какъв лиценз
    BAAI/bge-reranker-v2-m3 е многоезичен и под Apache-2.0 — може и в търговски продукт. Библиотеката fastembed не го поддържа (старият код спираше с грешка). Многоезичният модел, който fastembed има (jina-reranker-v2-base-multilingual), е под CC-BY-NC-4.0 — само за нетърговска употреба. Английските ms-marco модели са бързи, но не са правени за кирилица.
    ⏱️
    Колко струва във време
    Старият урок обещаваше „48 ms → 312 ms“. Това зависи изцяло от машината, дължината на текстовете и дали имаш GPU. Скриптът по-горе мери и двата пътя — пусни го на своята машина и реши дали забавянето си струва.
  6. Всичко заедно: от въпрос до отговор с цитат

    Последната стъпка събира веригата: хибридно търсене с филтър → преранкиране → петте най-добри откъса отиват при модела с етикет на източника. Системният промпт казва на модела да отговаря само от контекста и честно да каже, когато отговорът липсва.

    Python · full_rag_pipeline.py
    from langchain_core.prompts import ChatPromptTemplate
    from langchain_ollama import ChatOllama
    from reranker_pipeline import advanced_retrieve
    
    llm = ChatOllama(model="llama3.1:8b", temperature=0.1)   # по-голям модел = по-добър български
    
    PROMPT = ChatPromptTemplate.from_messages([
        ("system", "Ти си асистент на учебна AI академия. Отговаряй САМО от дадения контекст. "
                   "Ако отговорът не е там, кажи: „Тази информация не е в наличните материали.“ "
                   "Отговаряй на български. При правни въпроси цитирай члена в квадратни скоби."),
        ("human", "Контекст:\n{context}\n\nВъпрос: {question}"),
    ])
    
    def answer(question: str, course_id: str | None = None) -> dict:
        docs = advanced_retrieve(question, course_id=course_id, top_k=5)
        context = "\n\n".join(
            f"[{d['metadata'].get('source', 'Документ')}"
            f"{' · чл. ' + d['metadata']['article'] if d['metadata'].get('article') else ''}]: {d['text']}"
            for d in docs)
        reply = (PROMPT | llm).invoke({"context": context, "question": question})
        return {"answer": reply.content, "sources": [d["metadata"] for d in docs]}
    
    if __name__ == "__main__":
        r = answer("Кой плаща първите два дни от болничния и колко?")
        print(r["answer"])
        print("Източници:", [s.get("source", "?") for s in r["sources"]])
    ⛔
    Правните отговори са чернова
    Системата цитира текста, който е намерила — не проверява дали законът не е променен. Дръж в базата само действащи редакции (полето status) и давай отговорите като ориентир, не като правен съвет.

04Проверка

Чеклист

Тест

1. Хибридното търсене е включено, но оценката context recall е ниска (0.42). Коя е НАЙ-ВЕРОЯТНАТА причина?

2. Документ има dense оценка 0.92 и BM25 оценка 0.15. Как RRF изчислява крайната му оценка?

3. Cross-encoder е по-точен от векторното търсене. Защо не търсим директно с него?

4. Защо при Qdrant/bm25 за български задаваме disable_stemmer=True?

05Какво следва

06Източници

  1. Qdrant: хибридни заявки — prefetch, RRF, DBSF, параметър k и тегла (от кои версии).
  2. Qdrant: филтриране — FieldCondition, MatchValue, MatchAny, Range.
  3. Qdrant: индексиране — sparse индекс и модификаторът IDF, индекси върху payload.
  4. Qdrant: версии и проверка на здравето в Docker — защо в образа няма curl.
  5. FastEmbed — BM25 и списък на поддържаните модели.
  6. LangChain: разделители на текст — рекурсивно и Markdown нарязване.
  7. Спиране на langchain-experimental — съобщението от май 2026.
  8. Ollama: /api/embed, nomic-embed-text в Ollama и картата на модела — префиксите search_document / search_query.
  9. BAAI/bge-reranker-v2-m3 🌐 глобален — картата на модела и лицензът; BGE M3-Embedding (2024) — статията за основата му.
  10. Sentence Transformers: CrossEncoder — rank() и predict().
  11. Reciprocal Rank Fusion (Cormack и съавт., 2009) — оригиналната статия.
  12. НОИ: обезщетение при временна неработоспособност — чл. 40–41 КСО, фактите в примерите.