HardПрактика7 min

Embedding стратегии

Выбор модели (OpenAI, BGE, nomic, sentence-transformers), размерность, нормализация, pooling, fine-tuning, мультиязычность, chunk-then-embed, re-embedding

Что такое embedding

Embedding -- это представление объекта (текст, картинка, товар) как вектор в d-мерном пространстве. Цель: близкие по смыслу объекты получают близкие векторы (по cosine или L2). Embedding -- это мост между дискретным миром слов/документов и непрерывным миром ML-моделей.

Задачи, где embeddings фундаментальны: семантический поиск, RAG, кластеризация, классификация через kNN, рекомендации (item/user embeddings), дедупликация, детекция аномалий.

Качество всей системы -- от RAG до рекомендательного движка -- в первую очередь определяется качеством embeddings. Ошибка здесь каскадно бьёт дальше.

Выбор модели

Основные семейства моделей

Семейство Представители Размерность Ориентир Лицензия
OpenAI text-embedding-3-small, text-embedding-3-large 512-3072 качество, англоязычный корпус proprietary API
Cohere embed-v3, embed-multilingual-v3 1024 мультиязычный, retrieval proprietary API
BGE (BAAI) bge-large-en, bge-m3, bge-reranker 384-1024 OSS, retrieval-first MIT
E5 (Microsoft) e5-large-v2, multilingual-e5-large 1024 OSS, instruction-tuned MIT
Sentence-Transformers all-MiniLM-L6-v2, paraphrase-multilingual-mpnet 384-768 OSS, легковесные Apache 2.0
nomic-embed nomic-embed-text-v1.5 768 OSS, long-context, self-hosted Apache 2.0
Jina jina-embeddings-v3 1024 OSS, long-context (8k tokens) CC-BY-NC
Instructor instructor-xl 768 задачно-адаптируемые Apache 2.0

Как выбирать

Три осевых вопроса:

  1. Self-hosted или API? API (OpenAI/Cohere) -- качество топ, но данные уходят, ограничения на RPS, биллинг. Self-hosted (BGE/nomic/E5) -- полный контроль, но нужен GPU или CPU-батчи, нужно тюнить.
  2. Язык. Многие топ-модели тренированы на английском. Для русского/мультиязычного -- multilingual-e5-large, paraphrase-multilingual-mpnet-base-v2, bge-m3, Cohere multilingual.
  3. Длина текста. Обычный лимит -- 512 токенов. Jina/nomic тянут до 8k, полезно для чанков-крупных страниц.

Для IT CRIB выбран nomic-embed-text через локальный Ollama: работает без GPU, лицензия Apache, русский терпимо. Ограничение: падает на текстах > 5000 символов кириллицы -- поэтому чанки делаем 400 символов.

MTEB benchmark

Стандарт сравнения: MTEB (Massive Text Embedding Benchmark, Hugging Face). 56 задач, 8 категорий. Обращайте внимание на подкатегорию, релевантную вашей задаче:

  • Retrieval -- для RAG, поиска
  • STS (Semantic Textual Similarity) -- схожесть фраз
  • Classification -- когда embedding подаётся в классификатор
  • Clustering -- группировка

MTEB агрегат -- грубый ориентир. Всегда валидируйте на своём домене.

Размерность: 384 / 768 / 1536 / 3072

Компромисс "качество ↔ стоимость":

Размерность Модели Память на 1M векторов Скорость kNN Качество
384 MiniLM, bge-small 1.5 GB быстрая базовое
768 mpnet, nomic, bge-base 3 GB средняя хорошее
1024 E5-large, bge-large, Cohere 4 GB средняя высокое
1536 OpenAI text-embedding-3-small 6 GB медленная высокое
3072 OpenAI text-embedding-3-large 12 GB медленная топ

Matryoshka embeddings (text-embedding-3, nomic-v1.5) -- специальный трюк: модель обучается так, что первые k компонент уже осмысленны. Можно хранить 3072 для максимального качества, а искать по первым 512 для скорости, добирать точность переранкингом в полной размерности.

Практика: дефолт -- 768. Масштаб > 10M векторов и бюджет на латентность критичен -- 384 или matryoshka с обрезкой. Топ-качество без ограничений -- 1536+.

Нормализация L2

Cosine similarity с нормализованными векторами (‖v‖ = 1) эквивалентен dot product:

cos(a, b) = a·b / (‖a‖ ‖b‖) = a·b   (if ‖a‖ = ‖b‖ = 1)

Зачем это важно:

  • pgvector с <=> -- автоматически считает cosine, но быстрее на нормализованных векторах и совместимо с <#> (inner product)
  • FAISS / HNSW -- многие индексы оптимизированы под нормализованные векторы
  • Стабильность: некоторые модели выдают ненормализованные -- тогда норма тоже несёт сигнал о "длине текста", что редко полезно
import numpy as np

def l2_normalize(v: np.ndarray) -> np.ndarray:
    norm = np.linalg.norm(v, axis=-1, keepdims=True)
    return v / np.clip(norm, 1e-12, None)

# batch-normalize
embeddings = l2_normalize(embeddings)

Некоторые модели (OpenAI v3, BGE) уже отдают нормализованные векторы. Проверяйте -- np.linalg.norm(v) должно быть ≈ 1.0.

Pooling

Transformer-модель на вход получает последовательность токенов, на выход -- последовательность векторов (по одному на токен). Нам нужен один вектор на текст. Это делается через pooling:

  • [CLS] pooling -- берём вектор специального токена [CLS] из начала. Работает, если модель так обучалась (BERT-classification).
  • Mean pooling -- среднее по всем токенам (с учётом attention mask). Дефолт для sentence-transformers, надёжно.
  • Max pooling -- покомпонентный максимум. Редко, специфические задачи.
  • Weighted mean -- с весами по позиции, по attention, по TF-IDF.
  • Late interaction (ColBERT) -- не pooling вообще, сохраняем все токены; ретривер считает max-sim между query-токенами и doc-токенами. Дороже, но точнее.
def mean_pooling(last_hidden_state, attention_mask):
    # last_hidden_state: [batch, seq_len, hidden_dim]
    # attention_mask:    [batch, seq_len]
    mask = attention_mask.unsqueeze(-1).float()       # [batch, seq_len, 1]
    summed = (last_hidden_state * mask).sum(dim=1)    # [batch, hidden_dim]
    counted = mask.sum(dim=1).clamp(min=1e-9)         # [batch, 1]
    return summed / counted

Высокоуровневые API (sentence-transformers, OpenAI, Ollama) делают pooling сами -- вам не нужно думать. Но знать важно: когда пишете собственный инференс или fine-tune'ите модель, выбор pooling влияет на результат.

Домен-специфичный fine-tuning

Базовая модель обучена на открытом вебе. В узком домене (юридический, медицинский, ваш товарный каталог) её качество ниже, чем домен-адаптированной.

Contrastive learning

Самый популярный подход: InfoNCE / Multiple Negatives Ranking Loss. Нужен датасет пар (query, positive) -- семантически близкие тексты. Из каждого батча берутся "in-batch negatives" -- случайные тексты из того же батча.

Источники пар:

  • Логи кликов: (query, clicked_doc) -- естественные позитивы
  • Синтетика: GPT генерирует парафразы каждого документа
  • FAQ: (question, answer)
  • Тайтл + описание одного товара -- positive pair

Минимальный датасет для полезного fine-tune -- ~10k пар.

from sentence_transformers import SentenceTransformer, losses, InputExample
from torch.utils.data import DataLoader

model = SentenceTransformer("intfloat/multilingual-e5-base")

train_examples = [
    InputExample(texts=["что такое HNSW", "как работает иерархический граф ближайших соседей"]),
    InputExample(texts=["LLM токенизация", "разбиение текста на токены"]),
    # ... 10k+ pairs
]

loader = DataLoader(train_examples, shuffle=True, batch_size=64)
loss = losses.MultipleNegativesRankingLoss(model)

model.fit(
    train_objectives=[(loader, loss)],
    epochs=3,
    warmup_steps=100,
    output_path="./models/bge-it-crib-v1",
)

Hard negatives mining

Большой скачок качества -- добавить "тяжёлые" негативы: похожие, но семантически разные тексты. Находятся первым прогоном модели: для каждой query берём top-100 ближайших, исключаем известный positive, оставшиеся -- кандидаты на hard negative (с отбором человеком или через rerank).

Мультиязычность

Русскоязычные системы -- отдельная песня. Наивный выбор английской модели даёт мусор на кириллице: пробелы, опечатки, морфология.

Рекомендации для русского

  1. paraphrase-multilingual-mpnet-base-v2 -- 768-мерный, 50+ языков, Apache. Дефолт для русского.
  2. LaBSE -- 768, 109 языков. Хорош на семантической эквивалентности между языками.
  3. multilingual-e5-large -- 1024, сильный retrieval, instruction-tuned ("query: ..." / "passage: ...").
  4. bge-m3 -- 1024, multi-granularity (sparse+dense+colbert), long-context до 8k.
  5. cointegrated/rubert-tiny2 -- для легковесных задач, русско-ориентированный.
  6. nomic-embed-text (IT CRIB выбор) -- мультиязычный, 768, Apache. Проблема с длинной кириллицей -- держим чанки 400 символов.

Важный паттерн для E5: префиксы. При embedding query --

"query: как работает HNSW индекс"
"passage: HNSW -- это алгоритм приближённого поиска..."

Без префиксов качество падает на ~15%.

Кросс-языковой поиск

Если пользователь пишет по-русски, а документы по-английски (или наоборот) -- нужна cross-lingual модель. LaBSE, E5-multilingual, Cohere multilingual -- все работают. Но стоит валидировать на своём домене: качество варьируется.

Chunk-then-embed

Длинные документы не эмбеддятся целиком -- теряется специфика, модель ограничена по длине входа. Стратегия: режем на чанки, эмбеддим каждый, ищем по чанкам.

Параметры чанкинга (детально в 7.rag-architecture.md):

  • Размер: 400-800 символов для русского
  • Overlap: 100 символов
  • Respect sentence boundaries

Тонкости:

  • Контекстуализация: к чанку добавляйте метаданные: заголовок документа, секцию, дату. Улучшает embedding-качество:
"Документ: RAG архитектура. Секция: Reranking.
Cross-encoder -- это маленькая модель, которая оценивает пары..."
  • Sentence-window retrieval: эмбеддим по предложению, а при генерации подаём окно ±3 предложения вокруг попавшего. Даёт точный retrieval + богатый контекст.
  • Summary + details: эмбеддим summary параграфа, а в индекс кладём полный параграф. Для "больших" чанков работает лучше.

Re-embedding при смене модели

Модели устаревают. Переход text-embedding-ada-002 → text-embedding-3-large = разные размерности, разные пространства. Векторы несовместимы: запрос, эмбеддированный новой моделью, ничего разумного не найдёт в индексе старой.

Варианты миграции

  1. Big bang re-embed. Создаёте новый индекс, пересчитываете всё, переключаете трафик. Простой план, но дорого по времени и вычислениям на больших датасетах.
  2. Blue/green. Старый индекс продолжает работать. Параллельно -- новый с новой моделью. Онлайн-эксперимент (A/B), после валидации -- переключение.
  3. Dual-write + shadow read. Все новые документы эмбеддятся обеими моделями. Поиск на старой, логируется результат новой (shadow). Когда lookup-ratio достаточен (все "горячие" документы покрыты) -- переключаем.
  4. Lazy re-embed. Только документы, которые обновились/искались, пере-эмбеддятся. Очень медленная миграция, но дешёвая.

Критично: версионировать model_name и model_version в таблице эмбеддингов -- иначе вы не будете знать, какие векторы из какой модели. Один индекс -- одна модель. Не смешивайте.

Дрейф качества

Даже без смены модели -- через 6-12 месяцев качество retrieval может деградировать, так как ваш домен меняется, а модель нет. Метрики для мониторинга: offline recall@K на эталонной выборке, user clicks на top-K results, explicit feedback. Рост "zero-result queries" -- сигнал к анализу.

PHP: batch embed через Ollama + pgvector

<?php

declare(strict_types=1);

namespace App\Embedding;

use Doctrine\DBAL\Connection;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
 * Batch embedder using local Ollama inference.
 * Writes vectors into pgvector with model version for safe migration.
 */
final class OllamaEmbedder
{
    private const BATCH_SIZE = 32;
    private const MAX_CHARS  = 4500;   // safety margin for nomic-embed-text
    private const MODEL_NAME = 'nomic-embed-text';
    private const MODEL_VER  = 'v1.5';
    private const DIM        = 768;

    public function __construct(
        private readonly HttpClientInterface $http,
        private readonly Connection $db,
        private readonly LoggerInterface $logger,
        private readonly string $ollamaUrl = 'http://ollama:11434',
    ) {}

    /**
     * Embed a single text, L2-normalized.
     *
     * @return list<float>
     */
    public function embed(string $text): array
    {
        $trimmed = mb_substr($text, 0, self::MAX_CHARS);

        $response = $this->http->request('POST', $this->ollamaUrl . '/api/embed', [
            'json' => [
                'model' => self::MODEL_NAME,
                'input' => $trimmed,
            ],
            'timeout' => 30,
        ]);

        $data = $response->toArray();
        $vec = $data['embeddings'][0] ?? null;

        if (!is_array($vec) || count($vec) !== self::DIM) {
            throw new \RuntimeException('Invalid embedding response');
        }

        return $this->l2Normalize($vec);
    }

    /**
     * Embed a batch of chunks and write to pgvector in one transaction.
     *
     * @param list<array{source_id: string, chunk_index: int, content: string}> $chunks
     */
    public function ingestBatch(array $chunks): int
    {
        $inserted = 0;

        foreach (array_chunk($chunks, self::BATCH_SIZE) as $batch) {
            $rows = [];
            foreach ($batch as $c) {
                $vec = $this->embed($c['content']);
                $rows[] = [
                    'source_id'    => $c['source_id'],
                    'chunk_index'  => $c['chunk_index'],
                    'content'      => $c['content'],
                    'embedding'    => '[' . implode(',', $vec) . ']',
                    'model_name'   => self::MODEL_NAME,
                    'model_ver'    => self::MODEL_VER,
                ];
            }

            $this->db->transactional(function (Connection $conn) use ($rows, &$inserted): void {
                foreach ($rows as $r) {
                    $conn->executeStatement(
                        'INSERT INTO rag_chunks
                         (source_id, chunk_index, content, embedding, model_name, model_version)
                         VALUES (:source_id, :chunk_index, :content,
                                 :embedding::vector, :model_name, :model_ver)
                         ON CONFLICT (source_id, chunk_index, model_name, model_version)
                         DO UPDATE SET content = EXCLUDED.content,
                                       embedding = EXCLUDED.embedding',
                        $r,
                    );
                    $inserted++;
                }
            });

            $this->logger->info('embedder.batch_done', [
                'batch_size' => count($batch),
                'total' => $inserted,
            ]);
        }

        return $inserted;
    }

    /**
     * @param list<float> $v
     * @return list<float>
     */
    private function l2Normalize(array $v): array
    {
        $sumSq = 0.0;
        foreach ($v as $x) {
            $sumSq += $x * $x;
        }
        $norm = sqrt($sumSq);
        if ($norm < 1e-12) {
            return $v;
        }
        return array_map(fn (float $x) => $x / $norm, $v);
    }
}

Cosine search SQL

-- Assumes embeddings are already L2-normalized and HNSW index exists.
-- Returns top-10 chunks above similarity threshold, filtered by section.
SELECT
    rc.id,
    rc.source_id,
    rc.content,
    1 - (rc.embedding <=> :query_vec::vector) AS similarity
FROM rag_chunks rc
WHERE rc.model_name = 'nomic-embed-text'
  AND rc.model_version = 'v1.5'
  AND (:section IS NULL OR rc.metadata->>'section' = :section)
  AND 1 - (rc.embedding <=> :query_vec::vector) > 0.6  -- threshold
ORDER BY rc.embedding <=> :query_vec::vector
LIMIT 10;

Go: batch embed worker

package embedding

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"math"
	"net/http"
	"time"
)

const (
	modelName    = "nomic-embed-text"
	modelVersion = "v1.5"
	dim          = 768
	maxChars     = 4500
	batchSize    = 32
)

// Embedder is a batch-friendly Ollama client that returns L2-normalized vectors.
type Embedder struct {
	httpc   *http.Client
	baseURL string
	log     *slog.Logger
}

func NewEmbedder(baseURL string, log *slog.Logger) *Embedder {
	return &Embedder{
		httpc:   &http.Client{Timeout: 30 * time.Second},
		baseURL: baseURL,
		log:     log,
	}
}

type embedRequest struct {
	Model string `json:"model"`
	Input string `json:"input"`
}

type embedResponse struct {
	Embeddings [][]float32 `json:"embeddings"`
}

// Embed a single text. Returns L2-normalized vector of length dim.
func (e *Embedder) Embed(ctx context.Context, text string) ([]float32, error) {
	if len(text) > maxChars {
		text = text[:maxChars]
	}

	payload, err := json.Marshal(embedRequest{Model: modelName, Input: text})
	if err != nil {
		return nil, err
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost,
		e.baseURL+"/api/embed", bytes.NewReader(payload))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")

	resp, err := e.httpc.Do(req)
	if err != nil {
		return nil, fmt.Errorf("ollama embed: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("ollama embed: status %d", resp.StatusCode)
	}

	var out embedResponse
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, err
	}
	if len(out.Embeddings) == 0 || len(out.Embeddings[0]) != dim {
		return nil, fmt.Errorf("invalid embedding shape")
	}

	return l2Normalize(out.Embeddings[0]), nil
}

// Cosine similarity assuming both vectors are L2-normalized.
func Cosine(a, b []float32) float32 {
	if len(a) != len(b) {
		return 0
	}
	var dot float32
	for i := range a {
		dot += a[i] * b[i]
	}
	return dot
}

func l2Normalize(v []float32) []float32 {
	var sumSq float64
	for _, x := range v {
		sumSq += float64(x) * float64(x)
	}
	norm := math.Sqrt(sumSq)
	if norm < 1e-12 {
		return v
	}
	out := make([]float32, len(v))
	for i, x := range v {
		out[i] = float32(float64(x) / norm)
	}
	return out
}

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

Симптом Причина Лечение
Поиск даёт нерелевантные результаты Забыли нормализовать векторы L2-normalize на ingest и на query
Кросс-язычный поиск не работает Моноязычная модель Взять multilingual (E5, LaBSE, Cohere)
Качество просело после апгрейда библиотеки Pool-стратегия изменилась Зафиксировать версию, пересчитать
Много "zero-result" запросов Threshold слишком высокий или модель не подходит домену Снизить threshold, fine-tune на своих данных
Очень медленно Синхронный embed по одному тексту Batch через API (32-64 за вызов)
OOM на длинных текстах Модель не справляется Чанк-разбиение; для русского в nomic -- 400 символов
Смешались эмбеддинги от разных моделей Не версионировали Добавить model_name/version в таблицу, отдельный индекс на каждую версию

Выводы

  • Качество embeddings -- первый рычаг качества всего AI-пайплайна; выбор модели критичен.
  • Для русского: paraphrase-multilingual-mpnet, multilingual-e5-large, bge-m3, nomic-embed-text. Англоязычные топ-модели теряют до 30% качества на кириллице.
  • Размерность -- компромисс: 768 -- безопасный дефолт; 384 -- если масштаб огромен; 1536+ -- если бюджет позволяет. Matryoshka -- способ иметь оба.
  • L2-нормализация -- обязательна для cosine; некоторые модели делают сами, проверяйте.
  • Pooling влияет на качество -- для sentence-transformers дефолт mean pool, для BERT -- CLS; самописный инференс -- всегда явно.
  • Chunk-then-embed для длинных документов: размер 400-800 симв. для русского, overlap 100, уважайте границы предложений.
  • Re-embedding при смене модели -- планируемая операция: версия в таблице, blue/green переключение, шадоу-валидация.
  • Fine-tune на своём домене даёт +10-20% retrieval; нужен датасет ~10k positive pairs и hard-negatives mining.