Retrieval: семантический поиск, гибридный поиск, reranking и оптимизация выдачи
Хороший поиск — это не только векторы. Это комбинация подходов.
Семантический поиск — это то, что мы уже освоили: embedding вопроса, cosine similarity, top-k результатов. Он хорош для понимания смысла, но у него есть слепые зоны.
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
model = SentenceTransformer("intfloat/multilingual-e5-small")
documents = [
"Политика компании по охране труда",
"Инструкция по пожарной безопасности",
"Правила внутреннего трудового распорядка"
]
doc_embeddings = model.encode(documents)
query = "Охрана труда"
query_embedding = model.encode([query])
similarities = cosine_similarity(query_embedding, doc_embeddings)[0]
for doc, sim in zip(documents, similarities):
print(f"[{sim:.4f}] {doc}")Семантический поиск отлично находит «Политика компании по охране труда» для запроса «Охрана труда». Но что если запрос содержит точный термин, которого нет в документах?
BM25 — классический алгоритм полнотекстового поиска. В отличие от семантического, он ищет по точному совпадению слов с учётом частотности.
from rank_bm25 import BM25Okapi
import re
def tokenize(text):
"""Простая токенизация по словам."""
return re.findall(r'\w+', text.lower())
documents = [
"Политика компании по охране труда обновлена в 2026 году",
"Инструкция по пожарной безопасности действует для всех отделов",
"Правила внутреннего трудового распорядка утверждены приказом №42"
]
# Токенизируем документы
tokenized_docs = [tokenize(doc) for doc in documents]
# Создаём BM25 индекс
bm25 = BM25Okapi(tokenized_docs)
# Поиск
query = "приказ 42"
tokenized_query = tokenize(query)
scores = bm25.get_scores(tokenized_query)
for doc, score in zip(documents, scores):
print(f"[{score:.4f}] {doc}")BM25 найдёт документ с «приказом №42», даже если семантически запрос «приказ 42» не очень близок к другим документам. Keyword-поиск силён в:
Гибридный поиск объединяет семантический и keyword-подход. Каждый документ получает два скорa — от BM25 и от cosine similarity — которые объединяются.
def hybrid_search(query, documents, model, bm25, alpha=0.7, top_k=2):
"""
Гибридный поиск: комбинация семантического и BM25.
alpha: вес семантического поиска (0.7 = 70% семантика, 30% BM25)
"""
# Семантический поиск
doc_embeddings = model.encode(documents)
query_embedding = model.encode([query])
semantic_scores = cosine_similarity(query_embedding, doc_embeddings)[0]
# Нормализуем семантические скоры в диапазон [0, 1]
min_s, max_s = semantic_scores.min(), semantic_scores.max()
if max_s > min_s:
semantic_norm = [(s - min_s) / (max_s - min_s) for s in semantic_scores]
else:
semantic_norm = [0.5] * len(semantic_scores)
# BM25 поиск
tokenized_query = tokenize(query)
bm25_scores = bm25.get_scores(tokenized_query)
# Нормализуем BM25 скоры
min_b, max_b = bm25_scores.min(), bm25_scores.max()
if max_b > min_b:
bm25_norm = [(s - min_b) / (max_b - min_b) for s in bm25_scores]
else:
bm25_norm = [0.5] * len(bm25_scores)
# Комбинируем
combined = [alpha * s + (1 - alpha) * b for s, b in zip(semantic_norm, bm25_norm)]
# Возвращаем top-k
top_indices = np.argsort(combined)[-top_k:][::-1]
return [(documents[i], combined[i]) for i in top_indices]
results = hybrid_search("приказ 42", documents, model, bm25)
for doc, score in results:
print(f"[{score:.4f}] {doc}")alpha контролирует баланс: alpha=0.7 означает 70% семантики и 30% keyword. Для общих задач 0.5–0.7 — хороший выбор. Если в вашей предметной области много точных терминов (артикулы, коды, номера документов) — снижайте alpha.
Reranking — это второй этап поиска. Сначала мы находим кандидатов быстрым методом (семантический + BM25), а затем более точной моделью переранжируем.
Embedding-модель создаёт один вектор на весь чанк и сравнивает с вектором запроса. Это быстро, но теряет детализацию. Cross-Encoder (reranker) обрабатывает пару «запрос-документ» целиком и выдаёт точную оценку релевантности.
from sentence_transformers import CrossEncoder
# Загружаем reranker-модель
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
# Кандидаты с первого этапа
query = "Как оформить удалённую работу?"
candidates = [
"Политика компании по удалённой работе обновлена в январе 2026",
"Процедура оформления отпуска: заявление за 2 недели через HR-портал",
"Удалённая работа разрешена до 3 дней в неделю по согласованию с руководителем"
]
# Создаём пары запрос-документ
pairs = [(query, doc) for doc in candidates]
# Reranking
scores = reranker.predict(pairs)
for doc, score in zip(candidates, scores):
print(f"[{score:.4f}] {doc}")Reranker точнее embeddings, потому что видит полную картину взаимодействия запроса и документа. Но он медленный — поэтому применяется только к топ-10 или топ-20 кандидатам, а не ко всей базе.
def two_stage_search(query, all_documents, model, top_k_initial=20, top_k_final=5):
"""
Этап 1: быстрый семантический поиск → top_k_initial кандидатов
Этап 2: reranking кандидатов → top_k_final результатов
"""
# Этап 1: embedding поиск
doc_embeddings = model.encode(all_documents)
query_embedding = model.encode([query])
similarities = cosine_similarity(query_embedding, doc_embeddings)[0]
# Берём топ-N кандидатов
top_indices = np.argsort(similarities)[-top_k_initial:][::-1]
candidates = [all_documents[i] for i in top_indices]
# Этап 2: reranking
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
pairs = [(query, doc) for doc in candidates]
rerank_scores = reranker.predict(pairs)
# Финальный top-k
final_indices = np.argsort(rerank_scores)[-top_k_final:][::-1]
return [(candidates[i], rerank_scores[i]) for i in final_indices]
results = two_stage_search("удалённая работа", documents, model)
for doc, score in results:
print(f"[{score:.4f}] {doc}")Иногда пользователь формулирует вопрос слишком узко. Query Expansion генерирует дополнительные варианты запроса для улучшения поиска.
def expand_query(query):
"""Генерирует варианты запроса для расширения поиска."""
# Простой подход: синонимы и перефразирование
expansions = [
query,
query.replace("как", "каким образом"),
query.replace("что", "какие документы"),
f"инструкция: {query}",
f"правила: {query}"
]
return list(set(expansions)) # Убираем дубликаты
query = "как оформить отпуск"
expanded = expand_query(query)
print(f"Оригинал: {query}")
print(f"Расширенные: {expanded}")Для каждого варианта запроса мы ищем документы и объединяем результаты. Это помогает, когда пользователь не знает точной терминологии.
Более продвинутый подход — использовать LLM для перефразирования:
def llm_expand_query(query):
"""Используем LLM для генерации вариантов запроса."""
prompt = f"""Сгенерируй 3 варианта вопроса на русском языке, которые означают то же самое:
"{query}"
Ответь списком, каждый вариант с новой строки."""
# Здесь был бы вызов LLM
# Для примера — статические варианты
return [
"каким образом подать заявление на отпуск",
"процедура получения отпускных дней",
"что нужно сделать для оформления отпуска"
]k — количество документов, возвращаемых поиском. Слишком маленькое k — пропустим релевантные документы. Слишком большое k — передадим LLM лишний шум.
| Сценарий | Рекомендуемый k |
|---|---|
| Короткие чанки (100–200 слов) | 5–10 |
| Средние чанки (300–500 слов) | 3–5 |
| Длинные чанки (500+ слов) | 2–3 |
Вместо фиксированного k можно использовать порог по схожести:
def threshold_search(query, documents, model, threshold=0.6):
"""Возвращает все документы со схожестью выше порога."""
doc_embeddings = model.encode(documents)
query_embedding = model.encode([query])
similarities = cosine_similarity(query_embedding, doc_embeddings)[0]
results = [
(doc, float(sim))
for doc, sim in zip(documents, similarities)
if sim >= threshold
]
return sorted(results, key=lambda x: x[1], reverse=True)
results = threshold_search("отпуск", documents, model, threshold=0.65)
print(f"Найдено {len(results)} документов выше порога")
for doc, score in results:
print(f"[{score:.4f}] {doc}")Порог 0.6–0.7 для косинусного сходства — хорошая отправная точка. Если ни один документ не превышает порог — RAG-система может ответить «информация не найдена» вместо того чтобы генерировать ответ из нерелевантных чанков.
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.