Перейти к содержимому

4.1 · Эмбеддинги руками на numpy

Неделя 4 · RAG · ⏱ ~4 ч · Теория, Практика

С этой главы начинается второй проект — ассистент поддержки, который отвечает покупателям «Полки» по базе знаний магазина. Чтобы найти в базе нужный кусок, мало искать по словам: «можно сдать вскрытый чай?» и «вскрытая упаковка не принимается» почти не совпадают по словам, но говорят об одном. Такой поиск «по смыслу» строится на эмбеддингах.

Эмбеддинги лежат под всем, что ищет «по смыслу»: RAG (retrieval-augmented generation — модель отвечает по найденным кускам документов; вся схема — в следующей главе), семантический поиск, дедупликация, кластеризация обращений в поддержку, рекомендации. На собеседовании почти гарантированно спросят «что такое эмбеддинг», «почему косинус, а не евклидово расстояние» и «как выбрать модель эмбеддингов». Если один раз посчитать сходство руками на numpy, векторная БД перестаёт быть магией: это просто быстрый способ сделать то же самое на миллионе строк.

Эмбеддинг — это массив чисел фиксированной длины (например, 384, 768, 1536 или 3072 чисел), который модель выдаёт для куска текста. Модель обучена так, чтобы тексты с похожим смыслом получали близкие векторы, а с разным — далёкие. «Оформить возврат товара» и «return an item for a refund» окажутся рядом, хотя общих слов у них нет. «Cash on delivery» и «наложенный платёж» — тоже рядом, если модель мультиязычная.

Аналогия из магазина: представим, что у каждого товара есть «профиль» из сотни метрик (цена, вес, сезонность, кто и вместе с чем его покупает…). Два товара с похожими профилями продаются похоже. Эмбеддинг — такой же профиль, только для смысла текста, и метрики не названы: модель сама их выучила, отдельные координаты не интерпретируются.

Важные свойства:

  • Длина вектора фиксирована для модели и не зависит от длины текста. Фраза из трёх слов и абзац дают векторы одной размерности.
  • Векторы разных моделей несравнимы. Нельзя положить в индекс векторы от модели A и искать по ним вектором от модели B — даже при одинаковой размерности. Сменили модель — переиндексируем всё.
  • У модели есть лимит входа (в токенах). Текст длиннее лимита обрезается или вызывает ошибку — это одна из причин, зачем нужен chunking — нарезка документов на куски (гл. 4.3).
  • Эмбеддинг сжимает. Длинный текст про пять тем превращается в «среднее» по этим темам и плохо находится по каждой из них. Отсюда правило: чанк — про одну мысль.

Немного линейной алгебры (ровно столько, сколько нужно)

Заголовок раздела «Немного линейной алгебры (ровно столько, сколько нужно)»

«Близкие векторы» нужно уметь измерить числом: тогда поиск сводится к тому, чтобы посчитать близость вопроса к каждому тексту и взять лучшие. Для этого хватает трёх формул.

Вектор a = [a1, a2, ..., an].

Скалярное произведение (dot product): a·b = a1*b1 + a2*b2 + ... + an*bn. Большое положительное — векторы «смотрят» в одну сторону.

Норма (длина): ||a|| = sqrt(a·a).

Косинусное сходство: cos(a, b) = (a·b) / (||a|| * ||b||). Это косинус угла между векторами, от −1 до 1. Оно не зависит от длины векторов, только от направления. На практике у текстовых эмбеддингов значения обычно лежат в узком диапазоне (например, 0.2–0.9), и абсолютное число мало что значит: важен порядок (кто ближе), а не «0.8 — это хорошо».

Почему нормализованные векторы удобны. Если заранее поделить каждый вектор на его норму (длина станет 1), то cos(a, b) = a·b. Косинус превращается в одно скалярное произведение, а поиск по всей базе — в одно матричное умножение:

scores = M @ q # M: (N, d) matrix of normalized docs, q: (d,) normalized query

Многие API уже отдают нормализованные векторы (OpenAI пишет об этом в гайде по эмбеддингам), а в Sentence Transformers есть флаг normalize_embeddings=True. Всё равно стоит проверить: np.linalg.norm(v) должно быть ≈ 1.0.

Ещё одно следствие: для нормализованных векторов евклидово расстояние и косинус дают одинаковый порядок (||a−b||² = 2 − 2·cos). Поэтому выбор метрики в векторной БД для нормализованных векторов почти ничего не меняет — но метрика индекса должна совпадать с той, под которую обучена модель (см. карточку модели).

Считать эти формулы циклом по каждому вектору можно, но медленно. В Python для такой арифметики есть numpy. numpy — это типизированные массивы (как Float32Array в JavaScript или float[] в Java), над которыми операции выполняются целиком, без циклов в Python. Цикл for по 100 000 векторам в Python медленный; M @ q — быстрый, потому что работает на C.

import numpy as np
a = np.array([1.0, 2.0, 3.0], dtype=np.float32)
M = np.random.rand(5, 3).astype(np.float32) # 5 vectors of dim 3
M.shape # (5, 3) — rows x columns
a @ a # dot product -> scalar
np.linalg.norm(a) # vector length
M @ a # (5,) — dot of every row with a
norms = np.linalg.norm(M, axis=1, keepdims=True) # (5, 1)
M_normed = M / norms # broadcasting: each row divided by its own norm
scores = M_normed @ (a / np.linalg.norm(a)) # cosine of every row with a
np.argsort(-scores)[:5] # indices of top-5 largest scores

Ключевые понятия: shape (размерности), axis (вдоль какой оси считать), broadcasting (автоматическое «растягивание» массивов разной формы при арифметике), argsort (индексы сортировки). Если формы запутались — печатаем .shape после каждой строки, как отладочный вывод типа в привычном языке.

Математика и инструмент есть; осталось получить сами векторы. Их выдаёт модель эмбеддингов, и запустить её можно двумя способами.

1. Через API (OpenAI, Voyage, Cohere, Google и др.). Плюсы: высокое качество, ничего не ставить. Минусы: платно за токены, данные уходят наружу, сеть, rate limits. Иллюстративный вызов в стиле официального SDK:

# Illustrative: check model names and params in the current OpenAI docs
from openai import OpenAI
client = OpenAI() # reads OPENAI_API_KEY from the environment
resp = client.embeddings.create(
model="text-embedding-3-small",
input=["можно ли вернуть вскрытый чай", "warranty for the Light desk lamp"], # batch of texts
)
vectors = [item.embedding for item in resp.data]

Отправляем батчами (список строк в одном запросе), а не по одной строке — быстрее и меньше шансов упереться в rate limit. У моделей text-embedding-3-* есть параметр dimensions, который укорачивает вектор: меньше места, чуть ниже качество. У Anthropic своей модели эмбеддингов нет — в их документации рекомендован партнёр Voyage AI; это нормально, генерация и эмбеддинги часто от разных провайдеров.

2. Локально через Sentence Transformers. Модель скачивается с Hugging Face один раз и работает на нашей машине (CPU, видеокарта NVIDIA через cuda или Apple Silicon через mps). Бесплатно, офлайн, данные никуда не уходят.

# Illustrative: pick a concrete model from sbert.net / MTEB
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
vecs = model.encode(texts, normalize_embeddings=True, batch_size=32) # numpy array (N, d)

Нюанс: некоторые модели (семейства E5, BGE и др.) требуют префиксов — например, "query: ..." для запроса и "passage: ..." для документа. Без них качество заметно падает. Карточку модели на Hugging Face читаем всегда.

Моделей эмбеддингов десятки, и для обоих способов встаёт вопрос, какую взять. Общий ориентир — публичный бенчмарк.

MTEB (Massive Text Embedding Benchmark) — рейтинг моделей по десяткам задач: retrieval, классификация, кластеризация, на разных языках. Как читать:

Что смотреть Почему
Колонка Retrieval, а не общий средний балл Нам нужен поиск, а не классификация
Язык (multilingual / Russian) Покупатели спрашивают по-русски, а инструкции производителей к товарам часто на английском — нужна кросс-язычность
Размер модели и размерность 7B-модель не поедет на ноутбуке быстро; 3072 измерения = больше места
Max tokens Сколько текста модель «видит»
Лицензия Для портфолио неважно, для продукта важно

Лидерборд — ориентир, не истина: модели могут быть «подогнаны» под бенчмарк. Финальное решение — только собственный мини-eval на своих данных (его мы и сделаем в практике).

Соберём всё вместе: модель превращает тексты и вопрос в векторы, нормализация сводит косинус к скалярному произведению, numpy считает его для всех текстов разом.

тексты ──embed──► матрица M (N×d), нормализована
вопрос ──embed──► вектор q (d), нормализован
scores = M @ q ──► argsort ──► топ-k текстов

Это и есть «точный поиск» (brute force, flat index). Для 100 или даже 100 000 векторов он работает за миллисекунды. Векторные БД (гл. 4.4) нужны для хранения, метаданных, фильтров, обновлений и приближённого поиска (ANN: находит почти ближайших соседей, не сравнивая запрос со всеми векторами) на миллионах.

Это та глава, где разница между провайдерами важнее всего: у DeepSeek нет эндпоинта эмбеддингов. На ноябрь 2026 (проверено 13.11.2026 по API Reference DeepSeek) в разделе API Reference есть Chat Completions, Responses, Messages (Anthropic-формат), FIM Completion, список моделей, баланс, файлы и встроенные инструменты (поиск, чтение URL, OCR, речь), а эмбеддингов нет. Значит, OpenAI(base_url="https://api.deepseek.com").embeddings.create(...) работать не будет — генерацию берём у DeepSeek, а эмбеддинги делаем локально. Это обычная ситуация: генерация и эмбеддинги часто от разных поставщиков (у Anthropic, как мы уже видели, своей модели эмбеддингов тоже нет).

Основной путь в этой книге — два локальных варианта, и оба бесплатные и приватные:

  1. Ollama — локальный сервер моделей на http://localhost:11434. Эмбеддинги отдаёт нативный эндпоинт POST /api/embed и OpenAI-совместимый POST /v1/embeddings.
  2. Sentence Transformers — модель грузится прямо в наш Python-процесс (то, что уже описано выше в «Теории»).
  • Ollama ставим только с официального сайта ollama.com/download (там же инструкции для Linux и Windows). По документации для macOS нужна macOS 14 Sonoma или новее; на Apple Silicon работают CPU и GPU, модели лежат в ~/.ollama и занимают гигабайты. На macOS перед первым запуском приложения проверяем подпись: codesign -dv /Applications/Ollama.app (кто подписал) и spctl -a -vv /Applications/Ollama.app (нотаризация Apple).
  • Модели берём из официальной библиотеки (ollama pull <имя>), а модели Sentence Transformers — только из официальных организаций на Hugging Face (BAAI/..., sentence-transformers/..., intfloat/... и т. п.).
  • Версии библиотек фиксируем. На момент проверки на PyPI были ollama==0.6.3 (релиз 29.09.2026) и sentence-transformers==6.3.0. Ставим конкретную версию, а не «последнюю», и не берём релиз в день его выхода.

На странице embedding-моделей Ollama есть, среди прочих, bge-m3, embeddinggemma, qwen3-embedding, nomic-embed-text, mxbai-embed-large, snowflake-arctic-embed2, paraphrase-multilingual. Для русских вопросов к английским текстам (инструкции производителей) нужна мультиязычная модель. Разумный старт — bge-m3:

bge-m3
Где ollama.com/library/bge-m3 (скачивание ~1.2 GB) и huggingface.co/BAAI/bge-m3
Размерность 1024 (по карточке на Hugging Face)
Лимит входа 8192 токена
Языки 100+
Префиксы query:/passage: Не нужны (карточка прямо пишет, что инструкции к запросам больше не требуются)
Лицензия MIT

Альтернативы для сравнения: embeddinggemma (300M параметров, скачивание ~622 MB, 100+ языков, но окно всего 2K токенов, нужна Ollama ≥ 0.11.10) и qwen3-embedding:0.6b (~639 MB, окно 32K, размерность можно задавать). Перед использованием читаем карточку модели: префиксы и инструкции у разных семейств разные.

# Illustrative: pip install ollama==0.6.3 ; ollama pull bge-m3
import numpy as np
import ollama
texts = ["можно ли вернуть вскрытый чай", "warranty for the Light desk lamp"]
res = ollama.embed(model="bge-m3", input=texts) # a list -> one vector per text
M = np.array(res["embeddings"], dtype=np.float32) # shape (2, 1024)
print(M.shape, np.linalg.norm(M, axis=1)) # norms should be ~1.0

Что важно знать про /api/embed (по справке API):

  • input принимает строку или список строк — батч одним запросом.
  • Векторы приходят уже L2-нормализованными (длина каждого — 1) (страница про эмбеддинги). Нормы всё равно проверяем.
  • truncate по умолчанию true: текст длиннее окна модели молча обрезается. С truncate=False вместо тихой обрезки получим ошибку — для отладки chunking (глава 4.3) это то, что нужно.
  • dimensions — укоротить вектор (если модель это поддерживает), keep_alive — сколько держать модель в памяти между запросами.
  • В ответе есть prompt_eval_count (сколько токенов обработано) и load_duration (сколько ушло на загрузку модели — первый вызов медленный).

Тот же клиент, что для OpenAI, только base_url другой. Удобно, когда провайдера хочется менять одной переменной окружения.

# Illustrative: Ollama's OpenAI-compatible endpoint; the api_key is required by the SDK but ignored
from openai import OpenAI
local = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
resp = local.embeddings.create(model="bge-m3", input=texts)
vectors = [item.embedding for item in resp.data]

По странице OpenAI-совместимости /v1/embeddings поддерживает model, input (строки и списки строк, но не массивы токенов), encoding_format и dimensions; поле user не поддерживается.

# Illustrative: model card https://huggingface.co/BAAI/bge-m3
from sentence_transformers import SentenceTransformer
st = SentenceTransformer("BAAI/bge-m3") # downloads once from Hugging Face
V = st.encode(texts, normalize_embeddings=True) # numpy (N, 1024)

Одна и та же «bge-m3» в Ollama и в Sentence Transformers — это разные сборки и разные рантаймы (у Ollama модель упакована в свой формат). Не считаем их векторы взаимозаменяемыми, пока не проверим: считаем косинус между векторами одной фразы из обоих бэкендов. Правило «один индекс — одна модель» распространяется и на бэкенд: храним в метаданных не только имя модели, но и откуда она (ollama:bge-m3 или st:BAAI/bge-m3).

Возможность Claude (Anthropic) OpenAI DeepSeek Ollama (локально)
Эндпоинт эмбеддингов Нет; в документации рекомендован партнёр Voyage AI Есть, /v1/embeddings, модели text-embedding-3-* Нет (на 11.2026) Есть: /api/embed и /v1/embeddings
Укоротить вектор — dimensions — dimensions (зависит от модели)
Нормализация — Векторы нормализованы (по гайду OpenAI) — /api/embed отдаёт L2-нормализованные
Стоимость Отдельный счёт у Voyage За токены — Бесплатно, считает наша машина
Куда уходят тексты В облако Voyage В облако OpenAI — Никуда, всё локально
Обходной путь Ollama / Sentence Transformers — Ollama / Sentence Transformers —

Для полноты: OpenRouter (глава 2.4 · OpenRouter: один API ко многим моделям) тоже даёт эмбеддинги через POST https://openrouter.ai/api/v1/embeddings (в документации примеры openai/text-embedding-3-small и другие) — это вариант «облачные эмбеддинги без отдельного аккаунта OpenAI». Бесплатные модели там могут обучаться на входных данных, поэтому годятся только для выдуманных или публичных текстов.

  • Основной путь: Ollama (bge-m3) и Sentence Transformers — два локальных бэкенда, которые мы сравниваем между собой. Ключ API не нужен вообще.
  • Для сравнения (по желанию): OpenAI text-embedding-3-small, если ключ уже есть, — увидим, сколько даёт платная облачная модель на наших фразах.
  • Память. Эмбеддинг-модели маленькие, и даже на машине с 8 GB RAM проблем с ними нет: bge-m3 ~1.2 GB, embeddinggemma ~622 MB, qwen3-embedding:0.6b ~639 MB (размеры скачивания по библиотеке Ollama; в памяти примерно столько же плюс рабочий буфер). Но в сравнении бэкендов одна модель живёт в Ollama, а другая в нашем Python-процессе (Sentence Transformers), и вместе они занимают вдвое больше. Прогоняем бэкенды по очереди, а после прогона выгружаем модель Ollama: ollama ps показывает загруженные модели, ollama stop <модель> выгружает. Крупные эмбеддинг-модели (qwen3-embedding:4b/8b) при 8 GB RAM не берём; на 16 GB и больше их уже можно попробовать.
  • DeepSeek в этой главе не участвует — эмбеддингов у него нет. Он появится как генератор ответа в главах 5.1 и 5.3.
  • Безопасность: фразы в этой главе выдуманные, а база знаний магазина публичная. Когда дело дойдёт до данных покупателей (сообщения в поддержку, имена, телефоны, номера заказов, база магазина), эмбеддинги и генерация для них — только Ollama, локально: эти тексты не уходят ни в DeepSeek (серверы в Китае), ни в другие облака.
  1. Открываем NumPy quickstart → разделы «The Basics», «Basic Operations», «Indexing, Slicing and Iterating», «Broadcasting rules» (≈40 мин). Обращаем внимание на shape, axis и на то, что * — поэлементное умножение, а @ — матричное. Набираем примеры руками в REPL.
  2. OpenAI: Embeddings guide → читаем целиком (≈25 мин). Выносим: какие модели есть сейчас, их размерность и цена (меняются — смотрим на странице), параметр dimensions, замечание о нормализации векторов, раздел FAQ про выбор метрики.
  3. Sentence Transformers → Quickstart и раздел про Semantic Search; бегло — список Pretrained Models (≈30 мин). Выносим: model.encode(...), normalize_embeddings, утилиту util.cos_sim, разницу между bi-encoder (эмбеддинги) и cross-encoder (понадобится в гл. 5.2).
  4. MTEB Leaderboard → фильтруем по задаче Retrieval и мультиязычности (≈20 мин). Выписываем 2–3 кандидата небольшого размера (до ~600M параметров), у которых есть карточка с инструкцией по префиксам. Открываем карточку выбранной модели и читаем, как её правильно вызывать.
  5. Повторяем линейную алгебру из раздела «Теория» на бумаге: считаем косинус для [1,0] и [1,1] руками (≈10 мин). Ответ: ≈0.707.
  6. Ollama: Embeddings и справка /api/embed (≈20 мин). Выносим: батч через список в input, нормализацию, truncate (по умолчанию true — тихая обрезка), dimensions, keep_alive, поля ответа prompt_eval_count и load_duration.
  7. Ollama: OpenAI compatibility → раздел /v1/embeddings (≈10 мин). Выносим, какие поля поддерживаются, — чтобы один и тот же код на OpenAI SDK ходил и в OpenAI, и в Ollama.
  8. Карточки ollama.com/library/bge-m3 и huggingface.co/BAAI/bge-m3 (≈15 мин). Выносим размерность, лимит входа, нужны ли префиксы, лицензию. Сравниваем с 1–2 кандидатами из своего списка MTEB.
  9. API Reference DeepSeek → пробегаемся по списку эндпоинтов в боковом меню (≈5 мин). Убеждаемся сами, что эндпоинта эмбеддингов нет (на ноябрь 2026), — и запоминаем, как такие вещи проверять: по списку эндпоинтов в официальной справке, а не по чужим статьям.
  • Смешивание моделей. Индекс построен одной моделью, запрос эмбеддится другой — поиск выдаёт мусор без всяких ошибок. Имя модели храним рядом с векторами.
  • Забытые префиксы у E5/BGE-подобных моделей — тихая потеря качества.
  • Ненормализованные векторы + dot product = поиск, который предпочитает «длинные» векторы, а не похожие. Нормализуем сами и проверяем нормы.
  • Абсолютные пороги сходства («если cos > 0.8, то релевантно») переносятся между моделями плохо. Порог подбираем на своих данных или используем top-k.
  • float64 vs float32. numpy по умолчанию создаёт float64 — вдвое больше памяти. Для эмбеддингов хватает float32.
  • Стоимость и лимиты API. Не эмбеддим одно и то же дважды: кэшируем результат на диск (np.save), ключ — хэш текста + имя модели.
  • Ключи. OPENAI_API_KEY — только из окружения или .env, а .env — в .gitignore.
  • Короткие фразы против длинных документов. Эмбеддинг вопроса покупателя и эмбеддинг абзаца из базы знаний живут в немного разных «регионах». Это нормально и отчасти решается в гл. 5.1 (HyDE).

Проект для недель 4–5 — папка projects/02-support-kb-rag/: ассистент поддержки, который отвечает покупателям по базе знаний магазина. Самой базы ещё нет (её страницы появятся в гл. 4.3), поэтому здесь тренируемся на ста коротких фразах в projects/02-support-kb-rag/playground/: получаем эмбеддинги двумя локальными бэкендами, пишем поиск на numpy и сравниваем модели.

  • Окружение. uv init (uv из главы 1.3), через uv add ставим (версии фиксирует uv.lock) numpy, ollama, sentence-transformers, python-dotenv, (опционально) openai, scikit-learn, matplotlib. Сразу добавляем .env и .venv/ в .gitignore. Если делаем шаг «для сравнения» с OpenAI — ключ OPENAI_API_KEY только в .env.
  • Ollama. Ставим приложение с ollama.com (на macOS — проверка codesign -dv и spctl -a -vv до первого запуска), затем ollama pull bge-m3. Проверка: ollama list показывает модель, а python -c "import ollama; print(len(ollama.embed(model='bge-m3', input='test')['embeddings'][0]))" печатает размерность.
  • Данные. Файл phrases.txt: 100 фраз о магазине, по одной на строку, смесь русского и английского — вопросы покупателей и строки из базы знаний и инструкций к товарам. Группы по смыслу (по 8–12 фраз): доставка, возврат, гарантия, оплата, промокоды, статус заказа, уход за товаром и инструкции, шум (отзывы ни о чём, приветствия). Пример: «сколько идёт посылка в Казань», «opened tea and coffee cannot be returned», «где мой заказ, трек не обновляется», «do not use the lamp without the shade». Это выдуманные фразы — никаких реальных сообщений покупателей.
  • Эмбеддинги через Ollama (основной путь). embed_ollama.py: читает фразы, эмбеддит одним-двумя батчами через ollama.embed(model="bge-m3", input=[...]), сохраняет emb_ollama.npy и meta.json (бэкенд, модель, размерность, дата). Повторный запуск не пересчитывает, если файл есть. load_duration первого вызова выводится отдельно от остального времени.
  • Эмбеддинги через Sentence Transformers. embed_local.py: то же с другой мультиязычной моделью из нашего списка MTEB (или с BAAI/bge-m3, чтобы сравнить бэкенды), normalize_embeddings=True → emb_local.npy. Префиксы — если модель их требует.
  • (Для сравнения, по желанию) Эмбеддинги через облачный API. embed_api.py: OpenAI text-embedding-3-small → emb_api.npy. Повторный запуск не ходит в API, если файл есть.
  • Проверка DeepSeek. Одной строкой в compare.md записываем, что нашли в API Reference DeepSeek про эмбеддинги и почему этот провайдер в таблице сравнения отсутствует. Ключ DeepSeek для этого не нужен — запросы не делаем, хватит документации.
  • Поиск руками. search.py, ≤ 50 строк, без векторной БД и без util.cos_sim:
import numpy as np
def normalize(M: np.ndarray) -> np.ndarray:
# TODO: divide each row by its L2 norm (use axis=1, keepdims=True)
...
def top_k(M: np.ndarray, q: np.ndarray, k: int = 5) -> list[tuple[int, float]]:
# TODO: scores = M @ q, return k (index, score) pairs sorted by score desc
...

Проверяем, что np.allclose(np.linalg.norm(normalize(M), axis=1), 1.0). Запуск: python search.py --backend ollama "можно ли вернуть вскрытый чай" печатает 5 фраз со скорами (--backend local — Sentence Transformers, --backend api — облачная модель, если её подключали). Запрос эмбеддим тем же бэкендом и моделью, что и фразы.

  • Сравнение моделей. 10 запросов, для каждого заранее записываем, какие фразы «правильные». Таблица в compare.md: запрос → сколько правильных в топ-5 у Ollama-модели, у Sentence Transformers и (если подключали) у облачной API-модели, плюс время эмбеддинга 100 фраз (у Ollama — отдельно с загрузкой модели и без). Обязательно включаем 2–3 запроса на русском к английским фразам (вопрос покупателя к инструкции производителя) и наоборот.
  • (Опционально) 2D-картинка. PCA из sklearn.decomposition до 2 измерений, scatter, цвет = группа. Ожидаемо: группы образуют облака, но с перекрытием — PCA теряет много информации, это иллюстрация, а не метрика.

Ожидаемый результат: все модели находят «очевидных» соседей; различия проявятся на кросс-язычных запросах и на жаргоне («наложка», «ПВЗ», «трек»).

  • search.py на ≤ 50 строк ищет по смыслу через numpy, без векторной БД.
  • Есть emb_ollama.npy, emb_local.npy (и, по желанию, emb_api.npy) и таблица сравнения на 10 запросах с выводом в 2–3 предложения: какую модель и какой бэкенд берём для проекта и почему.
  • Можем объяснить, почему эмбеддинги в этом проекте делаются локально, а не у DeepSeek.
  • Можем объяснить, почему для нормализованных векторов косинус равен скалярному произведению.
  • Ключ не попал в git (git status не показывает .env).

Вопрос 1

Что такое эмбеддинг одним предложением?

Вектор фиксированной длины, который модель выдаёт для текста так, что похожие по смыслу тексты получают близкие векторы.

Вопрос 2

Даны векторы a = [3, 4] и b = [6, 8]. Чему равно косинусное сходство?

  • a) 0.5
  • b) 1.0
  • c) 0
  • d) 10

b) 1.0. b = 2a, векторы смотрят в одну сторону; косинус не зависит от длины.

Вопрос 3

Почему для нормализованных векторов поиск сводится к M @ q?

Потому что cos(a,b) = a·b / (||a||·||b||), а при единичных нормах знаменатель равен 1; умножение матрицы на вектор считает скалярное произведение со всеми строками сразу.

Вопрос 4

Можно ли искать вектором от модели text-embedding-3-small по индексу, построенному моделью из Sentence Transformers той же размерности?

  • a) да, если размерность совпадает
  • b) да, после нормализации
  • c) нет
  • d) только косинусом

c) нет. Пространства разных моделей не связаны: совпадение размерности случайно, близость в них ничего не значит.

Вопрос 5

В каком случае евклидово расстояние и косинус дают одинаковый порядок результатов?

Когда векторы нормализованы: ||a−b||² = 2 − 2·cos(a,b), то есть расстояние монотонно убывает с ростом косинуса.

Вопрос 6

Что в MTEB важнее всего смотреть для задачи RAG по базе знаний магазина и почему?

Метрики Retrieval и поддержку нужных языков (русский вопрос покупателя → английская инструкция производителя), плюс размер модели и лимит токенов. Общий средний балл смешивает задачи, которые здесь не нужны.

Вопрос 7

Что не так в этом коде?

M = np.load("emb.npy") # raw vectors from a local model
q = model.encode("return policy")
best = np.argsort(M @ q)[:5]

Три ошибки: векторы не нормализованы (dot product ≠ косинус); argsort сортирует по возрастанию, поэтому [:5] — пять худших, нужно np.argsort(-scores)[:5]; не проверено, нужны ли модели префиксы для запроса.

Вопрос 8

Зачем некоторым моделям префиксы query: / passage:?

Модель обучалась с разными префиксами для вопросов и документов, чтобы лучше сопоставлять короткие запросы с длинными пассажами; без префиксов вход не похож на обучающие данные и качество падает.

Вопрос 9

Модель эмбеддингов сменили. Что нужно сделать с уже построенным индексом?

Переэмбеддить все документы новой моделью и перестроить индекс; хранить имя модели рядом с векторами, чтобы такие несовпадения ловились.

Вопрос 10

Почему длинный текст про пять разных тем плохо находится по каждой из них?

Эмбеддинг — одно «сжатое» представление всего текста, он усредняет темы, и вектор оказывается между ними, не близко ни к одной.

Вопрос 11

Какой тип данных numpy разумно использовать для хранения эмбеддингов и почему?

  • a) int8 всегда
  • b) float64
  • c) float32
  • d) object

c) float32: вдвое меньше памяти, чем float64, без заметной потери качества поиска; int8 — это уже квантизация, отдельная оптимизация с потерями.

Вопрос 12

В коде написано OpenAI(base_url="https://api.deepseek.com", api_key=...).embeddings.create(model=..., input=texts). Что произойдёт и как правильно получить эмбеддинги в стеке «DeepSeek + Ollama»?

Ошибка: у DeepSeek нет эндпоинта эмбеддингов (на ноябрь 2026 его нет в API Reference). DeepSeek используется для генерации, а эмбеддинги делаются локально — ollama.embed(...), /v1/embeddings у Ollama или Sentence Transformers.

Вопрос 13

Что делает Ollama с текстом длиннее окна модели при вызове /api/embed по умолчанию, и как превратить это в явную ошибку?

  • a) возвращает ошибку
  • b) молча обрезает; truncate=False
  • c) делит на чанки и усредняет
  • d) переключается на модель с большим окном

b). По умолчанию truncate=true, и лишнее молча отрезается — кусок текста просто не участвует в поиске. С truncate=False слишком длинный вход вызывает ошибку, и о проблеме chunking мы узнаём сразу.

Вопрос 14

Индекс построен через Sentence Transformers (BAAI/bge-m3), а запросы решили эмбеддить через Ollama (bge-m3) — «модель же та же». Что нужно сделать перед этим?

Проверить, что векторы совпадают (косинус между векторами одних и тех же фраз из обоих бэкендов близок к 1), а надёжнее — эмбеддить индекс и запросы одним бэкендом. Сборки и рантаймы разные, поэтому в метаданных хранят и модель, и бэкенд.

В 4.2 · Как устроен RAG: обзор архитектуры увидим, куда встраивается этот поиск в полной цепочке RAG, а в 4.3 · Chunking — как нарезать базу знаний магазина, чтобы эмбеддинги работали.