Как считать эмбеддинги на CPU без GPU
Векторная база поднята, документы нарезаны на чанки, а на шаге «посчитать векторы» всё встаёт: гайды требуют видеокарту, аренда GPU выходит дороже остального конвейера, индексация на обычном VPS тянется часами. Хорошая новость: эмбеддинги на CPU — полноценный рабочий режим, а не компромисс, потому что модели-энкодеры в сотни раз меньше генеративных. Разберём, где процессора хватает, где он честно не тянет и как получить кратное ускорение без единой строки про CUDA.
Содержание
- Почему GPU нужен генерации, но почти не нужен эмбеддингам
- Сколько работы вы просите у процессора: арифметика вместо гаданий
- Модель под CPU: размерность, язык и длина контекста
- Рантайм и квантование: где берутся кратные ускорения
- Потоки, батчи и память: три места, где всё ломается
- Отдельный сервис эмбеддингов рядом с Qdrant
- Какой сервер под эмбеддинги на CPU брать в MAATRIX
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество 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-v2 | 6 × 384 | ~10,6 млн | ×1 |
| multilingual-e5-small | 12 × 384 | ~21 млн | ×2 |
| multilingual-e5-base | 12 × 768 | ~85 млн | ×8 |
| multilingual-e5-large, bge-m3 | 24 × 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-v2 | 22,7 млн | 384 | 256 | ~90 МБ |
| rubert-tiny2 | 29 млн | 312 | 2048 | ~120 МБ |
| multilingual-e5-small | 118 млн | 384 | 512 | ~470 МБ |
| multilingual-e5-base | 278 млн | 768 | 512 | ~1,1 ГБ |
| multilingual-e5-large | 560 млн | 1024 | 512 | ~2,2 ГБ |
| BAAI/bge-m3 | 568 млн | 1024 | 8192 | ~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 Inference | 80 в контейнере | /embed, /v1/embeddings | CPU-образ отдельным тегом, динамическая батчировка |
| Infinity | 7997 | /embeddings (OpenAI) | несколько моделей в одном процессе |
| Ollama | 11434 | /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 — десятки моделей в одном окне. Оплата картой РФ и по СБП.