В последние пару лет текстовые эмбеддинги, порождённые крупными языковыми моделями (LLM), показали себя как мощный инструмент для сравнения и кластеризации текстов. Однако после генерации возникает вопрос: «Что делать со всеми этими векторами?». Очень часто мы натыкаемся на советы использовать специализированные векторные базы данных наподобие Faiss, Qdrant или Pinecone. И действительно, в крупных проектах с миллионами и миллиардами эмбеддингов такая инфраструктура абсолютно оправдана. Но если ваш проект поменьше (тысячи или десятки тысяч векторных представлений) или вы просто хотите «поиграться» локально, то от внешних сервисов можно отказаться и всё отлично поместится в обычную оперативную память.
Автор оригинальной статьи продемонстрировал это на примере 32 254 эмбеддингов для карт Magic: the Gathering. Получилось порядка 94 МБ в памяти, что удобно умещается в современные конфигурации — в том числе и на бесплатных облачных инстансах. Главное — хранить в удобном формате и иметь возможность быстро считывать данные вместе с метаданными (названиями, типами, описанием). Ниже — идея, почему Parquet + Polars делают это почти идеально.
Почему не CSV или Pickle?
При работе с эмбеддингами есть несколько популярных (но не всегда удачных) способов хранения:
🔴 CSV (текстовый формат)
- В каждой строке много чисел с плавающей точкой, весящих намного больше, чем бинарные float32.
- На больших объёмах вырастает размер файла и время парсинга.
- Чтобы восстановить матрицу (n,d)(где n — число объектов, d — размерность эмбеддинга), придётся изрядно повозиться.
🔴 Pickle
- Быстрый способ сбросить всё, как есть в памяти, в файл.
- Но форматы Pickle небезопасны (могут выполнять произвольный код) и не всегда совместимы между разными версиями Python/операционными системами.
- Лучше избегать Pickle в продакшене.
🔴 Numpy .npy
- Уже лучше: хранит ровно бинарную матрицу float32, без overhead.
- Но привязка: как хранить метаданные (название, тип, текст объекта)? Приходится вести отдельный массив с индексами или придумать собственные решения.
Parquet: хранение колоночных данных с типами
Формат Parquet (развиваемый Apache Parquet) — это столбцовый формат, позволяющий эффективно сохранять и загружать табличные данные со строгими типами. Например, в одной колонке можно хранить строки (названия карт Magic), в другой — вложенные списки float32 (сами эмбеддинги). Преимущества:
🗄️ Колонно-ориентированная структура. Проще загружать только нужные колонки, экономя время и память.
🌐 Совместимость. Parquet понимают многие инструменты (Spark, Arrow, pandas, Polars).
🚀 Сжатие и быстрое чтение. Эмбеддинги не всегда сильно сжимаются, но сама структура позволяет максимально эффективно работать без лишнего парсинга строк.
Polars: быстрые датафреймы с поддержкой списков
Для Python наибольшую популярность имеет pandas, но для эмбеддингов есть минус: pandas испытывает сложности при работе с колонками, содержащими длинные вложенные списки (например, когда каждая строка — массив из 768 элементов типа float32). В результате такие данные часто превращаются в объекты или списки без строгой типизации, что требует использования обходных решений вроде np.vstack(), замедляя обработку.
Альтернатива — Polars, библиотека на Rust, совместимая с Apache Arrow. Она умеет:
🟢 Корректно хранить колонку типа «Array<float32>» (именно то, что нужно для эмбеддингов).
🟢 Легко передавать эти данные в NumPy. Вызов df["embedding"].to_numpy() может вернуть реальную двумерную матрицу без копирования.
🟢 Быстро фильтровать и выбирать подмножество строк (например, «хочу карточки только определённого цвета»), после чего доставать связанную часть эмбеддингов.
Так что схема выглядит так:
1️⃣ Чтение: df = pl.read_parquet("dataset.parquet")
2️⃣ Получаем нужные эмбеддинги: emb_matrix = df["embedding"].to_numpy(allow_copy=False)
3️⃣ Считаем косинусное сходство через быстрое dot_product(query, emb_matrix.T) (благодаря NumPy).
4️⃣ Находим индексы топ-K близких эмбеддингов и смотрим метаданные df[idx].
Всё это не требует никакого сервера или векторной БД. Если у вас 30 000—100 000 векторов по 768 float32, всё, скорее всего, влезет в память и будет быстро работать.
Пример: Magic: the Gathering
Автор оригинального поста сгенерировал эмбеддинги для 32 254 карт Magic (включая текст карты, стоимость, редкость и т.д.). 94 МБ занимает вся матрица float32 (32 254 строк × 768 столбцов). Для поиска ближайших соседей (например, топ-3 самых похожих карт) требуется около 1 мс на M3 Pro MacBook. Это довольно шустро.
Когда надо хранить метаданные (название, цвет, тип карты и т.д.), всё идёт в один parquet-файл: одна колонка — name, другая — embedding. Можно добавлять и другие, например, type, rarity, а потом фильтровать: «Покажи похожие, но только с цветом BB и типом Sorcery».
Когда всё-таки нужна векторная база?
Существуют ситуации, где Parquet + Polars уже недостаточно:
🪨 Сотни миллионов эмбеддингов — не поместятся в память или придётся делать сложный шардинг.
⏱️ Требование реального времени с экстремальным количеством запросов. Векторные БД (Faiss, Milvus, Qdrant, Pinecone) оптимизированы для ускоренной ANN (Approximate Nearest Neighbors).
⛅ Широкомасштабный проект: когда удобно иметь удалённый сервис, к которому подключается несколько микросервисов.
🔀 Объединение с другой бизнес-логикой и встроенная фильтрация «на лету», сильная параллелизация.
Но если у вас небольшой проект или вы делаете что-то локально, Parquet + Polars дают:
🔹 Единый портативный файл, который можно быстро перенести на другой компьютер.
🔹 Никакой привязки к Docker/внешним сервисам.
🔹 Встроенное хранение строк-метаданных и списка float32 под эмбеддингами в одной таблице.
🔹 Лёгкую фильтрацию и супербыструю загрузку/выгрузку.
Технические подробности реализации
🛠️ Чтение Parquet:
import polars as pl
df = pl.read_parquet("mtg-embeddings.parquet", columns=["name", "embedding"])
emb_matrix = df["embedding"].to_numpy(allow_copy=False) # (n, d) float32
🛠️ Поиск похожих (простейшая версия на косинусном сходстве при нормализованных векторах):
import numpy as np
def fast_dot_product(query, matrix, k=3):
dot_products = query @ matrix.T # скалярные произведения
idx = np.argpartition(dot_products, -k)[-k:]
idx = idx[np.argsort(dot_products[idx])[::-1]]
score = dot_products[idx]
return idx, score
idx, scores = fast_dot_product(query_embed, emb_matrix, k=4)
related_rows = df[idx]
🛠️ Добавление новых эмбеддингов:
# Допустим, у нас уже есть df (Polars DataFrame) и новый массив embeddings
df = df.with_columns([pl.Series("embedding", embeddings)])
df.write_parquet("updated-embeddings.parquet")
Итог
🔑 Parquet хранит данные столбцами с нужными типами.
🔑 Polars отлично умеет работать со столбцом вида «список float32», не требуя лишнего копирования.
🔑 NumPy обеспечивает быструю реализацию dot-продуктов и сортировок.
Вместе они дают лёгкое и эффективное решение для небольших проектов, где не нужен «тяжёлый» движок векторной БД. Да, при масштабировании свыше сотен тысяч-векторов может потребоваться Faiss/Qdrant/Pinecone, но для личных экспериментов или умеренных задач Parquet + Polars часто закрывают все потребности.
Ссылки на оригинал и дополнительную информацию
📝 Исходная статья «The best way to use text embeddings portably is with Parquet and Polars»