MAATRIX / Блог / Как считать эмбеддинги на CPU без GPU

Как считать эмбеддинги на CPU без GPU

Как считать эмбеддинги на CPU без GPU

MAATRIX

Векторная база поднята, документы нарезаны на чанки, а на шаге «посчитать векторы» всё встаёт: гайды требуют видеокарту, аренда GPU выходит дороже остального конвейера, индексация на обычном VPS тянется часами. Хорошая новость: эмбеддинги на CPU — полноценный рабочий режим, а не компромисс, потому что модели-энкодеры в сотни раз меньше генеративных. Разберём, где процессора хватает, где он честно не тянет и как получить кратное ускорение без единой строки про CUDA.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →

Почему GPU нужен генерации, но почти не нужен эмбеддингам

Разница не в «мощности», а в характере работы. Генеративная модель выдаёт по одному токену за раз, и на каждый токен приходится полный проход по всем весам: для 7B в квантовании Q4 это ~4,5 ГБ, которые нужно прочитать из памяти сто раз ради ста токенов ответа. Всё упирается в пропускную способность памяти, и здесь видеокарта выигрывает на порядок.

Энкодер устроен иначе: получает текст, делает один проход и отдаёт один вектор — без цикла по токенам вывода. Задача сводится к нескольким крупным матричным умножениям, с чем неплохо справляется и x86 с AVX2/AVX-512.

Отсюда два режима. Запрос пользователя — один короткий текст и один forward pass; на фоне последующего обращения к LLM эта работа теряется, и для поисковой части RAG видеокарта не нужна вообще. Индексация корпуса — пакетная нагрузка на сотни тысяч чанков; здесь CPU медленнее, но задача разовая и хорошо параллелится по ядрам.

Где CPU честно не тянет — называю прямо:

  • корпус в миллионы чанков, который переиндексируется еженедельно;
  • поток запросов в сотни в секунду на модели с 1024 измерениями;
  • документы, которые целиком гонят в bge-m3 с окном 8192: один такой текст стоит как шестнадцать чанков по 512;
  • дообучение энкодера — это тренировка, ей нужен GPU.

Сколько работы вы просите у процессора: арифметика вместо гаданий

Оценивать нагрузку по общему числу параметров — ошибка: процессор тратится только на слои трансформера, а таблица эмбеддингов — это индексация массива, вычислений там почти нет.

У XLM-RoBERTa, на которой построены multilingual-e5-* и bge-m3, словарь 250 002 токена: у multilingual-e5-small таблица занимает 250 002 × 384 ≈ 96 млн весов из 118 млн общих — 82% параметров не участвуют в вычислениях. Модель весит почти полгигабайта, а считает быстро.

Реальная цена прохода — веса слоёв трансформера: 4H² на внимание плюс 8H² на FFN, итого 12H² на слой.

МодельСлоёв × HВеса в слояхЦена токена
all-MiniLM-L6-v26 × 384~10,6 млн×1
multilingual-e5-small12 × 384~21 млн×2
multilingual-e5-base12 × 768~85 млн×8
multilingual-e5-large, bge-m324 × 1024~302 млн×28

Это расчёт по архитектуре, а не замер: реальное соотношение сдвигают кэш, длина текстов и BLAS. Но порядок он даёт верный — переход с small на large стоит примерно четырнадцатикратного роста времени индексации. Второй множитель — длина: внимание растёт квадратично по числу токенов, поэтому чанки по 200–400 токенов дешевле, чем окно, забитое под завязку.

И главное: не верьте чужим таблицам «токенов в секунду», включая мою — цифра зависит от поколения процессора, наличия AVX-512, частоты и соседей по гипервизору. Замерьте свою машину на тысяче чанков и экстраполируйте:

import time
sample = chunks[:1000]
t0 = time.perf_counter()
model.encode(sample, batch_size=32, normalize_embeddings=True)
dt = time.perf_counter() - t0
rate = len(sample) / dt
print(f"{rate:.1f} чанк/с → весь корпус: {len(chunks)/rate/60:.0f} мин")

Пик памяти смотрите тем же прогоном, снаружи: /usr/bin/time -v python3 index.py 2>&1 | grep 'Maximum resident'. Если результат вместе с Qdrant подбирается к общей RAM машины — вы в красной зоне ещё до OOM.

Развернуть за пару минут

Готовый образ на VPS MAATRIX: NVMe, AMD EPYC, root-доступ. Локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Развернуть Qdrant

Модель под CPU: размерность, язык и длина контекста

Размерность вектора выбирается один раз, а платите вы за неё всегда — это и скорость счёта, и объём хранения, и скорость поиска. Веса fp32 — параметры × 4 байта:

МодельПараметровРазмерностьМакс. токеновВеса fp32
all-MiniLM-L6-v222,7 млн384256~90 МБ
rubert-tiny229 млн3122048~120 МБ
multilingual-e5-small118 млн384512~470 МБ
multilingual-e5-base278 млн768512~1,1 ГБ
multilingual-e5-large560 млн1024512~2,2 ГБ
BAAI/bge-m3568 млн10248192~2,3 ГБ

Три правила, которые экономят переиндексацию.

Русский язык. all-MiniLM-L6-v2 часто стоит по умолчанию в туториалах, но обучена в основном на английском и на русских текстах проседает. Минимум для русского корпуса — multilingual-e5-small: тот же размер и скорость, но многоязычный словарь; для короткого чисто русского корпуса rubert-tiny2 дешевле всех.

Префиксы e5. Семейство обучено асимметрично: query: перед запросом, passage: перед документом. Забыли — ошибки не будет, векторы валидны, но качество поиска молча упадёт. У bge-m3 префиксы не нужны; у английских bge-*-en-v1.5 для запроса своя инструкция — Represent this sentence for searching relevant passages: .

Размерность режется не у всех. Модели с Matryoshka Representation Learning (nomic-embed-text-v1.5, Qwen3-Embedding) обрезаются без переобучения — truncate_dim=256 в конструкторе ужимает хранилище вчетверо. С e5 и bge-m3 так нельзя: обрезка ломает геометрию.

Во что размерность обойдётся в самой базе — отдельный расчёт: сколько RAM нужно для Qdrant.

Рантайм и квантование: где берутся кратные ускорения

Самое дешёвое ускорение — не менять модель, а сменить рантайм: PyTorch на CPU универсален, граф не оптимизирован под железо, веса в fp32.

Шаг первый — ONNX Runtime. Экспорт графа и слияние операций дают прирост ещё до квантования:

pip install "optimum[onnxruntime]" sentence-transformers
optimum-cli export onnx --model intfloat/multilingual-e5-base \
  --task feature-extraction e5-base-onnx/

Шаг второй — динамическое int8-квантование. Веса линейных слоёв переводятся в 8 бит, умножения идут по целочисленному пути, файл уменьшается вчетверо:

optimum-cli onnxruntime quantize --avx512_vnni \
  --onnx_model e5-base-onnx/ -o e5-base-int8/
du -sh e5-base-onnx e5-base-int8
1.1G	e5-base-onnx
281M	e5-base-int8

Оговорка обязательная: выигрыш от int8 держится на инструкциях VNNI. Проверьте процессор до того, как начнёте:

lscpu | grep -oE 'avx512(f|bw|_vnni)|avx2' | sort -u
avx2
avx512bw
avx512f
avx512_vnni

Нет avx512_vnni — модель всё равно уменьшится и немного ускорится, но кратного прироста не ждите: путь через AVX2 скромнее, и на таких машинах иногда выгоднее OpenVINO. В sentence-transformers 5.x бэкенд переключается прямо в конструкторе:

from sentence_transformers import SentenceTransformer

model = SentenceTransformer(
    "intfloat/multilingual-e5-base",
    backend="onnx",
    model_kwargs={"file_name": "onnx/model_qint8_avx512_vnni.onnx"},
)
# альтернатива на Intel: backend="openvino"

Что теряется: на MTEB динамическое int8 обычно стоит около процента метрики, заметнее — в хвосте выдачи. Есть тестовый набор вопросов — прогоните до и после; нет — начните с него, а не с квантования.

Третий вариант — fastembed. Библиотека самой Qdrant: ONNX Runtime внутри, модели уже квантованы, GPU не требуется и в базовой сборке не поддерживается.

from fastembed import TextEmbedding

for m in TextEmbedding.list_supported_models():
    print(m["model"], m["dim"], round(m["size_in_GB"], 2))

emb = TextEmbedding(model_name="intfloat/multilingual-e5-large", threads=4)
vectors = list(emb.embed(["passage: первый чанк", "passage: второй чанк"]))

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

Потоки, батчи и память: три места, где всё ломается

Потоки. Библиотеки линейной алгебры создают столько потоков, сколько видят ядер, и процессор оказывается занят переключением контекста, а не счётом. Особенно больно в Docker: с --cpus=2 квота действует, а nproc внутри контейнера показывает ядра хоста.

nproc
16
cat /sys/fs/cgroup/cpu.max
200000 100000
python3 -c "import os; print(os.cpu_count(), len(os.sched_getaffinity(0)))"
16 16

200000 100000 — квота в два ядра, а OpenMP запустит шестнадцать потоков. Лечится явным заданием:

[Service]
Environment=OMP_NUM_THREADS=4
Environment=MKL_NUM_THREADS=4
Environment=TOKENIZERS_PARALLELISM=false
ExecStart=/opt/embed/venv/bin/python /opt/embed/index.py

В коде — torch.set_num_threads(4), sess_options.intra_op_num_threads = 4 для ONNX Runtime, threads=4 в fastembed. Не запускайте четыре процесса по четыре потока на четырёх ядрах: сумма потоков должна совпадать с числом ядер.

Если при импорте видите такое — в окружении две реализации OpenMP:

OMP: Error #15: Initializing libomp.so, but found libgomp.so.1 already initialized.
OMP: Hint This means that multiple copies of the OpenMP runtime have been linked
into the program. That is dangerous, since it can degrade performance or cause
incorrect results.

KMP_DUPLICATE_LIB_OK=TRUE заглушит сообщение, но это заплатка: правильнее собрать окружение из одного источника колёс и не мешать conda с pip.

Батчи. Память считается по активациям, и матрица внимания в ней главная: batch × heads × seq × seq × 4 байта. Для батча 32, шестнадцати голов и длины 512 это 32 × 16 × 512 × 512 × 4 = 512 МиБ на один тензор, а таких по ходу графа живёт несколько:

RuntimeError: [enforce fail at alloc_cpu.cpp:117] data_ptr.
DefaultCPUAllocator: can't allocate memory: you tried to allocate 536870912 bytes.

Или, если ядро успело раньше:

journalctl -k | grep -i 'killed process'
Out of memory: Killed process 12674 (python3) total-vm:9312044kB, anon-rss:3781128kB

Лечение простое: batch_size 8–16 вместо 32, жёсткий model.max_seq_length = 512. Расход растёт линейно по батчу и квадратично по длине — резать длину эффективнее, чем батч.

Длина. Токенизатор предупреждает, но не падает:

Token indices sequence length is longer than the specified maximum sequence length
for this model (1024 > 512). Running this sequence through the model will result in
indexing errors

sentence-transformers в этот момент молча обрежет текст до 512 токенов — вторая половина чанка не попадёт в вектор. Ищите такие строки в логах индексации: это не косметика, а потерянные данные.

Отдельный сервис эмбеддингов рядом с Qdrant

Считать векторы в приложении удобно, пока оно одно. Дальше модель выносят в HTTP-сервис: веса держатся в памяти один раз, а батчи собираются из параллельных запросов сами.

ВариантПортAPIОсобенности
Text Embeddings Inference80 в контейнере/embed, /v1/embeddingsCPU-образ отдельным тегом, динамическая батчировка
Infinity7997/embeddings (OpenAI)несколько моделей в одном процессе
Ollama11434/api/embedесли уже стоит под генерацию
В процессе приложениявеса в памяти каждого воркера

Запуск TEI на процессоре — образ с префиксом cpu-, тег закрепляйте вместо latest:

docker run -d --name tei --restart unless-stopped \
  --cpus=4 -e OMP_NUM_THREADS=4 \
  -p 127.0.0.1:8080:80 -v $PWD/hf-cache:/data -e HF_HOME=/data \
  ghcr.io/huggingface/text-embeddings-inference:cpu-1.8 \
  --model-id intfloat/multilingual-e5-base --max-client-batch-size 64

curl -s 127.0.0.1:8080/embed -H 'Content-Type: application/json' \
  -d '{"inputs":["passage: тестовый фрагмент"]}' | jq '.[0] | length'
768

Первый запуск качает веса с huggingface.co. Если сервер наружу не ходит, вы увидите не «сервис не работает», а вот это:

OSError: We couldn't connect to 'https://huggingface.co' to load the files, and
couldn't find them in the cached files. Check your internet connection or see how
to run the library in offline mode at 'https://huggingface.co/docs/transformers/installation#offline-mode'.

Тогда веса кладут заранее и поднимают с HF_HUB_OFFLINE=1, вынося hf-cache на volume — второй запуск будет мгновенным.

Со стороны Qdrant — три вещи. Размерность коллекции жёсткая: другая длина вектора даст Wrong input: Vector inserting error: expected dim: 768, got 1024. При distance: Cosine Qdrant нормализует векторы сам, при Dot — нет, тогда normalize_embeddings=True нужен на стороне энкодера. На массовой заливке выключайте индекс заранее — иначе HNSW пересобирается параллельно вставке и забирает ядра у эмбеддера.

curl -sX PATCH 'http://127.0.0.1:6333/collections/docs' -H 'api-key: ***' \
  -H 'Content-Type: application/json' \
  -d '{"optimizers_config": {"indexing_threshold": 0}}'
# ... заливаете точки батчами по 128-256 с wait=false ...
curl -sX PATCH 'http://127.0.0.1:6333/collections/docs' -H 'api-key: ***' \
  -H 'Content-Type: application/json' \
  -d '{"optimizers_config": {"indexing_threshold": 20000}}'

Если Qdrant ещё не поднят — по шагам он разобран в установке и настройке Qdrant на VPS.

Какой сервер под эмбеддинги на CPU брать в MAATRIX

Расход — это веса модели, активации на батч и всё остальное: Qdrant, приложение, ОС, порядка гигабайта на небольшой коллекции.

Честный минимум: 2 vCPU, 4 ГБ RAM, 40–60 ГБ NVMe. Хватает на модель до 384 измерений в int8 (multilingual-e5-small после квантования — ~120 МБ), Qdrant рядом и корпус в сотню тысяч чанков. Ограничение прямое: multilingual-e5-large в fp32 сюда с базой не поместится — 2,2 ГБ весов плюс активации плюс Qdrant съедят запас, и первый крупный батч упадёт с DefaultCPUAllocator: can't allocate memory — индексация здесь ночная работа, а не фоновая.

Комфортный вариант: 4 vCPU, 8–16 ГБ RAM, 80–160 ГБ NVMe. Тут живут multilingual-e5-large или bge-m3 в int8, TEI на четырёх потоках, Qdrant с миллионом векторов и запас на переиндексацию без остановки поиска. Четыре ядра — ещё близкая к линейной точка роста; дальше растите память под коллекцию, а не ядра. Переиндексация регулярно дольше половины суток по расчёту из второй секции — сигнал брать GPU под неё, оставив поиск на CPU.

Локация — Великобритания (Лондон). Схема начинается со скачивания весов с huggingface.co: с британской площадки они идут напрямую на полной скорости, а из России — непредсказуемо, иногда архивы возят руками. Если в конвейере есть облачные эндпоинты эмбеддингов, с британского адреса они отвечают штатно, а с российского можно получить 403 unsupported_country_region_territory. Плюс пинг до ЕС в десятки миллисекунд, а RAG обращается в базу не один раз за ответ. Франция подходит по тем же причинам; США — когда генерация завязана на OpenAI или Anthropic; Россия — при персональных данных под 152-ФЗ.

Qdrant выбирается в каталоге apps.maatrix.io при заказе сервера и разворачивается автоматически — ставить руками ничего не нужно, работает на Ubuntu и Debian. Адрес панели и ключи появятся в личном кабинете, в разделе «Доступ»: останется поднять сервис эмбеддингов и создать коллекцию под свою размерность. Оплата — картой российского банка, по СБП, криптовалютой или токеном MAAT. С нуля конвейер по шагам разобран в статье про RAG по своим документам.

Развернуть за пару минут

Готовый образ на VPS MAATRIX: NVMe, AMD EPYC, root-доступ. Локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Развернуть Qdrant

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →

Частые вопросы

Сколько ядер реально даёт прирост?

Масштабирование сублинейное: до четырёх-восьми потоков прирост близок к пропорциональному, дальше упирается в пропускную способность памяти. Прогоните скрипт с OMP_NUM_THREADS=1, 2, 4, 8 и возьмите точку, после которой время перестало падать — платить за ядра сверх неё смысла нет.

Надо ли переиндексировать корпус после перехода на int8?

Смена самой модели — всегда полная переиндексация, размерности и пространства несовместимы. Квантование той же модели даёт близкие, но не идентичные векторы: смешивать fp32-документы с int8-запросами можно, но проверьте на своём тестовом наборе — поплыл хвост выдачи, переиндексируйте.

Можно ли считать эмбеддинги на CPU, а генерацию отдать облачному API?

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

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.