Разработка RAG с Elasticsearch kNN: гибридный поиск BM25 и векторы
Представьте: ваш Elasticsearch обрабатывает сотни тысяч документов, но пользователи жалуются, что поиск не находит релевантные ответы. BM25 отлично работает с точными совпадениями, но пасует перед синонимами, тавтологией и сложными запросами на русском языке. Результаты: клиенты уходят, операторы тратят время на поиск. Добавлять отдельную векторную базу? Это рост стоимости инфраструктуры, сетевые задержки и ещё одна система для поддержки. Оптимальное решение — использовать встроенный kNN в Elasticsearch 8.x, объединив полнотекстовый и векторный поиск в одном индексе. Мы помогли нескольким командам внедрить такой гибрид без смены инфраструктуры, и сейчас расскажем, как это сделать. Свяжитесь с нами для аудита вашего текущего поиска — мы предложим оптимальный маршрут.
Почему Elasticsearch kNN — оптимальное решение для гибридного поиска?
Типичные боли при внедрении RAG без перестройки: разрозненный поиск (BM25 пропускает семантику), инфраструктурный хаос (поднимать Pinecone или Weaviate параллельно с ES — увеличивать стоимость и сложность), латентность (внешние векторные БД добавляют сетевые задержки p99 до 50+ мс). Elasticsearch kNN решает все три: hybrid search (kNN + BM25) через RRF fusion в одном запросе, минимальный оверхед, не нужны новые серверы.
Elasticsearch — зрелая технология с 15+ лет на рынке, используется в тысячах продакшенов. Встроенная поддержка русского анализатора Snowball даёт качественный стемминг: запрос «договором» найдёт «договор», «договоры», «договорам». Это критично для BM25-части гибрида. Кроме того, ELK-стек (Logstash, Kibana) позволяет мониторить индексы и визуализировать метрики поиска без дополнительных инструментов.
Какие настройки HNSW дают наилучший баланс скорости и качества?
Для продакшена мы рекомендуем HNSW с параметрами m=16, ef_construction=100. Это оптимальный баланс между скоростью индексации и точностью поиска. Слишком маленькое num_candidates (менее 100) снижает recall, слишком большое — увеличивает latency. В наших проектах используем cosine similarity как метрику расстояния для эмбеддингов.
Как мы это делаем: стек, конфиги, кейс
Стек: Elasticsearch 8.11+, OpenAI text-embedding-3-small (1536-dim), Python 3.11, официальный клиент elasticsearch-py.
Кейс из нашей практики: миграция существующего Elasticsearch на RAG
Контекст: наш клиент — компания с 500K юридических документов в Elasticsearch 8.x. Задача: добавить RAG-поверхность без смены инфраструктуры.
Шаги:
- Добавление поля embedding (dense_vector, dims=1536) к существующему маппингу.
- Батчевая векторизация существующих документов (2 дня, 500K × $0.02/1M = $10).
- Reindexing с новым полем (6 часов).
- Добавление RRF fusion в поисковые запросы.
- RAG-слой поверх ES retrieval.
Результаты (vs чистый BM25):
- NDCG@5: 0.64 → 0.81
- Recall@10: 0.71 → 0.88
- Latency P95: 85мс → 140мс (hybrid)
- Faithfulness (RAGAS): 0.76 → 0.91
Экономия на инфраструктуре: не нужно поднимать отдельный сервер за $200/мес. Переход от pure BM25 к hybrid kNN+BM25 дал +27% к NDCG без смены инфраструктуры. Клиент получил работающий RAG за 2 недели.
Создание индекса и индексация документов
Добавляем поле dense_vector в существующий индекс и выполняем батчевую векторизацию.
from elasticsearch import Elasticsearch
es = Elasticsearch("http://localhost:9200")
# Создание индекса с маппингом
index_config = {
"mappings": {
"properties": {
"content": {
"type": "text",
"analyzer": "russian", # Нативная поддержка русской морфологии
},
"source": {"type": "keyword"},
"doc_type": {"type": "keyword"},
"page": {"type": "integer"},
"date": {"type": "date"},
"embedding": {
"type": "dense_vector",
"dims": 1536,
"index": True,
"similarity": "cosine",
"index_options": {
"type": "hnsw",
"m": 16,
"ef_construction": 100,
}
}
}
},
"settings": {
"number_of_shards": 1,
"number_of_replicas": 1,
}
}
es.indices.create(index="knowledge_base", body=index_config)
from openai import OpenAI
from elasticsearch.helpers import bulk
openai_client = OpenAI()
def generate_actions(chunks: list):
texts = [c["text"] for c in chunks]
response = openai_client.embeddings.create(
model="text-embedding-3-small",
input=texts
)
embeddings = [e.embedding for e in response.data]
for chunk, embedding in zip(chunks, embeddings):
yield {
"_index": "knowledge_base",
"_source": {
"content": chunk["text"],
"source": chunk["source"],
"doc_type": chunk["doc_type"],
"page": chunk.get("page", 0),
"embedding": embedding,
}
}
bulk(es, generate_actions(document_chunks))
Hybrid Search: BM25 + kNN на практике
Elasticsearch поддерживает гибридный поиск через knn + query в одном запросе с RRF fusion.
def hybrid_search_es(
query: str,
doc_type_filter: str = None,
top_k: int = 5
) -> list:
query_embedding = openai_client.embeddings.create(
model="text-embedding-3-small",
input=query
).data[0].embedding
filter_clause = []
if doc_type_filter:
filter_clause.append({"term": {"doc_type": doc_type_filter}})
body = {
"query": {
"bool": {
"must": {
"match": {
"content": {
"query": query,
"analyzer": "russian"
}
}
},
"filter": filter_clause,
}
},
"knn": {
"field": "embedding",
"query_vector": query_embedding,
"k": top_k * 3,
"num_candidates": 100,
"filter": filter_clause,
},
"rank": {
"rrf": {
"window_size": 50,
"rank_constant": 20,
}
},
"size": top_k,
"_source": ["content", "source", "doc_type"],
}
response = es.search(index="knowledge_base", body=body)
return [
{
"text": hit["_source"]["content"],
"source": hit["_source"]["source"],
"score": hit["_score"],
}
for hit in response["hits"]["hits"]
]
Преимущество русской морфологии из коробки
Elasticsearch с analyzer russian поддерживает стемминг русских слов через Snowball. Это критично для BM25 части гибридного поиска — запрос «договором» найдёт документы с «договор», «договоры», «договорам».
es.indices.analyze(
index="knowledge_base",
body={"analyzer": "russian", "text": "договором аренды"}
)
# tokens: ["договор", "аренд"] — стеммированные формы
Что входит в работу
- Аудит текущего индекса ES (маппинг, шарды, производительность)
- Проектирование схемы dense_vector и выбор embedding-модели
- Написание скриптов батчевой векторизации и реиндексации
- Реализация hybrid search с RRF fusion
- Интеграция RAG-пайплайна (с LangChain или прямыми вызовами OpenAI)
- Тестирование: NDCG, Recall, latency, faithfulness
- Документация и обучение команды (2 часа воркшопа)
Сроки ориентировочно
| Этап | Длительность |
|---|---|
| Анализ и проектирование | 2–3 дня |
| Векторизация и реиндексирование | 2–5 дней |
| Разработка гибридных запросов | 3–5 дней |
| RAG-пайплайн и оценка | 1–2 недели |
| Итого | 2–4 недели |
Сравнение Elasticsearch kNN с альтернативными векторными базами
| Характеристика | Elasticsearch kNN | Pinecone / Qdrant |
|---|---|---|
| Инфраструктура | Уже есть? Не нужно новой | Отдельный сервис |
| Гибридный поиск | Встроенный BM25 + kNN | Через отдельный BM25 + конкатенация |
| Russian stemmer | Да (Snowball) | Нет (нужен внешний) |
| Latency p99 | 140 мс (гибрид) | 50-100 мс (только вектор) |
| NDCG@5 (наш опыт) | 0.81 vs 0.64 (pure BM25) | ~0.75-0.80 (аналогично) |
Elasticsearch выигрывает по комплексной стоимости и простоте, если ES уже в продакшене. Для стартапов без легаси — Pinecone может быть быстрее в запуске.
Типичные ошибки при внедрении ES kNN
- Использовать слишком маленькое num_candidates (меньше 100) — падает recall.
- Не настраивать analyzer для русских текстов — BM25 бесполезен.
- Пытаться скормить embedding размерности 768 в поле с dims=1536 — ES вернёт ошибку.
- Забыть про rrf — без fusion гибрид не работает как ожидается.
Получите консультацию по вашему проекту — мы уже реализовали 10+ подобных проектов. Свяжитесь с нами, чтобы обсудить детали и получить индивидуальную оценку.







