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: найкраще з обох світів
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-пайплайн виглядає так:
- Інгестія: семантичний чанкінг → ембединг з Cohere multilingual → зберегти в pgvector з метаданими
- Пошук: hybrid search (семантичний + ключові слова) → набір кандидатів 20-30
- Reranking: Cohere rerank → top 5 результатів
- Генерація: LLM з витягнутим контекстом → відповідь
Кожен крок має спостережуваність (затримка, оцінки, ID чанків). Кожен крок налаштовується без торкання інших.
Різниця між наївним RAG і цим пайплайном не є маргінальною. Для реального Q&A документів з доменно-специфічним вмістом різниця у точності, яку повідомляють користувачі, часто є різницею між системою, якій користувачі довіряють, і тією, яку вони ігнорують після тижня.
Ми будуємо виробничі RAG-системи для B2B-клієнтів. Якщо ви оцінюєте архітектуру RAG для свого випадку використання, поговоріть з нами.
No comments yet. Be the first to comment.