Класичний RAG із суто векторним пошуком часто «промахується» по точних термінах, артикулах або іменах — і видає нерелевантні чанки. Гібридний підхід поєднує векторний (семантичний) і keyword (BM25) пошук, що драматично підвищує якість відповідей. Цей туторіал покаже, як зібрати робочий гібридний пошук на Python із використанням Qdrant і LangChain — від нуля до готового пайплайну за 2–3 години. Для старту потрібні базові знання Python і встановлений Docker на вашій машині.
🛠️ Що знадобиться
- Python 3.11+ — основна мова; встановіть із python.org, якщо ще немає
- Qdrant (Docker-образ) — векторна БД з вбудованою підтримкою гібридного пошуку через sparse + dense вектори; безкоштовний self-hosted варіант
- LangChain 0.3+ — оркестрація RAG-пайплайну; безкоштовний open-source пакет
- FastEmbed — локальна бібліотека для генерації dense і sparse ембедингів без API-ключів; безкоштовна
- OpenAI API або Ollama — LLM для генерації фінальної відповіді; OpenAI платний, Ollama безкоштовний локально
- Rank Fusion (вбудований у Qdrant) — алгоритм Reciprocal Rank Fusion для злиття результатів двох пошуків; входить у Qdrant безкоштовно
📋 Покрокова інструкція
Крок 1: Піднімаємо Qdrant локально і створюємо колекцію з hybrid-підтримкою
Відкрийте термінал і запустіть Qdrant через Docker однією командою: docker run -p 6333:6333 -p 6334:6334 -v $(pwd)/qdrant_storage:/qdrant/storage qdrant/qdrant:v1.13.0. Після запуску відкрийте браузер і перейдіть на http://localhost:6333/dashboard — ви побачите веб-інтерфейс Qdrant. Тепер встановіть Python-залежності командою: pip install qdrant-client fastembed langchain langchain-openai langchain-qdrant python-dotenv. Створіть файл setup_collection.py і вставте код для ініціалізації колекції з двома векторними просторами — dense (768 вимірів для семантики) і sparse (для BM25-подібного пошуку).

from qdrant_client import QdrantClient
from qdrant_client.models import (
VectorParams, Distance, SparseVectorParams, SparseIndexParams
)
client = QdrantClient("http://localhost:6333")
client.create_collection(
collection_name="hybrid_rag",
vectors_config={
"dense": VectorParams(size=384, distance=Distance.COSINE)
},
sparse_vectors_config={
"sparse": SparseVectorParams(
index=SparseIndexParams(on_disk=False)
)
}
)
print("Колекцію створено успішно!")
Запустіть скрипт командою python setup_collection.py і переконайтесь, що в консолі з’явилось повідомлення про успіх.
Крок 2: Індексуємо документи з генерацією dense і sparse векторів одночасно
Створіть файл indexer.py. Ключова ідея — для кожного чанку тексту генеруємо два типи ембедингів паралельно: dense через модель BAAI/bge-small-en-v1.5 (FastEmbed) і sparse через prithvida/Splade_PP_en_v1 (також FastEmbed). Зверніть увагу: sparse вектори зберігаються у форматі словника {індекс: вага} — це не помилка, так і повинно бути. Підводний камінь: якщо ваші документи українською, додайте мультилінгвальну модель intfloat/multilingual-e5-small замість bge-small для кращої якості.
from fastembed import TextEmbedding, SparseTextEmbedding
from qdrant_client import QdrantClient
from qdrant_client.models import PointStruct, SparseVector
import uuid
dense_model = TextEmbedding("BAAI/bge-small-en-v1.5")
sparse_model = SparseTextEmbedding("prithvida/Splade_PP_en_v1")
client = QdrantClient("http://localhost:6333")
documents = [
"Артикул XR-2045: промисловий насос, тиск 120 бар, витрата 50 л/хв",
"Семантичний пошук знаходить схожі за змістом документи навіть без точних збігів",
"Артикул ZT-9901: клапан зворотний, діаметр DN50, матеріал нержавіюча сталь",
]
points = []
for i, doc in enumerate(documents):
dense_vec = list(dense_model.embed([doc]))[0].tolist()
sparse_vec = list(sparse_model.embed([doc]))[0]
points.append(PointStruct(
id=str(uuid.uuid4()),
vector={
"dense": dense_vec,
"sparse": SparseVector(
indices=sparse_vec.indices.tolist(),
values=sparse_vec.values.tolist()
)
},
payload={"text": doc, "source": f"doc_{i}"}
))
client.upsert(collection_name="hybrid_rag", points=points)
print(f"Проіндексовано {len(points)} документів")
Крок 3: Реалізуємо гібридний пошук із Reciprocal Rank Fusion
Тепер найважливіша частина — сам гібридний запит. Qdrant 1.10+ підтримує Query API з вбудованим RRF, що дозволяє злити результати dense і sparse пошуку в один ранжований список однією командою. Створіть файл hybrid_search.py. Параметр limit=5 у prefetch контролює скільки кандидатів беремо з кожного пошуку перед злиттям — збільшіть до 20 для більшої точності на великих колекціях.
from qdrant_client import QdrantClient
from qdrant_client.models import Prefetch, Query, FusionQuery, Fusion
from fastembed import TextEmbedding, SparseTextEmbedding
client = QdrantClient("http://localhost:6333")
dense_model = TextEmbedding("BAAI/bge-small-en-v1.5")
sparse_model = SparseTextEmbedding("prithvida/Splade_PP_en_v1")
def hybrid_search(query_text: str, top_k: int = 3):
dense_vec = list(dense_model.embed([query_text]))[0].tolist()
sparse_vec = list(sparse_model.embed([query_text]))[0]
results = client.query_points(
collection_name="hybrid_rag",
prefetch=[
Prefetch(query=dense_vec, using="dense", limit=10),
Prefetch(
query=SparseVector(
indices=sparse_vec.indices.tolist(),
values=sparse_vec.values.tolist()
),
using="sparse",
limit=10
),
],
query=FusionQuery(fusion=Fusion.RRF),
limit=top_k,
with_payload=True
)
return [r.payload["text"] for r in results.points]
# Тест
hits = hybrid_search("клапан артикул ZT")
for h in hits:
print(f"→ {h}")
Крок 4: Інтегруємо гібридний ретривер у LangChain RAG-пайплайн
Тепер обгортаємо наш гібридний пошук у LangChain-сумісний ретривер і підключаємо LLM. Створіть файл rag_pipeline.py. Використайте клас BaseRetriever для кастомного ретривера, щоб LangChain «не знав» про деталі реалізації — це дає гнучкість замінити Qdrant у майбутньому без переписування всього пайплайну. Якщо хочете використати Ollama замість OpenAI, замініть ChatOpenAI на ChatOllama(model="llama3.2") і не забудьте запустити ollama pull llama3.2 у терміналі.
from langchain.schema import BaseRetriever, Document
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
from typing import List
import os
class HybridQdrantRetriever(BaseRetriever):
def _get_relevant_documents(self, query: str) -> List[Document]:
texts = hybrid_search(query, top_k=4)
return [Document(page_content=t) for t in texts]
llm = ChatOpenAI(model="gpt-4o-mini", api_key=os.getenv("OPENAI_API_KEY"))
prompt = ChatPromptTemplate.from_template("""
Відповідай ТІЛЬКИ на основі наданого контексту. Якщо відповіді немає — скажи чесно.
Контекст: {context}
Питання: {question}
Відповідь:""")
retriever = HybridQdrantRetriever()
chain = (
{"context": retriever | (lambda docs: "\n".join(d.page_content for d in docs)),
"question": RunnablePassthrough()}
| prompt | llm | StrOutputParser()
)
answer = chain.invoke("Які технічні характеристики артикулу ZT-9901?")
print(answer)
Крок 5: Тестуємо якість і налаштовуємо ваги RRF
Запустіть python rag_pipeline.py і порівняйте результати з чисто векторним пошуком. Для оцінки якості запустіть кілька запитів: один з точним артикулом (де виграє sparse), один концептуальний (де виграє dense) — гібрид має обидва відповісти правильно. Якщо хочете налаштувати баланс між dense і sparse, передайте параметр score_threshold або змініть limit у prefetch: більший limit для sparse посилює точний пошук, більший для dense — семантичний. Фінальний результат: у вас є повноцінний RAG-пайплайн, який знаходить документи і за змістом, і за точними термінами одночасно.
⚠️ Типові помилки та як їх уникнути
- Неправильний розмір dense вектора при створенні колекції — перевірте документацію моделі: bge-small видає 384 виміри, bge-base — 768; якщо вказати не той розмір, отримаєте помилку при upsert. Завжди друкуйте
len(dense_vec)перед створенням колекції. - Sparse вектори всі нульові або порожні — це трапляється, якщо текст занадто короткий (менше 3 слів) або модель SPLADE не завантажилась повністю. Перевірте
sparse_vec.values.sum() > 0перед індексацією. - RRF повертає гірші результати ніж просто dense — найчастіша причина: занадто малий prefetch limit (менше 5). Збільшіть до 15–20, і RRF зможе якісно перемішати кандидатів від обох методів.
- Повільна індексація через послідовну генерацію ембедингів — використовуйте batch-режим: передавайте список рядків у
model.embed(documents)одразу, а не в циклі по одному документу.
💡 Поради для кращого результату
По-перше, для українських текстів обов’язково використовуйте intfloat/multilingual-e5-small як dense модель — вона дає на 15–20% кращу семантичну точність порівняно з англомовними моделями. По-друге, при чанкуванні документів додавайте заголовок секції на початок кожного чанку у форматі «Назва розділу: текст чанку» — це різко покращує sparse пошук по термінах. По-третє, встановіть score_threshold=0.0 у prefetch і аналізуйте scores через r.score — якщо dense scores завжди вищі за sparse, ваші документи переважно концептуальні, і можна збільшити prefetch limit для dense. По-четверте, кешуйте ембединги запитів через functools.lru_cache — повторні однакові запити будуть відповідати миттєво без зайвих обчислень.
❓ Часті запитання (FAQ)
1. Чим гібридний пошук кращий за просто збільшення кількості чанків у dense-пошуку?
Dense пошук принципово не вміє знаходити точні збіги артикулів, кодів або рідкісних термінів — він «розмиває» значення у векторному просторі. Гібрид вирішує це структурно, а не кількісно: sparse компонент відповідає саме за точні збіги незалежно від розміру колекції.

2. Чи можна замінити Qdrant на Weaviate або Milvus?
Так, обидві БД підтримують гібридний пошук у 2026 році. Weaviate має вбудований BM25 через параметр hybrid у запиті, Milvus підтримує sparse через окремий індекс. Але Qdrant наразі має найпростіший API для RRF з коробки без додаткових налаштувань.
3. Скільки документів може обробити ця архітектура?
Qdrant self-hosted легко масштабується до десятків мільйонів чанків на одному сервері з достатньою RAM. Для продакшн-навантаження від 1 млн документів увімкніть on_disk=True для sparse індексу і використовуйте Qdrant Cloud замість Docker.
4. Як виміряти, чи справді гібрид кращий за моноопошук у моєму кейсі?
Зберіть тестовий датасет із 20–30 запитань з еталонними відповідями (ground truth). Запустіть пошук трьома способами: тільки dense, тільки sparse, гібрид — і порахуйте Recall@5 для кожного. Зазвичай гібрид виграє на 10–25% на змішаних датасетах.
5. Чи потрібно перестворювати колекцію, якщо я вже маю існуючий векторний індекс без sparse?
На жаль, так — Qdrant не дозволяє додати sparse вектори до вже існуючої колекції без її пересоздання. Спочатку зробіть snapshot через Dashboard (кнопка «Snapshots» → «Create snapshot»), потім видаліть колекцію, створіть нову з обома типами векторів і переіндексуйте документи заново.
🏁 Підсумок
Ви побудували повноцінний гібридний RAG-пайплайн: Qdrant із dual-vector колекцією, FastEmbed для dense і sparse ембедингів, Reciprocal Rank Fusion для злиття результатів і LangChain для оркестрації всього у зручний ланцюжок. Ця архітектура однаково добре знаходить і «насос із тиском 120 бар», і «обладнання для високого тиску» — що є головним показником якісного RAG.
Почніть прямо зараз із Кроку 1 — запустіть Docker-контейнер Qdrant і проіндексуйте 10–20 своїх реальних документів. Вже після першого тестового запиту ви побачите різницю в якості пошуку порівняно з тим, що у вас є зараз.
РОЗСИЛКА
📬 Щотижневий AI-дайджест
Найкращі статті про ШІ та автоматизацію — без спаму, лише суть
Без спаму · Відписатись будь-коли

