Зачем превращать текст в числа

Компьютер не понимает слова — он работает с числами. Чтобы сравнивать тексты по смыслу, нужен способ отобразить любой текст в числовой объект, пригодный для математического сравнения.

Наивный подход — посчитать, сколько раз каждое слово встречается в тексте (TF-IDF, bag-of-words). Это работает для поиска по ключевым словам, но полностью теряет смысл: «установить» и «инсталлировать» — это одно и то же, но у них разные числа в таком представлении.

✗ Keyword search (TF-IDF)
Запрос: «установка FastAPI»
«Как установить FastAPI» буква в букву
«FastAPI installation guide»другой язык
«Деплой FastAPI на сервер»синоним
«Добавить FastAPI в проект»другой глагол
«Getting started with FastAPI»перефраз
✓ Semantic search (embedding)
Запрос: «установка FastAPI»
«Как установить FastAPI»0.94
«FastAPI installation guide»0.91
«Деплой FastAPI на сервер»0.87
«Добавить FastAPI в проект»0.83
«Getting started with FastAPI»0.89

Embedding решает задачу иначе: каждый текст превращается в точку в многомерном пространстве так, что семантически близкие тексты оказываются геометрически близки. Запрос «установка FastAPI» и документ «FastAPI installation guide» находятся в похожих точках пространства — их можно сравнить математически.

Теория: от слов к пространству смыслов

Гипотеза дистрибутивности

Всё начинается с наблюдения лингвиста Джона Фёрта (1957):

«You shall know a word by the company it keeps» — слово можно понять по его окружению. Слова, которые встречаются в похожих контекстах, имеют похожее значение.

Возьмём слова «врач», «доктор», «хирург». Они появляются в текстах рядом с похожими словами: больница, пациент, лечение, операция. Значит, эти слова означают нечто близкое, даже если мы никогда явно не программировали это знание.

Именно это используют нейросети для создания embeddings: модель обучается на миллиардах текстов и самостоятельно обнаруживает, какие слова и фразы появляются в похожих контекстах. Никаких словарей, никаких правил — только статистика совместной встречаемости.

Путь от Word2Vec к контекстным моделям

Идея векторных представлений прошла несколько поколений:

2013 — Word2Vec (Google)
  Каждое слово = один фиксированный вектор.
  «bank» всегда один и тот же вектор — нельзя различить
  «river bank» и «bank account».
  Плюс: работает быстро, небольшой размер.

2018 — BERT (Google), контекстные embeddings
  Каждый токен получает вектор, зависящий от контекста.
  «bank» в «river bank» → один вектор.
  «bank» в «bank account» → другой вектор.
  Весь текст представлен набором контекстных векторов.

2019–2020 — Sentence-BERT, E5, BGE — sentence embeddings
  Модели специально обучены давать один вектор
  на целое предложение/абзац через contrastive learning:
  похожие тексты принудительно притягиваются в пространстве,
  непохожие — отталкиваются.

2022–2024 — text-embedding-3, Cohere embed-v3
  Большие модели с тысячами измерений. Поддержка 100+ языков,
  матрёшечные embeddings (можно урезать размерность без переобучения).
        

Для RAG используются модели последнего поколения — sentence/document embeddings. Они дают один вектор на произвольный текст (не только на слово), и специально обучены на задачу семантического поиска.

Как устроена embedding-модель

Посмотрим, что происходит внутри, когда мы передаём текст в embedding-модель на основе трансформера.

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
ПАЙПЛАЙН Входной текст «Как установить FastAPI?» Токенизатор Как │ уста │ нов │ ить │ Fast │ API │ ? ID: 1042 │ 8016 │ 9371 │ 7929 │ 1063 + позиционные + тип-токены Трансформер (12 слоёв self-attention) Каждый токен видит всех соседей → контекстный вектор для каждого Mean pooling усредняем векторы всех токенов Вектор (1 536 чисел) [0.21, −0.84, 0.53, ..., 0.11] СЕМАНТИЧЕСКОЕ ПРОСТРАНСТВО (2D проекция UMAP) программирование базы данных машинное обучение история Python Django Flask код функция FastAPI Запрос: «установка FastAPI» SQL PostgreSQL таблица запрос Redis модель обучение нейросеть точность loss Пётр I история Россия далеко → разные темы Python-экосистема базы данных ML / AI история запрос

Transformer обрабатывает каждый токен в контексте всех остальных (механизм self-attention). После 12–24 таких слоёв каждый токен «знает» о всём окружении. Mean pooling усредняет все токены в один вектор — это и есть embedding документа.

Свойства векторного пространства

Векторное пространство embeddings имеет несколько замечательных свойств, которые делают его полезным для поиска.

Близость = смысловое сходство
Геометрическое расстояние между точками в пространстве соответствует семантической близости текстов. Это не запрограммировано явно — модель обнаруживает это самостоятельно из статистики языка.
sim("Python документация", "Python docs") = 0.96
sim("Python документация", "рецепт торта") = 0.08
Кластеры = тематические группы
Тексты одной тематики естественно образуют кластеры в пространстве. Все статьи про «машинное обучение» будут рядом, все юридические тексты — в своём углу пространства. Это позволяет фильтровать поиск по теме.
Кластер ML: модель, обучение, нейросеть, loss...
Кластер SQL: запрос, таблица, индекс, JOIN...
Векторная арифметика
Направление в пространстве кодирует семантические отношения. Классический пример: разность «мужчина»–«женщина» — это вектор рода. Прибавить его к любому слову — и получим «женскую версию».
v(«король») − v(«мужчина»)
+ v(«женщина») ≈ v(«королева»)

v(«Россия») − v(«Москва»)
+ v(«Берлин») ≈ v(«Германия»)
Нормализация на единичную сферу
Большинство embedding-моделей нормализуют векторы к единичной длине. Это позволяет использовать скалярное произведение вместо косинусного сходства — операция быстрее и проще реализуется в векторных базах данных.
norm(v) = 1 для каждого вектора
dot(a, b) = cos(θ) = cosine_sim(a, b)

Косинусное сходство: главная метрика близости

Для сравнения embeddings используют косинусное сходство — косинус угла между двумя векторами. Оно измеряет направление, а не длину вектора, что важно для текстов разной длины.

cos(θ) = (A · B) / (|A| × |B|)   где · — скалярное произведение

Интерпретация значений:
  cos = 1.0   → векторы сонаправлены → идентичный смысл
  cos = 0.8–1 → очень похожие тексты → «тот же раздел»
  cos = 0.5–0.8 → схожая тема → «та же предметная область»
  cos = 0.0–0.5 → слабая связь
  cos < 0     → теоретически «противоположные», на практике редко

На практике для нормализованных векторов:
  cosine_sim(a, b) = dot(a, b) = sum(a[i] * b[i] for i in range(dim))
  — быстрая операция, реализована в любой векторной БД
        
Важный нюанс про отрицание: «сервис работает» и «сервис не работает» дают косинусное сходство ~0.85–0.92. Embedding фиксирует тему («сервис», «работа»), но плохо улавливает отрицание. Это ограничение всех текущих embedding-моделей — учитывайте при проектировании поиска.

Размерность и нормализация

Embedding — это вектор из N чисел типа float32. Размерность N называют числом измерений или dim.

«Как установить FastAPI?»
→ embedding →
0.21 −0.84 0.53 0.12 −0.33 0.67 0.04 −0.19 0.44 ··· 0.11
Вектор из 1 536 чисел (float32). Размер в памяти: 1 536 × 4 байта = 6 КБ на один документ. Для 100 000 документов: ~600 МБ в RAM.
Модель
Dim
Токены
Цена
Когда использовать
text-embedding-3-small
1 536
8 191
$0.02/M
Баланс цена/качество для большинства задач
text-embedding-3-large
3 072
8 191
$0.13/M
Максимальное качество, юридика и медицина
multilingual-e5-small local
384
512
бесплатно
Быстрый старт локально, многоязычный
multilingual-e5-large local
1 024
512
бесплатно
Высокое качество локально, CPU/GPU
BGE-M3 local
1 024
8 192
бесплатно
Длинные документы, многоязычный, sota open-source

Получение embeddings на практике

OpenAI Embeddings API

import numpy as np
from openai import AsyncOpenAI

client = AsyncOpenAI()  # OPENAI_API_KEY из переменной окружения


async def embed_one(text: str, model: str = "text-embedding-3-small") -> np.ndarray:
    """Эмбеддинг одного текста. Возвращает нормализованный вектор."""
    response = await client.embeddings.create(input=text, model=model)
    vector = np.array(response.data[0].embedding, dtype=np.float32)
    # API уже возвращает нормализованные векторы, но явная нормализация не помешает
    return vector / np.linalg.norm(vector)


async def embed_batch(
    texts: list[str],
    model: str = "text-embedding-3-small",
    batch_size: int = 100,
) -> np.ndarray:
    """
    Batch-эмбеддинг списка текстов.
    Разбиваем на батчи: API принимает до 2048 входов, но 100 — надёжный лимит.
    Возвращает матрицу (N, dim).
    """
    all_vectors: list[list[float]] = []

    for i in range(0, len(texts), batch_size):
        batch = texts[i : i + batch_size]
        response = await client.embeddings.create(input=batch, model=model)
        # Ответы возвращаются в том же порядке, что входы
        batch_vectors = [item.embedding for item in sorted(response.data, key=lambda x: x.index)]
        all_vectors.extend(batch_vectors)

    matrix = np.array(all_vectors, dtype=np.float32)
    # Нормализуем все строки сразу
    norms = np.linalg.norm(matrix, axis=1, keepdims=True)
    return matrix / norms


# Пример
import asyncio

async def main():
    texts = [
        "Как установить FastAPI?",
        "FastAPI installation guide",   # другой язык, похожий смысл
        "Рецепт торта Наполеон",        # другая тема
        "Как НЕ нужно устанавливать FastAPI",  # с отрицанием!
    ]

    vectors = await embed_batch(texts)
    print(f"Размерность: {vectors.shape}")  # (4, 1536)

    # Сравниваем с первым запросом
    query = vectors[0]
    for i, (text, vec) in enumerate(zip(texts[1:], vectors[1:]), 1):
        sim = float(np.dot(query, vec))  # dot = cosine sim для нормализованных
        print(f"sim(query, text[{i}]) = {sim:.3f}  ← «{text[:40]}»")

    # Ожидаемый вывод:
    # sim(query, text[1]) = 0.921  ← «FastAPI installation guide»
    # sim(query, text[2]) = 0.124  ← «Рецепт торта Наполеон»
    # sim(query, text[3]) = 0.873  ← «Как НЕ нужно устанавливать FastAPI»  ← !

asyncio.run(main())

Локальные модели: sentence-transformers

"""
sentence-transformers — библиотека для локальных embedding-моделей.
Поддерживает CPU и GPU, возвращает numpy-массивы.

pip install sentence-transformers
"""

import numpy as np
from sentence_transformers import SentenceTransformer


# Модель загружается один раз и переиспользуется
# multilingual-e5-large: 1.2 ГБ, хорошее качество для RU/EN
model = SentenceTransformer("intfloat/multilingual-e5-large")


def embed_local(
    texts: list[str],
    batch_size: int = 32,
    prefix: str = "passage: ",
) -> np.ndarray:
    """
    Эмбеддинг текстов локальной моделью.

    E5-модели требуют префиксы:
      "query: "   — для поисковых запросов
      "passage: " — для индексируемых документов

    Это важно: при неправильном префиксе качество снижается на 5-15%.
    """
    prefixed = [prefix + t for t in texts]
    return model.encode(
        prefixed,
        batch_size=batch_size,
        normalize_embeddings=True,  # вернёт векторы длиной 1
        show_progress_bar=len(texts) > 100,
    )


def embed_query(query: str) -> np.ndarray:
    return embed_local([query], prefix="query: ")[0]


def embed_documents(docs: list[str]) -> np.ndarray:
    return embed_local(docs, prefix="passage: ")


# Пример: поиск ближайшего документа
documents = [
    "FastAPI — современный веб-фреймворк для Python",
    "Установка FastAPI: pip install fastapi uvicorn",
    "Django — полнофункциональный веб-фреймворк",
    "PostgreSQL — реляционная база данных",
    "Машинное обучение с PyTorch",
]

doc_vectors = embed_documents(documents)
query_vector = embed_query("как установить FastAPI?")

# Cosine similarity = dot product для нормализованных
similarities = doc_vectors @ query_vector  # матричное умножение (N,)
ranked = sorted(enumerate(similarities), key=lambda x: -x[1])

print("Результаты поиска:")
for idx, score in ranked:
    print(f"  {score:.3f}  {documents[idx]}")

# Ожидаемый вывод:
#   0.912  Установка FastAPI: pip install fastapi uvicorn  ← top-1
#   0.874  FastAPI — современный веб-фреймворк для Python
#   0.731  Django — полнофункциональный веб-фреймворк
#   0.312  PostgreSQL — реляционная база данных
#   0.219  Машинное обучение с PyTorch

Что embedding улавливает — и что нет

✓ Хорошо улавливает
Семантическую близость и синонимию («установить» = «инсталлировать»)
Тему и предметную область текста
Межъязыковое сходство в multilingual-моделях (RU ↔ EN ↔ DE)
Стиль и тон: формальный vs неформальный
Структурные паттерны: вопрос vs утверждение vs инструкция
Аналогии и семантические отношения (king–man+woman≈queen)
✗ Улавливает плохо или не улавливает
Отрицание: «работает» и «не работает» ≈ 0.88 сходства
Точные числа: «5%» и «95%» могут быть близко по вектору
Редкие собственные имена, UUID, артикулы товаров
Логические операторы: «если A то B» vs «если B то A»
Порядок слов в коде: a = b vs b = a
Тексты длиннее context window модели (обычно 512–8192 токенов)
Практический вывод: для задач, где критичны точные числа, имена или отрицания, дополняйте semantic search ключевым поиском (BM25) — это называется hybrid search. Semantic search отлично работает для «найди похожее по теме», но не для «найди точно это».

Роль embeddings в RAG-системе

В RAG embeddings используются дважды — и каждый раз для одной задачи: найти ближайшие точки в векторном пространстве.

ИНДЕКСИРОВАНИЕ (один раз):
  Документ → chunking → [чанк1, чанк2, чанк3, ...]
                               ↓ embed_documents()
  Векторы: [v1, v2, v3, ...] → сохраняем в векторную БД
  Ключ: векторы хранятся рядом с исходным текстом чанка

RETRIEVAL (каждый запрос):
  Запрос пользователя → embed_query() → вектор запроса q
                                              ↓
  Векторная БД: найти k ближайших векторов к q
  Метод: Approximate Nearest Neighbor (ANN) — быстро, O(log N)
                                              ↓
  Возвращаем тексты чанков с наибольшим cosine_sim(chunk, query)
                                              ↓
  LLM: prompt = "Контекст: {чанки}\nВопрос: {запрос}"

КЛЮЧЕВОЕ ТРЕБОВАНИЕ:
  Документы и запросы должны эмбеддироваться ОДНОЙ И ТОЙ ЖЕ моделью.
  Векторные пространства разных моделей несовместимы:
  нельзя индексировать через E5 и искать через OpenAI.
        

Визуализация пространства: UMAP

1 536-мерное пространство нельзя нарисовать. Но его можно спроецировать в 2D через UMAP или t-SNE — с частичным сохранением структуры кластеров. Это полезно для отладки: убедиться, что тексты одной темы кластеризуются.

"""
Визуализация embedding-пространства через UMAP.
pip install umap-learn matplotlib
"""

import asyncio
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import umap


async def visualize_embeddings():
    # Тестовые тексты с метками тематики
    data = [
        # Python
        ("Python — язык программирования", "python"),
        ("pip install пакет", "python"),
        ("def функция(аргументы):", "python"),
        ("FastAPI веб-фреймворк", "python"),
        # Базы данных
        ("SQL запрос SELECT * FROM", "database"),
        ("PostgreSQL база данных", "database"),
        ("индекс в базе данных", "database"),
        ("JOIN таблиц в SQL", "database"),
        # ML
        ("обучение нейронной сети", "ml"),
        ("функция потерь loss", "ml"),
        ("градиентный спуск оптимизация", "ml"),
        ("точность модели accuracy", "ml"),
        # История
        ("Пётр I реформы России", "history"),
        ("Французская революция 1789", "history"),
        ("Второй мировой войны", "history"),
        ("история древнего Рима", "history"),
    ]

    texts, labels = zip(*data)
    vectors = await embed_batch(list(texts))

    # Снижаем размерность до 2D
    reducer = umap.UMAP(n_components=2, random_state=42, n_neighbors=5)
    points_2d = reducer.fit_transform(vectors)

    # Рисуем
    colors = {"python": "#7c6fff", "database": "#60a5fa", "ml": "#4ade80", "history": "#fbbf24"}
    fig, ax = plt.subplots(figsize=(10, 8), facecolor="#0d1117")
    ax.set_facecolor("#0d1117")

    for (x, y), text, label in zip(points_2d, texts, labels):
        color = colors[label]
        ax.scatter(x, y, color=color, s=80, alpha=0.8, zorder=3)
        ax.annotate(text[:25], (x, y), fontsize=7, color=color,
                    xytext=(4, 4), textcoords="offset points", alpha=0.9)

    # Легенда
    patches = [mpatches.Patch(color=c, label=l) for l, c in colors.items()]
    ax.legend(handles=patches, facecolor="#1a1a2e", edgecolor="#374151", labelcolor="white")
    ax.set_title("Embedding space (UMAP 2D)", color="white", pad=15)
    ax.tick_params(colors="#374151")
    for spine in ax.spines.values():
        spine.set_edgecolor("#374151")

    plt.tight_layout()
    plt.savefig("embedding_space.png", dpi=150, bbox_inches="tight", facecolor="#0d1117")
    print("Сохранено: embedding_space.png")


asyncio.run(visualize_embeddings())

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

Разные модели для индексирования и поиска
Векторные пространства двух разных моделей несовместимы: «близко» в одном пространстве не значит «близко» в другом. Если переиндексировали с новой моделью, но забыли обновить retriever — поиск вернёт мусор.
✓ Жёстко привязывайте модель к индексу. Храните имя модели в метаданных коллекции. При смене модели — полная переиндексация.
Не использовать префиксы у E5-моделей
Модели семейства E5 (multilingual-e5-small/base/large) и BGE обучены с префиксами: "query: " для запросов и "passage: " для документов. Без них качество падает на 5–15% даже при технически правильном коде.
✓ Читайте Model Card на HuggingFace: там всегда указано, нужны ли префиксы и какие именно.
Рассчитывать на отрицание
«Сервис работает» и «сервис не работает» имеют cosine_sim ≈ 0.87–0.93. Embedding улавливает тему, но не логическое отрицание. Поиск по «что не работает в X» найдёт статьи «как настроить X», не «проблемы с X».
✓ Для поиска по проблемам и дефектам добавляйте ключевой поиск (BM25) по словам «ошибка», «не работает», «проблема», «failed».
Слишком длинные тексты превышают context window
Большинство моделей принимают до 512 токенов (multilingual-e5) или 8 192 токенов (text-embedding-3). Текст длиннее молча обрезается. Embedding 10 000-символьной статьи = embedding только её начала.
✓ Применяйте chunking перед эмбеддингом. Проверяйте: tokenizer.encode(text, return_tensors=None) — если len > max, режьте.
Не нормализовать векторы перед dot product
Скалярное произведение (dot product) без нормализации измеряет и направление, и длину вектора. Длинный текст может иметь бо́льший dot product, чем более релевантный короткий. При ненормализованных векторах длинные документы попадают в топ просто за счёт размера.
✓ Всегда нормализуйте: v / np.linalg.norm(v). Или используйте cosine_similarity вместо dot product.

Шпаргалка

Быстрый старт:
  1. Выберите модель: для старта — text-embedding-3-small (API) или multilingual-e5-small (локально)
  2. Документы: embed_batch(chunks) → матрица (N, dim)
  3. Запросы: embed_one(query) → вектор (dim,)
  4. Нормализуйте оба: v / np.linalg.norm(v)
  5. Сходство: np.dot(query_vec, doc_vec) → число от 0 до 1
  6. Помните: одна модель для документов и запросов
ВЫБОР МОДЕЛИ:

  Критерий                   Рекомендация
  ─────────────────────────────────────────────────────────
  Прототип / старт быстро    multilingual-e5-small (бесплатно, 384d)
  Production, RU+EN          text-embedding-3-small ($0.02/1M токенов)
  Максимальное качество       text-embedding-3-large ($0.13/1M токенов)
  Только локально, качество   multilingual-e5-large / BGE-M3
  Длинные документы (>512t)   text-embedding-3 или BGE-M3 (8192 токенов)

ТИПИЧНЫЕ ПОРОГИ СХОДСТВА (text-embedding-3-small):

  ≥ 0.90 → почти идентичные тексты (дублированный контент)
  0.75–0.90 → очень близкая тема (top-k для RAG)
  0.50–0.75 → схожая предметная область
  < 0.50 → слабая связь (фильтровать при retrieval)

ПАМЯТЬ (одна коллекция, 100k документов):
  dim=384  → 100k × 384 × 4 байта ≈ 147 МБ
  dim=1536 → 100k × 1536 × 4 байта ≈ 590 МБ
  dim=3072 → 100k × 3072 × 4 байта ≈ 1.2 ГБ
        

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

  1. Негативный эксперимент с отрицанием. Составьте 10 пар предложений, где одно — утверждение, второе — его прямое отрицание. Например: «Redis быстрый» / «Redis медленный», «установка прошла успешно» / «установка завершилась с ошибкой». Вычислите cosine_sim для каждой пары через text-embedding-3-small и multilingual-e5-large. Есть ли разница между моделями? Какая минимальная схожесть получилась?
  2. Поиск дубликатов. Возьмите коллекцию из 50–100 текстовых документов (например, новостные заголовки или статьи вики). Эмбеддируйте все. Для каждого документа найдите 3 ближайших соседа. Отфильтруйте пары с cosine_sim > 0.92 — это кандидаты в дубликаты. Вручную проверьте: действительно ли похожи?
  3. Сравнение моделей. Возьмите одну коллекцию документов (минимум 20 штук разных тем) и 5 поисковых запросов. Проранжируйте результаты тремя моделями: text-embedding-3-small, multilingual-e5-small, multilingual-e5-large. Вручную оцените top-3 для каждого запроса по шкале 0–2 (0 = нерелевантно, 2 = точное попадание). Подсчитайте MAP@3. Какая модель победила?