Advanced RAG: чънкове, хибридно търсене, преранкиране
Базовият RAG (раздели → вгради → намери → отговори) работи в повечето случаи. Проваля се на три места: лошо нарязан текст, пропуснати точни термини и слабо подреждане. В този урок поправяме и трите — с Qdrant на собствената машина и модели, които вървят локално.
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Какво ще научиш
- Защо лошото нарязване е най-честата причина RAG да „не намира“ и как да избереш стратегия според вида документ.
- Как да режеш закон по членове, текст по смисъл и дълъг документ на „родител и дете“.
- Как да съчетаеш смислово (dense) и точно (BM25) търсене в Qdrant и да ги слееш с RRF.
- Как да ограничиш резултатите по курс, закон или дата с филтри — без нищо да изтече между потребители.
- Как преранкиращ модел (cross-encoder) подрежда 20 кандидата, за да стигнат до модела само най-добрите 5.
02Преди да започнеш
- Минал си Блокове 0–3 и имаш работещ базов RAG: знаеш какво е embedding и векторна база.
- Python 3.10 или по-нов, виртуална среда и Docker.
- Ollama 🔒 локално с
nomic-embed-text(за вграждане) и модел за отговори, напримерllama3.1:8b. - Няколко GB свободно място за модела за преранкиране — сваля се при първото пускане от Hugging Face 🌐 глобален.
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:8b03Стъпки
-
Защо базовото нарязване се проваля
Нарязването по фиксиран размер (например на всеки 500 знака) не знае къде свършва една мисъл. Член от закона може да попадне наполовина в края на един чънк и наполовина в началото на следващия — тогава никой чънк не съдържа цялото правило, търсенето връща парче и моделът отговаря непълно или грешно. Защо това е важно? Защото никакъв по-добър модел не поправя информация, която не е стигнала до него.
пример · един член, прерязан на две (учебен)Чънк 1: …Чл. 40. (5) Работодателят изплаща първите два работни дни от временната неработоспособност в размер 70 на сто от среднодневното Чънк 2: брутно възнаграждение… Чл. 41. (1) Паричното обезщетение е 80 на сто от среднодневното брутно възнаграждение… Въпрос: „Колко плаща работодателят за първите дни от болничния?“ → Чънк 1 няма базата („брутно възнаграждение“), Чънк 2 започва с чужд член.💡Фактите в примера (сверени с НОИ)От 2024 г. работодателят плаща първите два работни дни по 70%, а НОИ — обезщетение от 80% от среднодневното брутно възнаграждение (чл. 40–41 КСО). Текстът по-горе е съкратен за урока, не е дословен цитат. -
Пет стратегии за нарязване
Няма една правилна стратегия — зависи от документа. Започни с рекурсивното нарязване и мини на по-сложно само когато проверката (раздел „Проверка“ и урок 01-04b) покаже, че не стига.
Стратегия Как работи Подходяща за Фиксиран размер + застъпване N знака, K застъпване. Бързо и просто. Обща проза, новини. Лошо за закони. Рекурсивно ★ Реже по празен ред → нов ред → „. “ — пази абзаците. Повечето документи. Добро начало. Семантично Сравнява съседни изречения и реже там, където смисълът „скача“. Смесени текстове без ясна структура: доклади, становища. Родител–дете Малки чънкове за търсене, голям „родител“ за контекст на модела. Дълги технически документи. По структура ★ Разпознава заглавия (##), членове („Чл. N.“), таблици. Закони, наредби, стандарти (ЗЗД, КСО, КТ). Python · advanced_chunking.pyimport 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брои знаци. Кирилицата обикновено се разбива на повече токени от английския, затова не мери чънковете „на око“. В библиотеката на Ollamanomic-embed-textе с прозорец от 2K токена (самият модел поддържа до 8192) — дръж чънковете малки, иначе краят им се губи.⚠️Стар код в интернетМного примери ползватSemanticChunkerотlangchain-experimental. Пакетът е спрян от май 2026 и не се поддържа — затова по-горе семантичното нарязване е собствена функция от 20 реда. При перцентилния режим прагът е число от 0 до 100 (по подразбиране 95), не 0.85.⚖️Илюстративен сценарий: закон по членовеПредстави си база знания със ЗЗД, нарязана на парчета от по 500 знака. На въпроса „Какъв е общият давностен срок?“ търсенето връща края на чл. 109 и началото на чл. 110 — без правилото, че с изтичането на петгодишна давност се погасяват вземанията, за които законът не предвижда друг срок. С нарязване по членове чл. 110 е цял и носиarticle="110"в metadata, така че отговорът може и да го цитира. Старият урок даваше „23% → 97% точност“ — не можахме да проверим източника, затова числата са махнати. Измервай на собствените си документи. -
Хибридно търсене: смисъл + точни думи
Смисловото търсене (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.pyimport 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=True10 милиона вектора × 768 измерения × 4 байта са около 31 GB само за суровите вектори. Сon_disk=TrueQdrant ги държи във файлове, картографирани в паметта, и в RAM стоят предимно често търсените. Колко точно RAM ще ти трябва зависи от натоварването — измери, преди да купуваш хардуер. Старото „8 GB вместо 30 GB“ нямаше източник. -
Филтри по metadata: кой какво може да види
В академия с много курсове студентът от курс А не бива да вижда материалите на курс Б. Филтърът в Qdrant се прилага по време на търсенето, не след него — затова върнатите 20 кандидата вече са само от правилния курс. Слагай филтъра във всеки
Prefetchи прави индекс върху полетата, по които филтрираш (за ключа на „наемателя“ —is_tenant=True).Python · metadata_filters.pyimport 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, сливането вкарва чужди документи. Тествай с потребител, който не бива да вижда даден документ. -
Преранкиране: 20 кандидата → 5 най-добри
Векторното търсене изчислява заявката и документа поотделно и сравнява векторите. Cross-encoder чете заявката и документа заедно и преценява доколко документът отговаря именно на този въпрос. Това е по-точно, но и много по-бавно — затова го пускаш само върху 20-те кандидата от хибридното търсене.
Документ (илюстративни стойности) Място след хибридно Оценка cross-encoder Място след преранкиране Чл. 40 КСО — първите дни плаща работодателят 2 0.94 🥇 1 Чл. 41 КСО — размер на обезщетението 1 0.71 2 Общ преглед на осигурителното право 3 0.18 5 ↓ На въпроса „Кой плаща първите дни от болничния?“ общият преглед е близък по смисъл, но не отговаря — преранкирането го сваля надолу.
Python · reranker_pipeline.pyimport 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. Скриптът по-горе мери и двата пътя — пусни го на своята машина и реши дали забавянето си струва. -
Всичко заедно: от въпрос до отговор с цитат
Последната стъпка събира веригата: хибридно търсене с филтър → преранкиране → петте най-добри откъса отиват при модела с етикет на източника. Системният промпт казва на модела да отговаря само от контекста и честно да каже, когато отговорът липсва.
Python · full_rag_pipeline.pyfrom 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Проверка
Чеклист
- Стратегията за нарязване е избрана според вида документ; законите са нарязани по членове с номер в metadata.
- Вграждането ползва префиксите
search_document:иsearch_query:. - Колекцията има вектори
denseиsparse; sparse е сmodifier=IDF. - BM25 е без английски стемер.
- Хибридната заявка намира и точен номер на член, и перифраза без него.
- Филтърът е във всеки
Prefetch; има индекс върху филтрираните полета. - Преранкирането е върху ~20 кандидата; забавянето е измерено.
- Моделът отговаря само от контекста и цитира члена.
- Ключът на Qdrant е в средата, портовете не са отворени навън.
Тест
1. Хибридното търсене е включено, но оценката context recall е ниска (0.42). Коя е НАЙ-ВЕРОЯТНАТА причина?
2. Документ има dense оценка 0.92 и BM25 оценка 0.15. Как RRF изчислява крайната му оценка?
3. Cross-encoder е по-точен от векторното търсене. Защо не търсим директно с него?
4. Защо при Qdrant/bm25 за български задаваме disable_stemmer=True?
05Какво следва
06Източници
- Qdrant: хибридни заявки — prefetch, RRF, DBSF, параметър k и тегла (от кои версии).
- Qdrant: филтриране —
FieldCondition,MatchValue,MatchAny,Range. - Qdrant: индексиране — sparse индекс и модификаторът IDF, индекси върху payload.
- Qdrant: версии и проверка на здравето в Docker — защо в образа няма curl.
- FastEmbed — BM25 и списък на поддържаните модели.
- LangChain: разделители на текст — рекурсивно и Markdown нарязване.
- Спиране на langchain-experimental — съобщението от май 2026.
- Ollama: /api/embed, nomic-embed-text в Ollama и картата на модела — префиксите
search_document/search_query. - BAAI/bge-reranker-v2-m3 🌐 глобален — картата на модела и лицензът; BGE M3-Embedding (2024) — статията за основата му.
- Sentence Transformers: CrossEncoder —
rank()иpredict(). - Reciprocal Rank Fusion (Cormack и съавт., 2009) — оригиналната статия.
- НОИ: обезщетение при временна неработоспособност — чл. 40–41 КСО, фактите в примерите.