Что определяет качество embedding-модели

Не существует «лучшей» модели в абсолюте — есть модель, лучшая для вашей задачи. Качество определяется несколькими параметрами, которые часто противоречат друг другу.

Параметр              Влияние на RAG
─────────────────────────────────────────────────────────────────
Обучающие данные      Модель знает только то, на чём обучалась.
                      Обученная на вики и новостях плохо справляется
                      с юридическими текстами или кодом.

Контекстное окно      Сколько токенов модель видит за раз.
                      512 токенов (≈400 слов) → длинный абзац обрезается.
                      8192 токенов → можно эмбеддировать целую секцию.

Размерность           Больше измерений = точнее, но больше памяти.
                      384d ← быстро, дёшево; 3072d ← медленнее, точнее.

Языковое покрытие     Монолингвальные EN-модели плохо работают на RU.
                      Multilingual-модели обычно чуть слабее EN-only.

Лицензия и деплой     Нужна ли конфиденциальность? → только local-модели.
                      Нет GPU? → API или small-local (CPU-friendly).

Цена                  API: платите за каждый токен при индексировании.
                      Local: платите однажды железом/временем загрузки.
        

Теория: как обучают embedding-модели

Простое языковое моделирование (предсказание следующего токена, как в GPT) не даёт хороших embeddings для поиска — модель не обучена явно сближать похожие тексты. Для embedding-моделей используют contrastive learning.

Идея: подаём модели тройки (anchor, positive, negative):

  • Anchor — базовый текст (например, поисковый запрос)
  • Positive — семантически похожий текст (релевантный документ)
  • Negative — непохожий текст (нерелевантный документ)

Функция потерь штрафует модель, если вектор anchor ближе к negative, чем к positive. Модель учится раздвигать несвязанные тексты и сближать связанные.

Multiple Negative Ranking Loss (MNRL) — стандарт для sentence embeddings:

L = -log[ exp(sim(a, p)) / (exp(sim(a, p)) + Σ exp(sim(a, n_i))) ]

Где n_i — все остальные тексты в батче используются как negatives.
Батч из 256 текстов → 255 negatives для каждого anchor.
Это делает обучение эффективным: один forward pass даёт тысячи пар.

Hard Negatives — тексты, похожие по форме, но разные по смыслу:
  anchor:   «Как установить FastAPI?»
  positive: «pip install fastapi uvicorn»
  hard neg: «Как установить Django?»  ← похожий синтаксис, другой фреймворк

Без hard negatives модель учится различать очевидно разные тексты.
С hard negatives — учится на сложных случаях → лучше для RAG.
        

Карта моделей: качество и стоимость

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
MTEB RETRIEVAL SCORE (выше — лучше) E5-Mistral-7B-Instruct Microsoft · GPU needed 66.6 GPU text-embedding-3-large OpenAI · API 64.6 API BGE-M3 BAAI · Local · Multilingual 64.0 LOCAL nomic-embed-text-v1.5 Nomic AI · Local / API 62.4 LOCAL text-embedding-3-small OpenAI · API 62.3 API multilingual-e5-large Microsoft · Local · 50 langs 60.8 LOCAL multilingual-e5-small Microsoft · Local · CPU-friendly 58.7 LOCAL 50 59 68 ЦЕНА vs КАЧЕСТВО 67 65 63 61 59 57 бесплатно $0.02/1M $0.13/1M Стоимость индексирования → MTEB score → ✓ Лучший выбор (дорого) E5-Mistral-7B (нужен GPU) BGE-M3 multilingual nomic-embed-v1.5 8k ctx multilingual-e5-large multilingual-e5-small (CPU) text-embedding-3-small $0.02/1M tokens text-embedding-3-large $0.13/1M tokens sweet spot (локально)

MTEB: как читать бенчмарк

MTEB (Massive Text Embedding Benchmark) — стандарт оценки embedding-моделей. Включает 56 датасетов на 112 языках, разбитых на 8 типов задач. Для RAG важнее всего раздел Retrieval.

Ловушка MTEB: основная таблица на сайте — это английский бенчмарк. Для русского языка смотрите отдельно: MIRACL (multilingual retrieval) и ru-MTEB. Там расстановка сил другая — BGE-M3 и multilingual-E5 обходят OpenAI на русских текстах.
E5-Mistral-7B-InstructMicrosoft · GPU required
66.6
GPU
text-embedding-3-largeOpenAI · $0.13/1M tok
64.6
API
BGE-M3BAAI · Local · 100+ langs
64.0
LOCAL
nomic-embed-text-v1.5Nomic AI · Local · Apache 2.0
62.4
LOCAL
text-embedding-3-smallOpenAI · $0.02/1M tok
62.3
API
multilingual-e5-largeMicrosoft · Local · 50 langs
60.8
LOCAL
multilingual-e5-smallMicrosoft · Local · CPU-friendly
58.7
LOCAL

На русском языке (MIRACL benchmark) расстановка меняется: BGE-M3 выходит в лидеры (61.4), опережая text-embedding-3-small (53.8) и multilingual-e5-large (56.2). Если ваш корпус на русском — ориентируйтесь на multilingual-бенчмарки, а не на общий MTEB.

Matryoshka Representation Learning (MRL)

Обычно уменьшить размерность вектора без потери качества нельзя — нужно переобучать модель. MRL (предложена Google, 2022) решает это: модель обучается так, чтобы первые N измерений уже несли максимум смысла, а последующие добавляют уточнения.

Это позволяет взять вектор 1 536d и усечь его до 256d или 64d без переобучения — с предсказуемой, небольшой потерей качества. text-embedding-3 и nomic-embed-text-v1.5 поддерживают MRL.

3 072d — text-embedding-3-large, полное качество
1 536d — text-embedding-3-small / large default
512d — хорошо для большинства задач
64d — быстро, компактно
MTEB: 57.9 (−4.4) RAM: 250 КБ / 1k docs
MTEB: 61.6 (−0.7) RAM: 2 МБ / 1k docs
MTEB: 62.3 (baseline) RAM: 6 МБ / 1k docs
MTEB: 64.6 (+2.3 vs small) RAM: 12 МБ / 1k docs
При 100k документов: 1536d → ~600 МБ, 256d → ~100 МБ. Для большинства RAG задач 512d–1536d — оптимальный диапазон.

Детальный разбор моделей

text-embedding-3-small / large
OpenAI · 2024 · Encoder на основе трансформера + MRL
API MRL
Размерность
1 536 / 3 072
Context
8 191 токенов
Цена
$0.02 / $0.13
MTEB (EN)
62.3 / 64.6
✓ Плюсы
Параметр dimensions — MRL без переобучения
Лучший баланс цена/качество среди API-моделей
Не нужны префиксы — сразу работает
Стабильное API, SLA, поддержка 24/7
Хорошо работает на RU+EN смешанных текстах
✗ Минусы
Данные уходят на серверы OpenAI (compliance)
При большом корпусе стоимость растёт линейно
На чистом русском уступает multilingual-моделям
Нет офлайн-варианта
nomic-embed-text-v1.5
Nomic AI · 2024 · Apache 2.0 · Полностью открытая модель
Local MRL
Размерность
768 (MRL до 64)
Context
8 192 токена
Цена
Бесплатно
MTEB (EN)
62.4
✓ Плюсы
Качество сравнимо с text-embedding-3-small — бесплатно
Длинный контекст 8192 — подходит для целых документов
Открытые обучающие данные, аудируемая модель
MRL: можно уменьшить dim до 64 без переобучения
Apache 2.0 — коммерческое использование без ограничений
✗ Минусы
Требует task-префиксы (иначе −5–10% к качеству)
Нужен trust_remote_code=True в sentence-transformers
Многоязычность слабее, чем у BGE-M3 и multilingual-E5
768d — меньше, чем у конкурентов (компромисс)
BGE-M3
BAAI · 2024 · Multi-Lingual, Multi-Functional, Multi-Granularity
Local 100+ langs
Размерность
1 024
Context
8 192 токена
Цена
Бесплатно
MIRACL RU
61.4
✓ Плюсы
Dense + Sparse + ColBERT в одном вызове — hybrid search без BM25
Лучшее качество на русском среди open-source моделей
100+ языков, сильное межъязыковое выравнивание
8192 контекст — подходит для длинных документов
Нет обязательных префиксов для базового использования
✗ Минусы
1.2+ ГБ веса — тяжелее для RAM
Требует FlagEmbedding библиотеку для полного функционала
Sparse-выход — дополнительный тип хранения в векторной БД
На CPU медленнее: ~5–15 сек на батч 32 текста
multilingual-e5-small / base / large
Microsoft · 2023 · Instruct-tuned, 50 languages, E5 training
Local 50 langs
Размерность
384 / 768 / 1 024
Context
512 токенов
Цена
Бесплатно
MIRACL RU
56.2 (large)
✓ Плюсы
small-вариант: CPU-friendly, 120 МБ, быстро
Лучший выбор при ограниченных ресурсах
Хорошо изучена — много примеров в интернете
Стандартные sentence-transformers без доп. зависимостей
✗ Минусы
512 токенов — длинные чанки обрезаются
Префиксы "query: " и "passage: " обязательны
На RU уступает BGE-M3 на сложных задачах
small: заметно хуже large при сложных запросах

Task-префиксы: зачем они нужны

E5 и nomic-embed обучались с task-инструкциями: модели видели, для какой задачи создаётся embedding, и формировали разные представления под разные контексты использования. Без префикса модель работает в «общем» режиме — хуже, чем со специализированным.

Модель
Запрос (query)
Документ (passage)
multilingual-e5-*
"query: {текст}"
"passage: {текст}"
nomic-embed-text-v1.5
"search_query: {текст}"
"search_document: {текст}"
E5-Mistral-7B
"Instruct: {task_desc}\nQuery: {текст}"
(без префикса)
BGE-M3
— не нужны —
— не нужны —
text-embedding-3-*
— не нужны —
— не нужны —
Дополнительные режимы nomic-embed: помимо search_query/document, поддерживает classification: {текст} и clustering: {текст} — один и тот же чекпоинт работает оптимально для разных задач.

Код: OpenAI с MRL

import numpy as np
from openai import AsyncOpenAI

client = AsyncOpenAI()


async def embed_openai(
    texts: list[str],
    model: str = "text-embedding-3-small",
    dimensions: int | None = None,
) -> np.ndarray:
    """
    Эмбеддинг через OpenAI API.

    Параметр dimensions (MRL) — сокращает вектор без переобучения:
      dimensions=1536  → полное качество (default для small)
      dimensions=512   → MTEB −0.7, память −3x
      dimensions=256   → MTEB −1.2, память −6x
      dimensions=64    → MTEB −4.4, память −24x  ← только для прототипов

    Модели:
      "text-embedding-3-small"  — $0.02/1M, dim=1536, MTEB=62.3
      "text-embedding-3-large"  — $0.13/1M, dim=3072, MTEB=64.6
    """
    kwargs: dict = {"input": texts, "model": model}
    if dimensions is not None:
        kwargs["dimensions"] = dimensions

    response = await client.embeddings.create(**kwargs)
    # API возвращает в порядке index, сортируем на всякий случай
    items = sorted(response.data, key=lambda x: x.index)
    matrix = np.array([item.embedding for item in items], dtype=np.float32)
    # Нормализация (API уже нормализует, но явная гарантия)
    norms = np.linalg.norm(matrix, axis=1, keepdims=True)
    return matrix / np.where(norms == 0, 1, norms)


# Сравнение storage при MRL
import asyncio

async def demo_mrl():
    texts = ["Как настроить FastAPI с PostgreSQL?"] * 10  # 10 одинаковых

    v_full = await embed_openai(texts, dimensions=1536)
    v_half = await embed_openai(texts, dimensions=512)
    v_small = await embed_openai(texts, dimensions=64)

    print(f"Full  (1536d): {v_full.nbytes / 1024:.1f} КБ — shape {v_full.shape}")
    print(f"Half  ( 512d): {v_half.nbytes / 1024:.1f} КБ — shape {v_half.shape}")
    print(f"Small (  64d): {v_small.nbytes / 1024:.1f} КБ — shape {v_small.shape}")

    # Проверяем: насколько full и half схожи на одном запросе?
    sim = float(np.dot(v_full[0], v_full[0]))  # всегда 1.0 (нормализован)
    # cross-dim similarity нельзя считать — разные пространства!
    # MRL гарантирует, что в рамках одной dim всё работает корректно

asyncio.run(demo_mrl())

Код: nomic-embed-text-v1.5

"""
pip install sentence-transformers torch
Размер модели: ~550 МБ
"""

import numpy as np
from sentence_transformers import SentenceTransformer

# trust_remote_code=True обязателен — nomic использует кастомный код модели
_model_nomic: SentenceTransformer | None = None

def get_nomic_model() -> SentenceTransformer:
    global _model_nomic
    if _model_nomic is None:
        _model_nomic = SentenceTransformer(
            "nomic-ai/nomic-embed-text-v1.5",
            trust_remote_code=True,
        )
    return _model_nomic


def embed_nomic(
    texts: list[str],
    task: str = "search_document",
    dimensions: int | None = None,
) -> np.ndarray:
    """
    task варианты:
      "search_document"  — для индексируемых чанков
      "search_query"     — для пользовательских запросов
      "clustering"       — для кластеризации документов
      "classification"   — для задач классификации

    dimensions: MRL-усечение (768 → 512 → 256 → 128 → 64)
    """
    model = get_nomic_model()
    prefixed = [f"{task}: {t}" for t in texts]

    vectors = model.encode(
        prefixed,
        normalize_embeddings=True,
        batch_size=32,
        show_progress_bar=len(texts) > 100,
    )

    if dimensions is not None and dimensions < vectors.shape[1]:
        vectors = vectors[:, :dimensions]
        # После усечения нужна повторная нормализация
        norms = np.linalg.norm(vectors, axis=1, keepdims=True)
        vectors = vectors / np.where(norms == 0, 1, norms)

    return vectors.astype(np.float32)


def embed_nomic_query(query: str) -> np.ndarray:
    return embed_nomic([query], task="search_query")[0]

def embed_nomic_docs(docs: list[str], **kwargs) -> np.ndarray:
    return embed_nomic(docs, task="search_document", **kwargs)


# Пример
docs = [
    "FastAPI — высокопроизводительный веб-фреймворк",
    "Установка: pip install fastapi uvicorn",
    "История Второй мировой войны",
]

doc_vecs = embed_nomic_docs(docs)
query_vec = embed_nomic_query("как установить fastapi")

scores = doc_vecs @ query_vec  # cosine sim (нормализованные)
print("Результаты nomic-embed:")
for score, doc in sorted(zip(scores, docs), reverse=True):
    print(f"  {score:.3f}  {doc}")

Код: BGE-M3 с Dense + Sparse

"""
pip install FlagEmbedding torch
Размер модели: ~1.2 ГБ
FlagEmbedding даёт доступ к dense, sparse и colbert-векторам.
"""

import numpy as np
from FlagEmbedding import BGEM3FlagModel
from dataclasses import dataclass


@dataclass
class BGEOutput:
    dense: np.ndarray                       # (N, 1024) — dense vectors
    sparse: list[dict[str, float]]          # [{token: weight}, ...]


_bge_model: BGEM3FlagModel | None = None

def get_bge_model() -> BGEM3FlagModel:
    global _bge_model
    if _bge_model is None:
        _bge_model = BGEM3FlagModel(
            "BAAI/bge-m3",
            use_fp16=True,      # FP16: ×2 быстрее на GPU, качество не теряется
        )
    return _bge_model


def embed_bge(
    texts: list[str],
    return_sparse: bool = False,
    batch_size: int = 12,
    max_length: int = 8192,
) -> BGEOutput:
    """
    Dense-only (return_sparse=False): быстрее, достаточно для большинства задач.
    Dense+Sparse (return_sparse=True): hybrid retrieval без отдельного BM25.

    Sparse-вектор — словарь {token_id: вес}, аналог SPLADE.
    Qdrant и Weaviate поддерживают sparse-векторы нативно.
    """
    model = get_bge_model()
    output = model.encode(
        texts,
        batch_size=batch_size,
        max_length=max_length,
        return_dense=True,
        return_sparse=return_sparse,
        return_colbert_vecs=False,  # ColBERT требует много памяти, пропускаем
    )
    dense = output["dense_vecs"].astype(np.float32)

    if return_sparse:
        sparse = output["lexical_weights"]  # list[dict] — token → weight
    else:
        sparse = [{} for _ in texts]

    return BGEOutput(dense=dense, sparse=sparse)


def hybrid_score(
    query_dense: np.ndarray,
    query_sparse: dict[str, float],
    doc_dense: np.ndarray,
    doc_sparse: dict[str, float],
    alpha: float = 0.5,
) -> float:
    """
    Гибридный скор: alpha * dense_score + (1-alpha) * sparse_score.
    alpha=1.0 → только dense, alpha=0.0 → только sparse.
    """
    dense_score = float(np.dot(query_dense, doc_dense))

    # Sparse score: dot product по общим токенам
    common_tokens = set(query_sparse) & set(doc_sparse)
    sparse_score = sum(
        query_sparse[t] * doc_sparse[t] for t in common_tokens
    )
    # Нормализуем sparse в [0,1] — грубая оценка
    sparse_norm = max(
        sum(v**2 for v in query_sparse.values()) ** 0.5 *
        sum(v**2 for v in doc_sparse.values()) ** 0.5,
        1e-9,
    )
    sparse_score /= sparse_norm

    return alpha * dense_score + (1 - alpha) * sparse_score


# Пример hybrid retrieval
docs = [
    "BGE-M3 supports dense retrieval",
    "Установка FastAPI: pip install fastapi",
    "История Python: создан Гвидо ван Россумом",
]

query = "как установить fastapi"

# Эмбеддируем с sparse
doc_out   = embed_bge(docs,    return_sparse=True)
query_out = embed_bge([query], return_sparse=True)

print("BGE-M3 Hybrid scores:")
for i, doc in enumerate(docs):
    score = hybrid_score(
        query_out.dense[0], query_out.sparse[0],
        doc_out.dense[i],   doc_out.sparse[i],
        alpha=0.6,
    )
    print(f"  {score:.3f}  {doc}")

Код: multilingual-E5

"""
pip install sentence-transformers
Варианты: multilingual-e5-small (120 МБ), -base (470 МБ), -large (560 МБ)
"""

import numpy as np
from sentence_transformers import SentenceTransformer
from functools import lru_cache


@lru_cache(maxsize=3)
def get_e5_model(size: str = "large") -> SentenceTransformer:
    """Кешируем модель: один экземпляр на процесс."""
    return SentenceTransformer(f"intfloat/multilingual-e5-{size}")


def embed_e5_documents(
    texts: list[str],
    size: str = "large",
    batch_size: int = 32,
) -> np.ndarray:
    """
    Префикс "passage: " для документов — ОБЯЗАТЕЛЕН.
    Без него MTEB Retrieval падает на ~10 пунктов.
    """
    model = get_e5_model(size)
    prefixed = [f"passage: {t}" for t in texts]
    vecs = model.encode(prefixed, batch_size=batch_size, normalize_embeddings=True)
    return vecs.astype(np.float32)


def embed_e5_query(query: str, size: str = "large") -> np.ndarray:
    """Префикс "query: " для запросов."""
    model = get_e5_model(size)
    vec = model.encode([f"query: {query}"], normalize_embeddings=True)
    return vec[0].astype(np.float32)


# Выбор варианта по задаче
# small:  CPU-friendly, 120 МБ, ~2x быстрее large, MTEB 58.7
# base:   баланс скорость/качество, 470 МБ
# large:  максимальное качество, 560 МБ, MTEB 60.8

# Пример: сравнение small vs large на русских текстах
texts_ru = [
    "Установка FastAPI в виртуальное окружение Python",
    "История создания Эйфелевой башни",
    "Нейронные сети и машинное обучение",
]
query_ru = "установка веб-фреймворка"

for size in ["small", "large"]:
    docs_v = embed_e5_documents(texts_ru, size=size)
    query_v = embed_e5_query(query_ru, size=size)
    scores = docs_v @ query_v
    top = texts_ru[scores.argmax()]
    print(f"E5-{size}: top-1 = «{top[:50]}» ({scores.max():.3f})")

Практическое сравнение на одних данных

"""
Бенчмарк: сравниваем модели по скорости и MRR@10 на своих данных.
Запускайте на репрезентативной выборке своего корпуса.
"""

import asyncio
import time
import numpy as np
from dataclasses import dataclass, field


@dataclass
class BenchResult:
    model: str
    mrr_at_10: float
    embed_ms_per_doc: float
    dim: int
    total_docs: int


def compute_mrr(query_vecs: np.ndarray, doc_vecs: np.ndarray, qrels: list[list[int]]) -> float:
    """
    qrels[i] = список индексов релевантных документов для запроса i.
    """
    mrrs = []
    for q_idx, (qvec, relevant) in enumerate(zip(query_vecs, qrels)):
        sims = doc_vecs @ qvec
        ranked = np.argsort(-sims)[:10]
        for rank, doc_idx in enumerate(ranked, 1):
            if doc_idx in relevant:
                mrrs.append(1.0 / rank)
                break
        else:
            mrrs.append(0.0)
    return float(np.mean(mrrs))


async def benchmark_all(
    docs: list[str],
    queries: list[str],
    qrels: list[list[int]],
) -> list[BenchResult]:
    results = []

    # ── OpenAI text-embedding-3-small ──
    t0 = time.time()
    doc_vecs   = await embed_openai(docs,    model="text-embedding-3-small")
    query_vecs = await embed_openai(queries, model="text-embedding-3-small")
    ms = (time.time() - t0) / len(docs) * 1000
    mrr = compute_mrr(query_vecs, doc_vecs, qrels)
    results.append(BenchResult("text-embedding-3-small", mrr, ms, 1536, len(docs)))

    # ── nomic-embed-text-v1.5 ──
    t0 = time.time()
    doc_vecs   = embed_nomic_docs(docs)
    query_vecs = np.array([embed_nomic_query(q) for q in queries])
    ms = (time.time() - t0) / len(docs) * 1000
    mrr = compute_mrr(query_vecs, doc_vecs, qrels)
    results.append(BenchResult("nomic-embed-v1.5", mrr, ms, 768, len(docs)))

    # ── BGE-M3 dense ──
    t0 = time.time()
    doc_out   = embed_bge(docs)
    query_out = embed_bge(queries)
    ms = (time.time() - t0) / len(docs) * 1000
    mrr = compute_mrr(query_out.dense, doc_out.dense, qrels)
    results.append(BenchResult("bge-m3-dense", mrr, ms, 1024, len(docs)))

    # ── multilingual-e5-large ──
    t0 = time.time()
    doc_vecs   = embed_e5_documents(docs)
    query_vecs = np.array([embed_e5_query(q) for q in queries])
    ms = (time.time() - t0) / len(docs) * 1000
    mrr = compute_mrr(query_vecs, doc_vecs, qrels)
    results.append(BenchResult("multilingual-e5-large", mrr, ms, 1024, len(docs)))

    return results


# Запуск и вывод
async def main():
    # Ваши данные: список документов, запросы и список релевантных индексов
    docs    = ["..."] * 200   # ← вставьте свои документы
    queries = ["..."] * 20    # ← ваши запросы
    qrels   = [[0, 1], [5]]   # ← qrels[i] = индексы релевантных docs для query[i]

    results = await benchmark_all(docs, queries, qrels)

    print(f"{'Модель':<25} {'MRR@10':>7} {'ms/doc':>8} {'Dim':>6}")
    print("─" * 52)
    for r in sorted(results, key=lambda x: -x.mrr_at_10):
        print(f"{r.model:<25} {r.mrr_at_10:>7.3f} {r.embed_ms_per_doc:>8.1f} {r.dim:>6}")

asyncio.run(main())

Гайд по выбору модели

Быстрый старт, не хочу настраивать. Нет времени на эксперименты, корпус на EN или смешанный RU+EN, есть бюджет на API.
text-embedding-3-small
Данные конфиденциальны, нужен offline. Медицинские записи, юридические документы, внутренняя документация — данные не могут покидать контур.
nomic-embed / BGE-M3
Корпус преимущественно на русском. Внутренняя вики, техподдержка, новости на РУ — хочу максимальное качество именно на русских текстах.
BGE-M3
Нет GPU, запускаю на CPU-сервере. Небольшой корпус до 50k документов, скорость некритична, хочу приемлемое качество без GPU.
multilingual-e5-small
Нужен hybrid search (dense + sparse). Корпус содержит специфические термины, имена, ID — нужны одновременно семантика и точное совпадение ключевых слов.
BGE-M3
Длинные документы (>512 токенов). Статьи, контракты, технические спецификации — нужен контекст целого раздела или документа без обрезки.
BGE-M3 / nomic-embed
Нужно сэкономить на хранении векторов. Коллекция 10M+ документов, хочу уменьшить размер индекса без переобучения и значительной потери качества.
text-embedding-3 + dimensions=512
Максимальное качество, деньги не проблема. Юридика, медицина, финансы — каждый нерелевантный результат критичен, готов платить за лучшее качество.
text-embedding-3-large

Смена модели в production

Векторные пространства разных моделей несовместимы. Переход на новую модель требует полной переиндексации. Вот как это сделать безопасно, без downtime.

"""
Blue-Green переиндексация при смене embedding-модели.

Стратегия:
  1. Создаём новую коллекцию (new_collection)
  2. Переиндексируем все документы новой моделью
  3. Переключаем трафик на новую коллекцию
  4. Удаляем старую коллекцию

Это обеспечивает zero-downtime: пока идёт переиндексация,
старая коллекция продолжает отвечать на запросы.
"""

import asyncio
from dataclasses import dataclass
import chromadb


@dataclass
class IndexStats:
    total: int = 0
    processed: int = 0
    failed: int = 0


async def reindex_to_new_model(
    old_collection_name: str,
    new_collection_name: str,
    new_embed_fn,          # async callable(texts) -> np.ndarray
    chroma_client: chromadb.Client,
    batch_size: int = 100,
    progress_fn=None,      # callback(processed, total)
) -> IndexStats:
    """
    Переносит все документы из старой коллекции в новую,
    эмбеддируя через new_embed_fn.
    """
    old_col = chroma_client.get_collection(old_collection_name)
    new_col = chroma_client.get_or_create_collection(new_collection_name)
    stats = IndexStats()

    # Получаем все документы из старой коллекции (без эмбеддингов)
    all_data = old_col.get(include=["documents", "metadatas"])
    stats.total = len(all_data["ids"])

    for i in range(0, stats.total, batch_size):
        batch_ids   = all_data["ids"][i : i + batch_size]
        batch_texts = all_data["documents"][i : i + batch_size]
        batch_metas = all_data["metadatas"][i : i + batch_size]

        try:
            new_vecs = await new_embed_fn(batch_texts)
            new_col.add(
                ids=batch_ids,
                embeddings=new_vecs.tolist(),
                documents=batch_texts,
                metadatas=batch_metas,
            )
            stats.processed += len(batch_ids)
        except Exception as e:
            print(f"Ошибка в батче {i}: {e}")
            stats.failed += len(batch_ids)

        if progress_fn:
            progress_fn(stats.processed, stats.total)

    return stats


# Использование
async def migrate():
    client = chromadb.Client()

    # Новая модель — BGE-M3 вместо text-embedding-3-small
    async def new_embed(texts: list[str]):
        return embed_bge(texts).dense

    stats = await reindex_to_new_model(
        old_collection_name="docs_v1",
        new_collection_name="docs_v2",
        new_embed_fn=new_embed,
        chroma_client=client,
        progress_fn=lambda n, t: print(f"\r{n}/{t}", end=""),
    )
    print(f"\nПереиндексировано: {stats.processed}, ошибок: {stats.failed}")
    # После успеха: переключаем приложение на "docs_v2", удаляем "docs_v1"

asyncio.run(migrate())

Типичные ошибки

Смешивать модели в одном индексе
Часть документов проиндексировали text-embedding-3-small, часть — multilingual-e5. При поиске одной моделью — мусор. Пространства разных моделей несовместимы математически.
✓ Храните имя модели в метаданных коллекции. При добавлении документов — проверяйте соответствие. Один индекс = одна модель.
Сравнивать MTEB scores для EN и RU задач
MTEB leaderboard — преимущественно английский. text-embedding-3-small MTEB=62.3 на EN, но на MIRACL RU = 53.8. BGE-M3 на EN меньше — но на RU выигрывает. Выбор по EN MTEB для русского корпуса ведёт к субоптимальному результату.
✓ Для русского языка смотрите MIRACL benchmark и ru-MTEB отдельно. Запускайте own-benchmark на вашем корпусе.
Забывать task-префиксы у E5 и nomic
Код без префиксов работает — ошибок нет. Но качество снижается на 5–15% тихо и незаметно. Разработчик думает, что модель слабая, хотя проблема в отсутствующем "passage: ".
✓ Оберните embed-функции в helper'ы: embed_e5_docs() и embed_e5_query() — внутри каждой прибит правильный префикс.
Сравнивать MRL-векторы разных размерностей
dimensions=1536 и dimensions=256 от одной модели — разные пространства. Нельзя вычислять сходство между 1536-мерным вектором запроса и 256-мерным вектором документа.
✓ Документы и запросы должны иметь одинаковую dimensions. Зафиксируйте в конфиге: EMBEDDING_DIM=512, и используйте его везде.
Загружать модель внутри функции при каждом вызове
model = SentenceTransformer("...") внутри функции означает загрузку 500 МБ с диска при каждом запросе. 10 запросов — 10 загрузок, 5 секунд overhead на каждый.
✓ Загружайте модель один раз при старте приложения. Используйте глобальную переменную или синглтон (@lru_cache, dependency injection).

Шпаргалка

Правило выбора за 10 секунд:
  • Данные не могут покинуть контур → BGE-M3 (лучший локально)
  • Важен русский язык → BGE-M3 или multilingual-e5-large
  • Нужен API без настройки → text-embedding-3-small
  • CPU-only, небольшой корпус → multilingual-e5-small
  • Нужен hybrid search → BGE-M3 (dense + sparse из одной модели)
TASK-ПРЕФИКСЫ (важно не забыть):

  multilingual-e5:
    Документы:  "passage: {текст}"
    Запросы:    "query: {текст}"

  nomic-embed-text-v1.5:
    Документы:  "search_document: {текст}"
    Запросы:    "search_query: {текст}"
    Кластеризация: "clustering: {текст}"

  BGE-M3, text-embedding-3:
    — без префиксов —

MRL РАЗМЕРНОСТИ (text-embedding-3-small, MTEB EN):
  1536d → 62.3 (baseline, ~6 КБ/doc)
  512d  → 61.6 (−0.7, ~2 КБ/doc)
  256d  → 61.1 (−1.2, ~1 КБ/doc)
  64d   → 57.9 (−4.4, ~0.25 КБ/doc)

СМЕНА МОДЕЛИ: всегда полная переиндексация!
  1. Создать new_collection с новой моделью
  2. Переиндексировать все документы
  3. Переключить трафик на new_collection
  4. Удалить old_collection

ТЕСТ ПЕРЕД ВЫБОРОМ:
  Всегда прогоните benchmark на 20-50 своих запросах
  с известными ответами. MTEB ≠ ваш конкретный домен.
        

Практические задания

  1. Собственный бенчмарк. Возьмите документацию или корпус своего проекта. Создайте 15–20 тестовых вопросов с известными релевантными документами. Прогоните все четыре модели через функцию benchmark_all() и сравните MRR@10. Модель с лучшим MTEB не всегда побеждает на вашем домене.
  2. MRL trade-off на практике. Проиндексируйте один корпус через text-embedding-3-small с dimensions=1536, 512, 64. Для каждой размерности запустите retrieval на 10 тестовых запросах и измерьте время поиска + MRR@10. Найдите «точку перегиба» — где уменьшение dim начинает заметно ухудшать качество.
  3. BGE-M3 hybrid vs dense. Используя corpus с документами, содержащими специфические термины или имена (например, UUIDs или названия продуктов), сравните dense-only и hybrid-retrieval. Задайте запросы, где точное совпадение термина критично. Какой alpha (0.3, 0.5, 0.7) даёт лучший MRR?