RAG поза основами: reranking, hybrid search та pgvector у продакшні

Article

RAG поза основами: reranking, hybrid search та pgvector у продакшні

Кожен туторіал з RAG онлайн виглядає однаково: зроби ембединги документів, збережи вектори, витягни за косинусною схожістю, передай до LLM, готово. Добре працює в демо. У продакшні з реальними користувачами та реальними даними розсипається способами, що фруструють при налагодженні і незручно пояснювати клієнтам.

Ось що ми навчилися, будуючи RAG-системи, що реально витримують виробниче навантаження. Без іграшкових прикладів.

Чому наївний RAG зазнає невдачі

Фундаментальне припущення наївного RAG: косинусна схожість між ембедингами запиту та документа є надійним проксі для релевантності. Так не є. Не надійно.

Три типові режими відмови:

Семантичний дрейф при зміні домену. Загальні ембединги (наприклад, OpenAI text-embedding-3-small) кодують семантичні відносини на основі загального веб-тексту. Якщо ваші документи є висококомпетентними доменно-специфічними — юридичні договори, медична документація, фінансові звіти — представлення доменно-специфічних термінів моделлю ембедингу може не точно відображати їх відносини релевантності у вашому контексті.

Упередженість популярності в просторі ембедингів. Поширені терміни кластеризуються разом у просторі ембедингів. Запити з використанням поширеного словника витягнуть документи, що випадково містять ці поширені терміни, а не обов'язково найбільш релевантні.

Обрізання вибірки top-K. Ви витягуєте top 5 або 10 документів. Фактично найбільш релевантний документ може бути на позиції 8 у семантичному пошуку, але на позиції 1 якщо ви також враховували б зіставлення ключових слів. При наївному RAG ви його ніколи не знайдете.

Reranking: відсутній шар

Reranking — це єдине покращення з найбільшим впливом, яке ви можете зробити в RAG-пайплайні. Ідея: витягніть більший набір кандидатів (top 20-50) використовуючи швидкий наближений векторний пошук, потім застосуйте точнішу але повільнішу cross-encoder модель для їх переупорядкування і візьміть фактичний top K.

Чому це працює? Bi-encoder моделі (що використовуються для початкового пошуку) кодують запит і документ незалежно та порівнюють їх представлення. Cross-encoder моделі (що використовуються для rerankingu) бачать запит і документ разом, що дає їм набагато більше контексту для оцінки релевантності.

API rerankingu Cohere робить це простим:

import cohere

co = cohere.Client(api_key=COHERE_API_KEY)

def rerank_results(query: str, documents: list[str], top_n: int = 5) -> list[dict]:
    response = co.rerank(
        model="rerank-multilingual-v3.0",
        query=query,
        documents=documents,
        top_n=top_n,
        return_documents=True,
    )
    return [
        {
            "document": result.document.text,
            "relevance_score": result.relevance_score,
            "index": result.index,
        }
        for result in response.results
    ]

# Використання в пайплайні
initial_results = vector_store.search(query, limit=30)  # Витягти 30
docs = [r.content for r in initial_results]
reranked = rerank_results(query, docs, top_n=5)  # Залишити top 5

У наших виробничих системах ми послідовно спостерігаємо 15-30% покращення якості відповідей після додавання rerankingu, виміряне задоволеністю користувачів та фактичною точністю. Вартість затримки реальна (зазвичай додаткові 200-500мс), але прийнятна для більшості бізнес-застосунків.

Використовуйте rerank-multilingual-v3.0 для всього, що стосується європейських мов. Він обробляє польську, німецьку, українську, французьку та інші без деградації якості, що спостерігається в моделях лише для англійської.

Hybrid search поєднує семантичний векторний пошук з традиційним пошуком ключових слів (BM25/full-text). Інтуїція: іноді важлива не семантична схожість, а точне зіставлення термінів. Назви моделей, коди продуктів, регуляторні посилання, власні назви — вони потребують зіставлення ключових слів, щоб бути надійно знайденими.

Базовий підхід: запустіть обидва пошуки, злийте та дедублікуйте результати, застосуйте функцію злиття.

def hybrid_search(
    query: str,
    query_embedding: list[float],
    semantic_weight: float = 0.7,
    keyword_weight: float = 0.3,
    limit: int = 20,
) -> list[dict]:
    conn = get_connection()

    # Reciprocal Rank Fusion
    sql = """
    WITH semantic AS (
        SELECT
            id,
            content,
            1 - (embedding <=> %s::vector) AS semantic_score,
            ROW_NUMBER() OVER (ORDER BY embedding <=> %s::vector) AS sem_rank
        FROM documents
        ORDER BY embedding <=> %s::vector
        LIMIT %s
    ),
    keyword AS (
        SELECT
            id,
            content,
            ts_rank_cd(to_tsvector('ukrainian', content), query) AS keyword_score,
            ROW_NUMBER() OVER (
                ORDER BY ts_rank_cd(to_tsvector('ukrainian', content), query) DESC
            ) AS kw_rank
        FROM documents,
             plainto_tsquery('ukrainian', %s) query
        WHERE to_tsvector('ukrainian', content) @@ query
        LIMIT %s
    ),
    fused AS (
        SELECT
            COALESCE(s.id, k.id) AS id,
            COALESCE(s.content, k.content) AS content,
            COALESCE(1.0 / (60 + s.sem_rank), 0) * %s +
            COALESCE(1.0 / (60 + k.kw_rank), 0) * %s AS score
        FROM semantic s
        FULL OUTER JOIN keyword k ON s.id = k.id
    )
    SELECT id, content, score
    FROM fused
    ORDER BY score DESC
    LIMIT %s;
    """

    cursor = conn.cursor()
    cursor.execute(sql, (...))
    return [{"id": r[0], "content": r[1], "score": r[2]} for r in cursor.fetchall()]

60 у формулі RRF — константа, що контролює чутливість до рангів. Вищі значення роблять злиття менш чутливим до відмінностей рангів.

pgvector vs Qdrant: чесний компроміс

Обидва є правомірними виборами. Рішення не є технічним догматизмом — йдеться про існуючу інфраструктуру та толерантність до операційної складності.

pgvector має сенс коли: - Ви вже на PostgreSQL і хочете мінімізувати операційну складність - Ваш датасет зручно вміщується в одному екземплярі бази даних (сотні мільйонів векторів або менше) - Вам потрібні ACID-транзакції, що поєднують векторний пошук з реляційними даними - Ваша команда знає SQL і не хоче вивчати ще одну мову запитів

Qdrant має сенс коли: - Ви працюєте у дуже великому масштабі (мільярди векторів) - Вам потрібні named vectors та multi-vector search зразу - Ви хочете виділену продуктивність векторної БД без налаштування PostgreSQL - Вам потрібна вбудована фільтрація payload більш виразна ніж WHERE-клаузи SQL

Для B2B-проектів, якими ми зазвичай займаємося — CRM, бази знань, Q&A документів — pgvector комфортно обробляє масштаб і різко зменшує інфраструктурну складність.

Поради оптимізації pgvector, що мають значення:

-- Використовуйте індекс HNSW для продакшну (краща продуктивність запитів ніж IVFFlat)
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

-- Встановіть ef_search під час запиту на основі компромісу точність/швидкість
SET hnsw.ef_search = 100;  -- Вище = точніше, повільніше

-- Часткові індекси для відфільтрованого пошуку
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops)
WHERE active = true AND language = 'uk';

-- Перевірте використання індексу
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, content, 1 - (embedding <=> '[...]'::vector) AS score
FROM documents
ORDER BY embedding <=> '[...]'::vector
LIMIT 10;

Чанкінг: семантичний замість фіксованого розміру

Чанкінг фіксованого розміру (розділити кожні N токенів) простий і послідовно посередній. Проблема: він розділяє на довільних межах, що часто руйнують семантичну цілісність. Абзац про регуляторну вимогу розбивається на три чанки, кожен з яких погано витягується, бо йому бракує контексту.

Семантичний чанкінг групує текст за концептуальними межами: розриви абзаців, заголовки розділів, зміни теми. Дорожчий у обчисленні, значно кращі результати пошуку.

def semantic_chunk(text: str, max_chunk_size: int = 512) -> list[str]:
    """
    Розділити текст за межами абзаців, об'єднуючи короткі абзаци
    і розділяючи довгі на межах речень.
    """
    paragraphs = [p.strip() for p in text.split('\n\n') if p.strip()]
    chunks = []
    current_chunk = []
    current_size = 0

    for para in paragraphs:
        para_size = len(para.split())

        if current_size + para_size > max_chunk_size and current_chunk:
            chunks.append(' '.join(current_chunk))
            current_chunk = [para]
            current_size = para_size
        else:
            current_chunk.append(para)
            current_size += para_size

    if current_chunk:
        chunks.append(' '.join(current_chunk))

    return chunks

Завжди зберігайте метадані чанку: ID вихідного документа, позицію чанку, заголовок розділу якщо доступний, номер сторінки для PDF.

Вибір моделі ембедингу для багатомовності

Якщо ви будуєте для європейських ринків, моделі ембедингів лише для англійської — реальна проблема якості. text-embedding-3-small тренований переважно на англійському тексті.

Cohere embed-multilingual-v3.0 — те, що ми використовуємо як стандарт для будь-якої системи, що обробляє кілька європейських мов. Він явно підтримує 100+ мов з відповідною якістю на польській, німецькій, українській, французькій та інших. Різниця якості для запитів не-англійською є достатньо значущою, щоб мати значення у продакшні.

Логуйте свої показники пошуку. Серйозно. Якщо ви не зберігаєте відстані ембедингів та оцінки ранжувальника для кожного запиту, у вас немає сигналу для налагодження якості пошуку. Ми логуємо все це до PostgreSQL і щотижня аналізуємо запити з низькими оцінками впевненості.

Складання разом

Виробничий RAG-пайплайн виглядає так:

  1. Інгестія: семантичний чанкінг → ембединг з Cohere multilingual → зберегти в pgvector з метаданими
  2. Пошук: hybrid search (семантичний + ключові слова) → набір кандидатів 20-30
  3. Reranking: Cohere rerank → top 5 результатів
  4. Генерація: LLM з витягнутим контекстом → відповідь

Кожен крок має спостережуваність (затримка, оцінки, ID чанків). Кожен крок налаштовується без торкання інших.

Різниця між наївним RAG і цим пайплайном не є маргінальною. Для реального Q&A документів з доменно-специфічним вмістом різниця у точності, яку повідомляють користувачі, часто є різницею між системою, якій користувачі довіряють, і тією, яку вони ігнорують після тижня.


Ми будуємо виробничі RAG-системи для B2B-клієнтів. Якщо ви оцінюєте архітектуру RAG для свого випадку використання, поговоріть з нами.

Comments

No comments yet. Be the first to comment.

Leave a comment