# Гибридный поиск в локальном RAG: dense, sparse и точные артикулы

Источник: https://modelium.tech/zhurnal/gibridnyy-poisk-rag/

Гибридный поиск соединяет dense-векторы для смысловых формулировок и sparse-векторы BM25 для совпадений слов; Qdrant объединяет две выдачи через RRF. Для артикула добавьте отдельный точный поиск по полю keyword: BM25 разбивает строку на токены и не гарантирует совпадение всего кода. Ниже — локальная демонстрация с Ollama, FastEmbed и Qdrant; качество на ваших документах нужно проверять по размеченным запросам.

**Проверено по документации 24 сентября 2026 года.** Команды ниже — пример для собственного теста, а не отчёт о прогоне Modelium. Пример использует синтетические карточки; реальные результаты, задержка и требования к памяти зависят от ваших данных и машины. Запрос «гибридный поиск rag» встречается в подсказках Google и Яндекса, но его частотность не измерена.

## Какой поиск нужен для RAG с артикулами?

В корпоративном RAG запрос «чем заменить клапан подачи» и запрос `KT-417` требуют разных сигналов. Dense-вектор приближает фрагменты по смыслу, sparse BM25 ищет совпадения слов, а Qdrant может слить обе выдачи через reciprocal rank fusion (RRF) [s1](#src-s1)[s2](#src-s2). Это помогает собрать кандидатов для ответа с цитатами, но само по себе не проверяет корректность ответа модели.

**Не доверяйте гибридному ранжированию задачу точного равенства артикула.** BM25 обрабатывает строку как набор токенов; в реализации FastEmbed перед токенизацией удаляются некоторые неалфавитно-цифровые символы, поэтому строку с дефисом нельзя считать неделимым ключом [s6](#src-s6). Вынесите нормализованный артикул в отдельное поле payload с индексом `keyword` и при кодовом запросе сначала используйте точный фильтр; он сравнивает строку без токенизации и чувствителен к регистру [s4](#src-s4). Если точный код не найден, можно отдельно предложить гибридные **кандидаты**, но не выдавать их за точное совпадение.

        

          
        
        
Вход

      
    
        
Основная ветка

      
    
        
Контроль

      
    

`  </tr><tr>
    <td>
    <p>«клапан линии подачи»</p>
  </td>
<td>
    <p>dense + sparse → RRF</p>
  </td>
<td>
    <p>есть ли релевантный фрагмент с источником <a href="#src-s1" rel="noopener noreferrer">s1</a></p>
  </td>

  </tr><tr>
    <td>
    <p><code>KT-417</code></p>
  </td>
<td>
    <p>индексированный <code>sku</code> с точным совпадением</p>
  </td>
<td>
    <p>совпадает ли весь код после нормализации <a href="#src-s4" rel="noopener noreferrer">s4</a></p>
  </td>

  </tr><tr>
    <td>
    <p>«клапан KT-417»</p>
  </td>
<td>
    <p>точный SKU, затем смысловой/словесный поиск для контекста</p>
  </td>
<td>
    <p>не подменён ли конкретный товар похожим <a href="#src-s4" rel="noopener noreferrer">s4</a><a href="#src-s1" rel="noopener noreferrer">s1</a></p>
  </td>

  </tr></tbody>
    </table>
  <p>Это <strong>правило маршрутизации</strong>, предложенное редакцией, а не опубликованный результат эксперимента. У реальных артикулов могут отличаться тире, латиница/кириллица, регистр, нули и альтернативные написания. Нормализацию и справочник эквивалентных кодов согласуйте с владельцем данных: слепо удалять символы нельзя.</p><h2>Как подготовить полностью локальную демонстрацию?</h2><p>Понадобятся Docker, Python с <code>venv</code>, установленная <a href="/zapusk/ollama/" rel="noopener noreferrer">Ollama</a> и возможность один раз получить образ Qdrant и веса embedding-модели. Локальный Qdrant по умолчанию <strong>не включает аутентификацию и шифрование</strong>; не открывайте порт в сеть и не помещайте туда реальные корпоративные документы без отдельной политики доступа <a href="#src-s5" rel="noopener noreferrer">s5</a>. Пример использует опубликованный тег образа <code>v1.19.1</code>; перед рабочим развёртыванием зафиксируйте digest образа, версии Python-библиотек, Ollama и скачанных весов. Тег EmbeddingGemma требует Ollama не ниже 0.11.10 по карточке вендора <a href="#src-s8" rel="noopener noreferrer">s8</a>.</p><p>mkdir -p qdrant_storage<br />docker run -d --name qdrant-hybrid-demo \<br />  -p 127.0.0.1:6333:6333 \<br />  -v "$PWD/qdrant_storage:/qdrant/storage" \<br />  qdrant/qdrant:v1.19.1<br />curl -fsS http://127.0.0.1:6333/<br />ollama pull embeddinggemma<br />curl -fsS http://127.0.0.1:11434/api/embed \<br />  -H 'Content-Type: application/json' \<br />  -d '{"model":"embeddinggemma","input":"контрольный фрагмент"}'<br />python3 -m venv .venv<br />.venv/bin/python -m pip install qdrant-client fastembed requests<br /></p><p>У <code>/api/embed</code> ожидается JSON с массивом <code>embeddings</code>; пустой массив или ошибка HTTP — повод остановиться, а не подставить выдуманный вектор <a href="#src-s7" rel="noopener noreferrer">s7</a>. В официальном облачном примере Qdrant указан <code>cloud_inference=True</code>; <strong>не копируйте</strong> его в сценарий закрытого контура. Ниже dense считает локальная Ollama, sparse — клиентский FastEmbed, а Qdrant хранит и объединяет векторы <a href="#src-s1" rel="noopener noreferrer">s1</a><a href="#src-s3" rel="noopener noreferrer">s3</a><a href="#src-s6" rel="noopener noreferrer">s6</a><a href="#src-s7" rel="noopener noreferrer">s7</a>. Скачивание контейнера, Python-зависимостей и модели всё равно обращается к внешним источникам: для строго офлайн-стенда сначала подготовьте и проверьте эти артефакты, затем проведите отдельный <a href="/zhurnal/proverka-offline-kontura-ii/" rel="noopener noreferrer">сетевой тест</a>.</p><h2>Как создать dense, sparse и точную ветки?</h2><p>Сохраните пример как <code>hybrid_demo.py</code> и выполните <code>.venv/bin/python hybrid_demo.py</code> <strong>только на тестовой коллекции</strong>. Он намеренно завершится, если коллекция с таким именем уже есть, и никогда не удаляет данные. Размер dense-поля берётся из фактического ответа Ollama: документация подчёркивает, что он зависит от embedding-модели <a href="#src-s7" rel="noopener noreferrer">s7</a>. FastEmbed <code>Qdrant/bm25</code> обрабатывает русский текст параметром <code>language="russian"</code>; его реализация требует IDF-модификатор у sparse-поля Qdrant <a href="#src-s6" rel="noopener noreferrer">s6</a><a href="#src-s3" rel="noopener noreferrer">s3</a>.</p><p>import requests<br />from fastembed import SparseTextEmbedding<br />from qdrant_client import QdrantClient, models<br /><br />COLL = "modelium_hybrid_demo"<br />ollama = "http://127.0.0.1:11434/api/embed"<br />client = QdrantClient(url="http://127.0.0.1:6333")<br />bm25 = SparseTextEmbedding(model_name="Qdrant/bm25", language="russian")<br /><br /><br />def dense(text):<br />    response = requests.post(ollama, json={"model": "embeddinggemma", "input": text}, timeout=120)<br />    response.raise_for_status()<br />    return response.json()["embeddings"][0]<br /><br /><br />def sparse(text, query=False):<br />    result = next(bm25.query_embed(text) if query else bm25.embed([text]))<br />    return models.SparseVector(indices=result.indices.tolist(), values=result.values.tolist())<br /><br /><br />rows = [<br />    (1, "KT-417", "Клапан KT-417 управляет подачей жидкости в контуре А."),<br />    (2, "KT-418", "Клапан KT-418 предназначен для обратной линии контура Б."),<br />    (3, "PM-931", "Насос PM-931 поддерживает давление линии подачи."),<br />]<br />if client.collection_exists(COLL):<br />    raise SystemExit(f"Коллекция {COLL} уже есть: выберите отдельную тестовую среду")<br />dim = len(dense(rows[0][2]))<br />client.create_collection(<br />    collection_name=COLL,<br />    vectors_config={"dense": models.VectorParams(size=dim, distance=models.Distance.COSINE)},<br />    sparse_vectors_config={"lexical": models.SparseVectorParams(modifier=models.Modifier.IDF)},<br />)<br />client.create_payload_index(COLL, field_name="sku", field_schema=models.PayloadSchemaType.KEYWORD, wait=True)<br />client.upsert(<br />    collection_name=COLL,<br />    points=[models.PointStruct(<br />        id=ident,<br />        vector={"dense": dense(text), "lexical": sparse(text)},<br />        payload={"sku": sku, "text": text, "source": f"demo:{ident}"},<br />    ) for ident, sku, text in rows],<br />    wait=True,<br />)<br /><br />query = "клапан в линии подачи"<br />vector = dense(query)<br />lexical = sparse(query, query=True)<br />for name, params in (<br />    ("dense", {"query": vector, "using": "dense"}),<br />    ("sparse", {"query": lexical, "using": "lexical"}),<br />):<br />    hits = client.query_points(collection_name=COLL, limit=3, with_payload=True, **params).points<br />    print(name, [(h.payload["sku"], round(h.score, 4)) for h in hits])<br />hybrid = client.query_points(<br />    collection_name=COLL,<br />    prefetch=[<br />        models.Prefetch(query=vector, using="dense", limit=20),<br />        models.Prefetch(query=lexical, using="lexical", limit=20),<br />    ],<br />    query=models.FusionQuery(fusion=models.Fusion.RRF),<br />    limit=3,<br />    with_payload=True,<br />).points<br />print("hybrid", [(h.payload["sku"], round(h.score, 4)) for h in hybrid])<br /><br />code = "KT-417"  # замените только после определения правил нормализации SKU<br />exact, _ = client.scroll(<br />    collection_name=COLL,<br />    scroll_filter=models.Filter(must=[<br />        models.FieldCondition(key="sku", match=models.MatchValue(value=code))<br />    ]),<br />    limit=10, with_payload=True,<br />)<br />print("exact", [(p.payload["sku"], p.payload["source"]) for p in exact])<br /></p><p>Пример prefetch + <code>FusionQuery(RRF)</code> следует Query API Qdrant <a href="#src-s1" rel="noopener noreferrer">s1</a><a href="#src-s2" rel="noopener noreferrer">s2</a>. <code>limit</code> каждой prefetch-ветки должен охватывать итоговый <code>limit</code> (и смещение, если оно появится), иначе часть кандидатов не дойдёт до слияния <a href="#src-s2" rel="noopener noreferrer">s2</a>. Баллы dense, sparse и RRF имеют <strong>разный смысл</strong>: здесь их печатают для диагностики внутри каждой ветки, а не сравнивают как одну шкалу <a href="#src-s2" rel="noopener noreferrer">s2</a>. Индекс <code>sku</code> и фильтр <code>MatchValue</code> соответствуют документации keyword-поиска <a href="#src-s4" rel="noopener noreferrer">s4</a>.</p><h2>Как проверить результат и не выдумать качество?</h2><p>После запуска проверьте наличие трёх строк <code>dense</code>, <code>sparse</code>, <code>hybrid</code>, непустых результатов и <code>exact</code> с <code>KT-417</code> и <code>demo:1</code>. Это <strong>ожидаемый критерий для синтетического ввода</strong>, не заявленный фактический результат Modelium. Затем составьте хотя бы две группы запросов: точные артикулы (включая похожие <code>KT-417</code>/<code>KT-418</code>, отсутствующий код и варианты регистра) и смысловые перефразировки. Для каждого запроса вручную разметьте допустимые фрагменты и сопоставьте выдачу dense-only, sparse-only, RRF и точной ветки на одинаковых данных; <a href="/zhurnal/ocenka-kachestva-rag/" rel="noopener noreferrer">методика оценки RAG</a> помогает отделить качество поиска от качества ответа модели.</p><p>В реальном RAG храните у каждого фрагмента ID исходного документа, страницу/раздел, версию и права доступа. Извлечённый текст с цитатой — ещё не готовый ответ; генератор должен получать только разрешённые найденные фрагменты, а правильность ответа проверяется отдельно. Общая схема пилота — в <a href="/zhurnal/rag-sistema-dlya-biznesa/" rel="noopener noreferrer">руководстве по RAG</a>. При изменении embedding-модели или способа токенизации переиндексируйте документы и запросы одинаковой версией: сравнение старых и новых векторов без контроля конфигурации не является тестом качества <a href="#src-s7" rel="noopener noreferrer">s7</a><a href="#src-s6" rel="noopener noreferrer">s6</a>.</p><h2>Что делать при сбое и как остановить стенд?</h2><ul><li><strong>Нет ответа от Qdrant.</strong> Проверьте <code>docker ps</code>, <code>curl http://127.0.0.1:6333/</code> и журнал контейнера; официальная quickstart использует порт 6333 и предупреждает об отсутствии auth по умолчанию <a href="#src-s5" rel="noopener noreferrer">s5</a>.</li><li><strong>Ollama возвращает ошибку или пустые embeddings.</strong> Проверьте процесс Ollama, загруженный <code>embeddinggemma</code>, версию приложения и локальный <code>/api/embed</code>; не записывайте нулевые векторы вместо неудачного ответа <a href="#src-s7" rel="noopener noreferrer">s7</a><a href="#src-s8" rel="noopener noreferrer">s8</a>.</li><li><strong>Sparse-запрос не находит русский термин.</strong> Убедитесь, что и документы, и запросы прошли один и тот же <code>Qdrant/bm25</code> с <code>language="russian"</code>, а sparse-коллекция создана с <code>modifier=idf</code>; английская обработка по умолчанию не подходит для русскоязычного теста <a href="#src-s6" rel="noopener noreferrer">s6</a><a href="#src-s3" rel="noopener noreferrer">s3</a>.</li><li><strong>Артикул не найден, хотя похожий документ появился.</strong> Проверьте точное значение <code>sku</code>, регистр, символы, payload index и нормализацию; keyword-фильтр отличается от полнотекстового поиска <a href="#src-s4" rel="noopener noreferrer">s4</a>. Похожий товар нельзя автоматически считать заменой.</li><li><strong>Результаты ранжирования слабые.</strong> Сравните четыре ветки на размеченных запросах, проверьте качество извлечения текста, chunking, метаданные и релевантность embedding-модели. Не объявляйте RRF лучше без сопоставимой оценки; документы Qdrant описывают механизм слияния, не ваши метрики <a href="#src-s2" rel="noopener noreferrer">s2</a>.</li></ul><p>Остановить тестовый Qdrant можно <code>docker stop qdrant-hybrid-demo &amp;&amp; docker rm qdrant-hybrid-demo</code>. Директорию <code>qdrant_storage</code> команда <strong>не удаляет</strong>; не очищайте её, пока не убедились, что там только тестовые данные. <code>ollama rm embeddinggemma</code> удалит локальную модель, если она больше никому не нужна. Следующий шаг — <a href="/zhurnal/ocenka-kachestva-rag/" rel="noopener noreferrer">проверить выдачу по артикулам и смысловым запросам</a> на размеченном наборе, сохранив версии, найденные источники и ошибки.</p>
`

## Источники

1. [Qdrant — Hybrid Search](https://qdrant.tech/documentation/search/text-search/hybrid-search/) — получено 2026-09-24
2. [Qdrant — Hybrid and Multi-Stage Queries](https://qdrant.tech/documentation/search/hybrid-queries/) — получено 2026-09-24
3. [Qdrant — Full-Text Search](https://qdrant.tech/documentation/search/text-search/full-text-search/) — получено 2026-09-24
4. [Qdrant — Text Filtering and Keyword Strings](https://qdrant.tech/documentation/search/text-search/text-filtering/) — получено 2026-09-24
5. [Qdrant — Local Quickstart](https://qdrant.tech/documentation/quickstart/) — получено 2026-09-24
6. [Qdrant FastEmbed — реализация BM25, закреплённый Git commit](https://raw.githubusercontent.com/qdrant/fastembed/f61e7ab4745c50ee31495312d4c30a1426725b44/fastembed/sparse/bm25.py) — получено 2026-09-24
7. [Ollama — Embeddings](https://docs.ollama.com/capabilities/embeddings) — получено 2026-09-24
8. [Ollama Library — embeddinggemma](https://ollama.com/library/embeddinggemma) — получено 2026-09-24
