Alexander Bond • 01.11.2026 • 8 мин чтения ragгибридный поискcolpali

RAG без галлюцинаций в High-Load B2B: гибридный поиск, ColPali для PDF-таблиц и кастомные реранкеры на собственных серверах

Почему наивный векторный RAG гарантированно деградирует на прайс-листах и ГОСТах — и как гибридный поиск с RRF, визуальные эмбеддинги ColPali и локальный BGE-reranker снижают галлюцинации по ценам с 14.8% до 0.38% при p95 = 240 мс.

Почему наивный векторный RAG гарантированно деградирует на прайс-листах и ГОСТах — и как гибридный поиск с RRF, визуальные эмбеддинги ColPali и локальный BGE-reranker снижают галлюцинации по ценам с 14.8% до 0.38% при p95 = 240 мс.

1. Наивный RAG умирает на первом прайс-листе

Классический пайплайн (чанк 512 токенов + косинус по text-embedding-3-large или bge-m3 + top-5 в промпт) уверенно проходит демо и разваливается на первом боевом запросе вида «цена на подшипник SKF 6205-2RSH при поставке от 500 шт. по договору №14/2025». Причины системные:

  • **Разрыв таблиц чанкером.** Заголовок «Цена, ₽/шт (от 500 шт.)» уезжает в соседний чанк, и LLM видит цифру без размерности и без условия.
  • **Артикулы и ГОСТы — шум для dense.** Косинус между `6205-2RS` и `6205-2RSH` равен 0.97, хотя это разные складские позиции. `ГОСТ 8338-75` и `ГОСТ 8338-83` для вектора почти неразличимы.
  • **Lost-in-the-Middle.** Релевантный чанк на позиции 7 из 10 статистически игнорируется моделью — чем длиннее контекст, тем хуже извлечение из середины.
  • **Уверенная достройка.** Модель не говорит «нет данных», она экстраполирует цену по соседним строкам таблицы.
  • В боевом контуре First Touch — платформы ИИ-рассылок в Telegram, где бот пишет по вашей базе знаний с ваших аккаунтов, отвечает на вопросы и передаёт менеджеру заинтересованных клиентов — ровно это давало 14.8% галлюцинаций по ценам и условиям поставки. Ниже — архитектура, которая заменила наивный пайплайн.

    2. Анатомия гибридного поиска: Dense + Sparse

    Разделение ответственности

    | Компонент | Что решает | Слабость |

    |---|---|---|

    | Dense (bge-m3, 1024d) | Семантика, синонимы: «подшипник» ≈ «bearing» ≈ «опора вала» | Не видит разницы в один символ артикула |

    | Sparse (BM25Okapi / SPLADE-v3) | Точные токены: артикулы, ID договоров, ГОСТы, единицы | Не обобщает перефразировки |

    В enterprise нужны оба: dense находит документ, sparse доказывает, что это тот самый артикул. Отключение любой половины стоит 17–20 п.п. Hit Rate@3.

    RRF против Weighted Sum

    Складывать скоры напрямую нельзя: BM25 не ограничен сверху и зависит от длины документа, косинус лежит в [0,1]. Min-max нормировка нестабильна на выбросах. Рабочее решение — Reciprocal Rank Fusion, устойчивый к масштабу:

    score(d) = Σ_i 1 / (k + rank_i(d)), где k = 60 (сглаживание, Cormack et al., 2009).

    Weighted Sum (α·dense + (1-α)·sparse) даёт +1–2% NDCG только после тюнинга α под конкретный домен и ломается при смене коллекции. RRF не требует обучения и работает из коробки.

    Качество на внутреннем бенчмарке (2 400 запросов, 180k чанков)

    | Стратегия | Hit Rate@3 | NDCG@5 | MRR | Recall@50 |

    |---|---|---|---|---|

    | Dense only | 71.4% | 0.62 | 0.58 | 0.81 |

    | Sparse only | 68.9% | 0.59 | 0.61 | 0.74 |

    | Hybrid + RRF | 88.2% | 0.79 | 0.76 | 0.93 |

    | Hybrid + RRF + reranker | 94.8% | 0.89 | 0.86 | 0.95 |

    3. ColPali: мультимодальный RAG для сложных PDF

    PyPDF, Tesseract и Unstructured теряют ровно то, что в B2B-документе самое ценное: многоколоночную верстку, объединённые ячейки смет, сноски с условиями, графики и чертежи. Типичный сбой: таблица 6×14 после text extraction превращается в плоский список чисел без привязки к столбцам — модель складывает цену из одной колонки с количеством из другой.

    ColPali решает это без OCR: страница режется на визуальные патчи, PaliGemma-энкодер отдаёт по эмбеддингу на патч, а сопоставление запроса и страницы идёт по late interaction (механизм ColBERT) — максимум по каждому токену запроса. Пространственные связи «строка ↔ столбец ↔ сноска» сохраняются физически, потому что документ не покидал пиксельное представление. Для PDF-смет, спецификаций и чертежей это даёт +11–14 п.п. точности извлечения табличных значений относительно OCR-пайплайна.

    4. Локальные реранкеры: Cross-Encoder обязателен

    Би-энкодер кодирует запрос и документ независимо — он физически не видит их взаимодействия, поэтому top-50 по косинусу содержит документы «про то же», но не «про этот пункт договора». Cross-Encoder читает пару (query, passage) целиком и различает нюансы вроде «цена при поставке от 500 шт.» против «цена при поставке от 50 шт.».

    Стек на собственном GPU-контуре: BGE-Reranker-v2-m3 (или Qwen2-VL-Reranker для страниц ColPali), развёрнутый через Text Embeddings Inference (TEI) или vLLM с continuous batching. Никакого внешнего API — данные клиента не покидают периметр.

    Эффект на боевом контуре: Hit Rate@3 71.4% → 94.8%, NDCG@5 0.62 → 0.89, p50 инференса реранкера по 40 кандидатам — 31 мс, p95 — 45 мс на GPU L4.

    Отдельный слой — машиночитаемая спецификация llms.txt: она описывает доменную онтологию базы знаний (разделы, сущности, правила навигации), и агент-роутер не тратит токены на слепой перебор коллекций, а сразу выбирает нужный индекс. Это же снижает долю запросов, уходящих в ColPali-индекс вместо текстового, — экономия 60% GPU-времени на визуальных патчах.

    5. Сжатие контекста и защита от Lost-in-the-Middle

  • **Score thresholding.** В промпт попадают только кандидаты с score реранкера ≥ 0.35. Если ни один не прошёл — возвращаем «в базе нет ответа», а не top-1 «на всякий случай». Это главный источник снижения галлюцинаций: модель физически лишена возможности домыслить.
  • **Динамическое окно.** 3 чанка для фактологического запроса, 8 — для сравнительного. Контекст стабилизируется: релевантное ставится в начало и конец, а не в середину.
  • **Prompt caching префиксов.** Системный промпт + онтология + шапка регламента кешируются на стороне инференса (vLLM APC / OpenAI cache), что срезает 40–55% времени prefill на повторяющихся запросах рассылки.
  • 6. Воспроизводимый пайплайн на Python

    from qdrant_client import QdrantClient
    from rank_bm25 import BM25Okapi
    from sentence_transformers import CrossEncoder
    
    def reciprocal_rank_fusion(rankings, k: int = 60) -> dict:
        scores = {}
        for ranking in rankings:
            for pos, doc_id in enumerate(ranking):
                scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + pos + 1)
        return dict(sorted(scores.items(), key=lambda kv: -kv[1]))
    
    class EnterpriseRAGPipeline:
        def __init__(self, client: QdrantClient, collection: str,
                     corpus: list[dict], encoder, reranker_path: str):
            self.client, self.collection = client, collection
            self.corpus = {d['id']: d for d in corpus}
            self.ids = [d['id'] for d in corpus]
            self.bm25 = BM25Okapi([d['text'].lower().split() for d in corpus])
            self.encoder = encoder
            self.reranker = CrossEncoder(reranker_path, max_length=512)
    
        def retrieve(self, query: str, top_k: int = 40) -> list[str]:
            dense = self.client.search(self.collection, self.encoder.encode(query), limit=top_k)
            dense_ids = [h.payload['doc_id'] for h in dense]
            sparse_ids = self.bm25.get_top_n(query.lower().split(), self.ids, n=top_k)
            fused = reciprocal_rank_fusion([dense_ids, sparse_ids])
            return list(fused)[:top_k]
    
        def build_context(self, query: str, threshold: float = 0.35, limit: int = 6):
            cand = self.retrieve(query)
            pairs = [(query, self.corpus[c]['text']) for c in cand]
            scores = self.reranker.predict(pairs)
            ranked = sorted(zip(cand, scores), key=lambda kv: -kv[1])
            return [self.corpus[c]['text'] for c, s in ranked if s >= threshold][:limit]

    build_context возвращает пустой список, если уверенности нет — и это корректный ответ, а не сбой.

    7. Продакшен-кейс First Touch: метрики боевого контура

    Инфраструктура First Touch после миграции:

  • База знаний: **280 000 страниц** технической документации, прайс-листов и регламентов B2B-клиентов; текстовый индекс Qdrant + BM25 (in-memory, шардированный) + отдельная ColPali-коллекция на 41k страниц.
  • Галлюцинации по ценам и условиям поставки: **14.8% → 0.38%**.
  • Точность извлечения сложных табличных данных: **98.6%**.
  • Задержка p95: **240 мс** полного цикла, включая реранкинг топ-40 кандидатов на локальной L4.
  • Стоимость: 0 ₽ за embedding-запросы — всё на собственном железе.
  • 8. Чеклист и CTA

    Четыре правила production-ready RAG в 2026 году:

    1. Никогда не полагайтесь на один тип поиска — dense без sparse не различает артикулы.

    2. Слияние через RRF, а не через сырые скоры: BM25 и косинус несопоставимы.

    3. Реранкер обязателен, и он должен стоять в вашем контуре: cross-encoder на API — это утечка и задержка.

    4. Отсутствие ответа — валидный результат. Порог по score важнее, чем top-k.

    Если ваш бот уже врёт по ценам — начните с аудита базы знаний и развёртывания RAG-контура без галлюцинаций: First Touch собирает гибридный индекс, поднимает локальный реранкер и подключает ИИ-рассылку в Telegram, которая передаёт менеджеру только заинтересованных клиентов.


    🚀 Проект автора: First Touch (https://aifirsttouch.ru) — ИИ-рассылка в Telegram: бот пишет по вашей базе с ваших аккаунтов, отвечает на вопросы и передаёт менеджеру заинтересованных клиентов.

    Автор: Alexander Bond — AI-разработчик и архитектор автономных систем


    Теги: rag, гибридный поиск, colpali, реранкеры, векторные базы, llm, first touch

    Автоматизируйте привлечение B2B-клиентов

    Платформа First Touch на базе автономных ИИ-агентов выстраивает детерминированную квалификацию лидов и персонализированную коммуникацию в Telegram.

    Попробовать First Touch