Что такое 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 |
Как выбирать
Три осевых вопроса:
- Self-hosted или API? API (OpenAI/Cohere) -- качество топ, но данные уходят, ограничения на RPS, биллинг. Self-hosted (BGE/nomic/E5) -- полный контроль, но нужен GPU или CPU-батчи, нужно тюнить.
- Язык. Многие топ-модели тренированы на английском. Для русского/мультиязычного --
multilingual-e5-large,paraphrase-multilingual-mpnet-base-v2,bge-m3, Cohere multilingual. - Длина текста. Обычный лимит -- 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).
Мультиязычность
Русскоязычные системы -- отдельная песня. Наивный выбор английской модели даёт мусор на кириллице: пробелы, опечатки, морфология.
Рекомендации для русского
- paraphrase-multilingual-mpnet-base-v2 -- 768-мерный, 50+ языков, Apache. Дефолт для русского.
- LaBSE -- 768, 109 языков. Хорош на семантической эквивалентности между языками.
- multilingual-e5-large -- 1024, сильный retrieval, instruction-tuned (
"query: ..."/"passage: ..."). - bge-m3 -- 1024, multi-granularity (sparse+dense+colbert), long-context до 8k.
- cointegrated/rubert-tiny2 -- для легковесных задач, русско-ориентированный.
- 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 = разные размерности, разные пространства. Векторы несовместимы: запрос, эмбеддированный новой моделью, ничего разумного не найдёт в индексе старой.
Варианты миграции
- Big bang re-embed. Создаёте новый индекс, пересчитываете всё, переключаете трафик. Простой план, но дорого по времени и вычислениям на больших датасетах.
- Blue/green. Старый индекс продолжает работать. Параллельно -- новый с новой моделью. Онлайн-эксперимент (A/B), после валидации -- переключение.
- Dual-write + shadow read. Все новые документы эмбеддятся обеими моделями. Поиск на старой, логируется результат новой (shadow). Когда lookup-ratio достаточен (все "горячие" документы покрыты) -- переключаем.
- Lazy re-embed. Только документы, которые обновились/искались, пере-эмбеддятся. Очень медленная миграция, но дешёвая.
Критично: версионировать model_name и model_version в таблице эмбеддингов -- иначе вы не будете знать, какие векторы из какой модели. Один индекс -- одна модель. Не смешивайте.
Дрейф качества
Даже без смены модели -- через 6-12 месяцев качество retrieval может деградировать, так как ваш домен меняется, а модель нет. Метрики для мониторинга: offline recall@K на эталонной выборке, user clicks на top-K results, explicit feedback. Рост "zero-result queries" -- сигнал к анализу.
Реализация: batch embed + cosine search
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.