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

# Введение
Большинство команд на собственном горьком опыте убеждаются в необходимости хранилища функций . Модель обнаружения мошенничества работает в ноутбуке, но незаметно дает сбой в рабочей среде. Агент службы поддержки дает общий ответ, потому что не знает, кто пользователь. Конвейер рекомендаций дублирует один и тот же расчет «затрат за 30 дней» в трех заданиях, и два из них не совпадают.
Хранилище признаков — это часть инфраструктуры , которая решает эти проблемы. Оно определяет признаки один раз, хранит их в двух форматах (один для обучения, другой для развертывания) и поддерживает их синхронизацию. Мы собираемся создать минимальное хранилище с нуля на Python , используя DuckDB , Parquet , Redis и FastAPI . Затем мы рассмотрим, как приложения ИИ меняют то, для чего мы его на самом деле используем.
Полный код достаточно короткий, чтобы мы могли рассмотреть каждый компонент.

# Что на самом деле решает магазин функций
Классический аргумент заключается в искажении результатов при подаче данных на обучающую выборку: SQL-запросы, использованные для создания обучающего набора данных, не совпадают с кодом, выполняемым при выводе результатов, поэтому значения расходятся. Эта проблема реальна, и стандартным решением является разделение данных на офлайн и онлайн этапы.
Современный подход шире. Агенты на основе больших языковых моделей (LLM) и конвейеры генерации с расширенным поиском (RAG) нуждаются в структурированном контексте пользователя во время вывода, при каждом запросе, менее чем за 10 мс. LLM не помнит, кто пользователь. Если нам нужен персонализированный результат, мы должны ввести в запрос уровень тарифного плана пользователя, недавнюю активность и состояние учетной записи, и нам нужна система, которая может быстро и стабильно возвращать эти значения. Именно это нам и предоставляет онлайн-хранилище и API поиска данных в хранилище признаков.
Поэтому мы разрабатываем решения для обоих случаев. Одни и те же пять компонентов обрабатывают как сценарий использования прогнозного машинного обучения, так и сценарий использования контекста LLM.
# Пять компонентов
- Реестр функций, определяющий функции в виде кода.
- Офлайн-магазин на Parquet, данные о котором запрашиваются из DuckDB, предназначен для обучения и замещения вакансий.
- Интернет-магазин на Redis для поиска информации с низкой задержкой при выполнении инференции.
- Конвейер материализации, который передает последние значения из офлайн-режима в онлайн.
- Сервис FastAPI, предоставляющий типизированный API для получения данных.

# Пример работы: Персонализированная система рекомендаций для получения степени магистра права
Мы управляем стриминговым сервисом. Когда пользователь открывает приложение, LLM генерирует короткое персонализированное сообщение «что посмотреть дальше». Для этого LLM необходимы три вещи о пользователе:
| Особенность | Тип | Свежесть |
|---|---|---|
| пользовательский_сегмент | нить | ежедневно |
| watch_count_30d | инт | почасово |
| последний_жанр | нить | за событие |
Сущность называется user_id. Мы зарегистрируем эти три функции, материализуем их и предоставим LLM по запросу.
// 1. Определение реестра функций
Реестр — это просто место, где функции объявляются один раз, с указанием их сущности, типа данных и источника. Мы используем класс данных.
from dataclasses import dataclass from typing import Literal @dataclass(frozen=True) class Feature: name: str entity: str dtype: Literal[«int», «float», «str»] source: str # путь к файлу Parquet или представлению SQL REGISTRY: dict[str, Feature] = { «user_segment»: Feature(«user_segment», «user_id», «str», «data/user_segment.parquet»), «watch_count_30d»: Feature(«watch_count_30d», «user_id», «int», «data/watch_count_30d.parquet»), «last_genre»: Feature(«last_genre», «user_id», «str», «data/last_genre.parquet»), }
Полный код можно найти здесь.
При запуске программы отображается следующий вывод:
Зарегистрированные признаки: user_segment entity=user_id dtype=str source=data/user_segment.parquet watch_count_30d entity=user_id dtype=int source=data/watch_count_30d.parquet last_genre entity=user_id dtype=str source=data/last_genre.parquet
Это контракт. Все остальные компоненты считывают данные из REGISTRY, поэтому переименование функции, изменение её типа данных или указание на новый источник происходят в одном месте. В производственных системах это может быть YAML-файл или модуль Python, добавленный в репозиторий Git, с проверкой кода при каждом изменении.
// 2. Создание офлайн-хранилища с использованием DuckDB и Parquet
В офлайн-хранилище хранится полная история значений каждого параметра. В качестве уровня хранения мы используем файлы Parquet, а в качестве механизма запросов — DuckDB. DuckDB считывает файлы Parquet напрямую, что означает отсутствие необходимости в отдельной базе данных.
Вот пример кода:
import duckdb import pandas as pd def get_historical_features( entity_df: pd.DataFrame, features: list[str] ) -> pd.DataFrame: con = duckdb.connect() con.register(«entities», entity_df) base = «SELECT * FROM entities» for fname in features: f = REGISTRY[fname] src = f.source.replace(«'», «''») con.execute(f»CREATE VIEW {fname}_src AS SELECT * FROM '{src}'») base = f»»» SELECT t.*, s.{fname} FROM ({base}) t ASOF LEFT JOIN {fname}_src s ON t.user_id = s.user_id AND t.event_timestamp >= s.event_timestamp «»» return con.execute(base).df()
Полный код можно найти здесь.
При запуске программы отображается следующий вывод:
| ID пользователя | event_timestamp | пользовательский_сегмент | watch_count_30d | последний_жанр |
|---|---|---|---|---|
| 8a2f | 2026-05-05 12:00:00 | повседневный | 22 | NaN |
| б13с | 2026-05-07 20:00:00 | повседневный | 5 | триллер |
| 8a2f | 2026-05-07 22:00:00 | power_user | 47 | документальный |
Соединение AsOf — это соединение по моменту времени. Для каждой строки сущности оно выбирает самое последнее значение признака, где временная метка признака совпадает или предшествует временной метке события. Именно это предотвращает утечку — ситуацию, когда обучающая строка создается со значением признака, которого еще не существовало в момент прогнозирования.
Объединение данных в определенный момент времени по-прежнему является правильным решением для любой модели, которую мы планируем обучать или дорабатывать. В случае использования LLM исключительно на этапе вывода результатов, мы можем никогда не вызывать эту функцию. Нам по-прежнему необходимо офлайн-хранилище, поскольку именно оттуда поступают данные для заполнения пропусков, оценочные наборы данных и результаты аудита.
// 3. Настройка интернет-магазина на Redis
Интернет-магазин хранит только самое актуальное значение для каждой сущности. Redis является стандартным выбором, поскольку поиск хеша занимает менее миллисекунды.
import json import fakeredis # использовать redis.Redis() для проверки реального сервера в продакшене r = fakeredis.FakeRedis(decode_responses=True) def write_online(entity: str, entity_id: str, values: dict) -> None: r.hset( f»{entity}:{entity_id}», mapping={k: json.dumps(v) for k, v in values.items()}, ) def read_online(entity: str, entity_id: str, features: list[str]) -> dict: raw = r.hmget(f»{entity}:{entity_id}», features) return {f: json.loads(v) if v else None for f, v in zip(features, raw)}
Полный код можно найти здесь.
При запуске программы отображается следующий вывод:
read_online -> {'user_segment': 'power_user', 'watch_count_30d': 47, 'last_genre': 'documentary'} missing key -> {'user_segment': None}
Ключевая структура — entity:entity_id. Значение — хеш, по одному полю на каждый объект. Один запрос HMGET возвращает все запрошенные объекты за один цикл. На локальном экземпляре Redis с тремя объектами это занимает менее 1 мс.
// 4. Запуск конвейера материализации
Материализация перемещает значения из офлайн-режима в онлайн. В реальной системе это происходит по расписанию (Airflow, cron, потоковая обработка данных). Здесь же это функция.
def materialize(features: list[str]) -> None: by_entity: dict[str, dict] = {} for fname in features: f = REGISTRY[fname] src = f.source.replace(«'», «''») df = duckdb.sql(f»»» SELECT {f.entity}, {fname} FROM '{src}' QUALIFY ROW_NUMBER() OVER ( PARTITION BY {f.entity} ORDER BY event_timestamp DESC ) = 1 «»»).df() for _, row in df.iterrows(): by_entity.setdefault(row[f.entity], {})[fname] = row[fname] for entity_id, values in by_entity.items(): write_online(«user_id», entity_id, values)
Полный код можно найти здесь.
При запуске программы отображается следующий вывод:
user_id:8a2f -> {'user_segment': 'power_user', 'watch_count_30d': 47, 'last_genre': 'documentary'} user_id:b13c -> {'user_segment': 'casual', 'watch_count_30d': 5, 'last_genre': 'thriller'}
В предложении QUALIFY хранится последняя строка для каждой сущности. Мы объединяем все характеристики одного пользователя в одну запись в Redis, чтобы сократить количество обращений к серверу. Запускайте это с той периодичностью, которая необходима для каждой характеристики: ежечасно для watch_count_30d, почти в реальном времени для last_genre, ежедневно для user_segment. В реальной реализации реестр — подходящее место для кодирования этой периодичности.
// 5. Предоставление доступа к службе получения данных FastAPI
Сервис извлечения данных представляет собой производственную поверхность. Именно это используется в приложении LLM.
f = resp.json()[«features»] print(«nЗапрос, который получит LLM:») print( f» Система: Вы рекомендуете сериалы для потокового сервиса.n» f» Контекст пользователя: segment={f['user_segment']}, » f»просмотрел {f['watch_count_30d']} фильмов за последние 30 дней, » f»последний просмотренный жанр: {f['last_genre']}.n» f» Задача: предложить 3 фильма в дружелюбном, коротком сообщении.» )
Полный код можно найти здесь.
При запуске программы отображается следующий вывод:
POST /get-online-features -> 200 body: {'user_id': '8a2f', 'features': {'user_segment': 'power_user', 'watch_count_30d': 47, 'last_genre': 'documentary'}} Запрос, который получит LLM: Система: Вы рекомендуете сериалы для стримингового сервиса. Контекст пользователя: сегмент=power_user, посмотрел 47 фильмов за последние 30 дней, последний просмотренный жанр: документальный. Задача: предложить 3 фильма в дружелюбном, коротком сообщении.
Хранилище функций — это тот элемент, который превращает «пользователя 8a2f» в структурированный контекст, который может использовать LLM.
# Где заканчивается хранилище функций и начинается векторная база данных
Векторная база данных ( Pinecone , Weaviate , pgvector ) не является хранилищем признаков, хотя обе они используются в качестве основы для вывода модели. Они решают разные задачи поиска.

В реальной системе LLM используются оба подхода. Векторная база данных возвращает три наиболее похожих предыдущих сеанса просмотра. Хранилище признаков возвращает сегмент пользователя и количество недавних просмотров. Запрос объединяет их.
# Распространенные антипаттерны
Вот несколько шаблонов, которые, как мы постоянно видим, не работают:
- Вычисление характеристик внутри сервиса модели. Та же логика в итоге оказывается в обучающем блокноте и API, и эти два определения расходятся в течение квартала.
- Рассматривайте интернет-магазин как источник достоверной информации. Redis теряет данные при некорректном перезапуске. Офлайн-магазин является каноническим; интернет-магазин — это кэш.
- Пропуск реестра. Три команды независимо друг от друга определяют active_user, и панели мониторинга перестают соответствовать модели.
- Называть векторную базу данных хранилищем признаков — это неправильно. Она не может выполнять структурированный поиск по ключу сущности, а запрос, требующий и того, и другого, в любом случае будет подключен к двум системам.
- Заполнение пробелов без соединения данных в определенный момент времени. Тренировочный набор данных выглядит отлично, производственная модель выглядит некорректно, а пробел — это утечка.
# Сравнение с Feast, Tecton и Databricks
Наши примерно 200 линий выполняют ту же работу в миниатюре.

Если мы хотим продолжить работу по той же схеме — с самостоятельным размещением — то Feast является наиболее близким аналогом. Tecton и Databricks — это управляемые решения с явными функциями LLM (API Tecton для получения признаков для LLM, Feature Serving от Databricks для сложных генеративных систем ИИ). Выбор между ними в основном сводится к вопросу о том, насколько мы хотим управлять ими самостоятельно и размещена ли остальная часть нашего стека уже в Databricks.
# Заключение
Рабочее хранилище признаков состоит из пяти компонентов: реестра, офлайн-хранилища, онлайн-хранилища, этапа материализации и API для извлечения данных. Создание такого хранилища один раз позволяет понять, почему производственные системы выглядят именно так. Это также показывает, где меняется дизайн для ИИ: онлайн-путь извлечения данных — это поверхность, с которой взаимодействует LLM, объединения данных в определенный момент времени имеют значение при обучении или оценке, а векторная база данных располагается рядом с хранилищем признаков, а не внутри него.
Как только у нас появятся эти компоненты, замена нашей минимальной версии на Feast, Tecton или Databricks будет в основном заключаться в миграции реестра. Структура системы останется прежней.
Нейт Розиди — специалист по анализу данных и продуктовой стратегии. Он также является адъюнкт-профессором, преподающим аналитику, и основателем StrataScratch, платформы, помогающей специалистам по анализу данных готовиться к собеседованиям с помощью реальных вопросов от ведущих компаний. Нейт пишет о последних тенденциях на рынке труда, дает советы по прохождению собеседований, делится проектами по анализу данных и освещает все аспекты SQL.
Источник: www.kdnuggets.com
Похожие записи
Похожие записи
«Chad: The Brainrot IDE» — это новый продукт, поддерживаемый Y Combinator, настолько дикий, что люди подумали, что это подделка.
13.11.2025
Как любимый интернет-пользователями папа-белка создал самое популярное приложение для камеры 2026 года
04.05.2026
Самцы осьминогов руководствуются в процессе спаривания женскими гормонами.
05.04.2026Подписка на рассылку
Получайте свежие новости и идеи на почту. Без спама — только самое интересное.
Нажимая «Подписаться», вы соглашаетесь с политикой конфиденциальности.
