Feast MongoDB Offline Store:基于单集合聚合管道的离线特征存储实战指南
【免费下载链接】feastThe Open Source Feature Store for AI/ML项目地址: https://gitcode.com/GitHub_Trending/fe/feast
Feast 的 MongoDB Offline Store 是一个将 MongoDB 作为离线特征存储(Offline Store)的贡献型(contrib)实现,允许你直接基于 MongoDB 中的数据训练模型、运行批量打分(batch scoring),并在读取路径上采用 MongoDB 聚合管道与复合索引,将单实体查询成本控制在 O(log n) 量级。读完本文,你将掌握该离线存储的单集合数据模型、复合索引设计、feature_store.yaml配置方式、pull_latest/get_historical_features两条读取路径的实现原理(评分路径与服务端去重、训练路径与merge_asof)、strict_pit语义以及写入与内存行为,并了解其配套单元测试对关键行为的验证方式。
设计定位:单集合、共享 schema 的离线特征存储
该离线存储的完整实现位于 mongodb.py,模块文档对其设计做了三点概括:
- 单集合(single-collection)schema:所有 Feature View 共享同一个 MongoDB collection(默认名为
feature_history),通过文档中的feature_view字段区分彼此; - 服务端去重(scoring path):当
entity_df中实体 ID 唯一时,聚合管道追加$group阶段,每个(entity_id, feature_view)最多返回一条文档,传输量由 O(N×P×K) 降为 O(N×K)(N 为实体数,P 为每实体观测数,K 为 Feature View 数); - 复合索引支撑整条管道:索引
(entity_id ASC, feature_view ASC, event_timestamp DESC, created_at DESC)使单实体查询成本从 O(P) 降为 O(log P)。
数据模型:所有 Feature View 共享的feature_history集合
所有 Feature View 的观测数据写入同一个 collection(默认feature_history),由feature_view字段做判别(discriminator)。README 给出了文档的 JSON 形态:
// Collection: feature_history { "entity_id": Binary("..."), // Serialized entity key (bytes) "feature_view": "driver_stats", // Discriminator "features": { // Nested subdocument "trips_today": 5, "rating": 4.8 }, "event_timestamp": ISODate("2024-01-15T10:00:00Z"), "created_at": ISODate("2024-01-15T10:00:01Z") }各字段的含义与实现对应关系如下:
| 字段 | 类型 | 说明 | 源码依据 |
|---|---|---|---|
entity_id | Binary(bytes) | 序列化后的实体键(entity key),由 Feast 的serialize_entity_key生成 | mongodb.py中_serialize_entity_key_from_row与_ser |
feature_view | string | 判别字段,即数据源MongoDBSource的name | MongoDBSource.feature_view_name返回self.name |
features | 嵌套子文档 | 特征名 → 特征值 的映射 | offline_write_batch逐列构造 |
event_timestamp | ISODate | 事件时间戳,Point-in-Time 连接的核心字段 | 默认timestamp_field="event_timestamp" |
created_at | ISODate | 写入时间戳,用于同事件时间下的冲突裁决(取最新) | 默认created_timestamp_column="created_at" |
注意entity_id在 MongoDB 中是序列化后的字节串(不直接存可读的 join key 列),这正是读取时需要_expand_entity_id_column反序列化、把 join key 还原成独立列的原因。反序列化逻辑见mongodb.py的_expand_entity_id_column,它调用deserialize_entity_key将字节展开为各 join key 列后再输出。
复合索引:一次懒创建支撑全部查询
存储层在首次使用时懒创建一个复合索引(源码中_ensure_indexes使用create_index(..., name="entity_fv_ts_idx", background=True)),并通过模块级缓存_indexes_ensured(集合f"{conn_str}/{db}/{collection}"去重)避免每次调用都重复建索引。索引定义如下:
db.feature_history.createIndex({ "entity_id": 1, "feature_view": 1, "event_timestamp": -1, "created_at": -1 })从源码结构可以推断该索引的每一列都服务于具体管道阶段:
entity_id(升序)支撑$match: {entity_id: {$in: [...]}}的实体点查;feature_view(升序)配合$match中的feature_view判别过滤;event_timestamp(降序)支撑$lte: max_ts的时间窗口过滤以及$sort中的时间降序;created_at(降序)支撑$sort/$group $first对同一事件时间下“最新写入”的选择。
配置:接入feature_store.yaml
在 Feast 的feature_store.yaml中通过offline_store段启用该离线存储:
offline_store: type: feast.infra.offline_stores.contrib.mongodb_offline_store.mongodb.MongoDBOfflineStore connection_string: mongodb://localhost:27017 database: feast collection: feature_history # optional, default: feature_history对应源码中的MongoDBOfflineStoreConfig(FeastConfigBaseModel子类),三个可配置项及默认值如下:
| 配置项 | 默认值 | 说明 |
|---|---|---|
type | feast.infra.offline_stores.contrib.mongodb_offline_store.mongodb.MongoDBOfflineStore | 离线存储实现类路径 |
connection_string | mongodb://localhost:27017 | MongoDB 连接 URI,支持带认证与多副本的 URI 形式 |
database | feast | MongoDB 数据库名 |
collection | feature_history | 所有 Feature View 共享的集合名 |
在 Feature View 定义中,batch source 需要使用MongoDBSource,其name即文档判别字段:
from feast.infra.offline_stores.contrib.mongodb_offline_store.mongodb import MongoDBSource driver_source = MongoDBSource( name="driver_stats", timestamp_field="event_timestamp", created_timestamp_column="created_at", )MongoDBSource继承自 Feast 的DataSource,source_type()返回CUSTOM_SOURCE,其 proto 序列化将{"feature_view": self.name}写入custom_options.configuration(见_to_proto_impl)。
读取路径一:pull_latest_from_table_or_query
pull_latest_from_table_or_query返回时间窗口内每个实体最新一条观测,管道形如$match → $sort → $group($first) → $project(见mongodb.py):
$match:按feature_view判别 +event_timestamp的$gte/$lte时间窗口;$sort:entity_id升序、event_timestamp降序、created_at降序——保证“最新写入”排最前;$group:按entity_id分组取$first,每个实体仅返回一条;$project:展开features子文档的各特征列,并保留event_timestamp(可选created_at)。
与之配套的pull_all_from_table_or_query则只做$match + $project,返回窗口内全部原始行、不去重,通常服务于离线特征物料(training data)的原始抽取。
读取路径二:get_historical_features的评分路径与训练路径
get_historical_features是训练/批量打分的主入口,源码根据entity_df的形态在两条路径间按 Feature View 逐条自动切换:
Scoring path(评分路径)——当entity_df中实体 ID 唯一,且(strict_pit=True时)所有实体请求时间戳相同:
- 管道为
$match + $sort + $group,在 MongoDB 服务端完成去重,每个(entity_id, feature_view)至多返回一条文档; - 复合索引使单实体成本为 O(log P),且避免了把每个实体的全部历史观测拉到 Python 侧;
- Python 侧随后做一次向量化 left join,并施加
future_mask(严格 PIT 时把晚于请求时间的文档置NULL)。
Training path(训练路径)——当entity_df存在重复实体 ID 且位于不同时间戳(典型的 PIT 训练数据形态):
- 省略
$group阶段,将候选文档与实体表按实体键分组后在 Python 中执行pandas.merge_asof(direction="backward")做逐行 Point-in-Time 连接,该操作由 pandas 底层的 C 实现优化; - 为正确处理“同一事件时间、不同写入时间”的冲突,
fv_df会先按["event_timestamp", "created_at"]排序,保证merge_asof命中created_at最新的文档(对应测试test_training_path_created_at_tiebreaker)。
路径选择的核心判定代码位于get_historical_features的_run_single中:
unique_entities = result[eid_col].nunique() == len(result) scoring_path = unique_entities and ( not strict_pit or result[event_timestamp_col].nunique() == 1 )即:实体键唯一 + 请求时间戳一致(或strict_pit=False)时走服务端$group;否则回退merge_asof。这保证了不同 Feature View 可以混合使用不同路径(见测试test_mixed_join_key_cardinality:user_id维度的 FV 走merge_asof,而(user_id, device_id)维度的 FV 仍可走评分路径)。
strict_pit参数语义
get_historical_features接受strict_pit关键字参数,默认True:
strict_pit=True(默认,训练/评估安全):文档时间戳严格晚于实体请求时间戳的观测被返回为NULL,避免“未来泄漏”(future leakage);strict_pit=False:用于实时推理(real-time inference)场景,始终返回该实体最新的观测,即使其时间戳晚于名义请求时间。
对应的测试用例覆盖了三种场景(见测试文件test_mongodb.py):test_scoring_path_nulls_future_doc验证未来文档被置NULL;test_scoring_path_nulls_future_doc_chunk_size_1验证在分块(_CHUNK_SIZE=50_000默认值)边界下行为一致;test_strict_pit_false_returns_future_doc验证strict_pit=False时未来文档会被返回。源码在$match阶段用ts_filter = {"$lte": max_ts} if strict_pit else {}做服务端过滤,Python 侧再用future_mask兜底置空。
查询折叠(Query-collapse):从 K 次往返降为一次
README 强调的核心优化是Query-collapse:共享相同 join key 集合(join key signature)的多个 Feature View,会被分组成一次 MongoDB 聚合往返,而不是每个 Feature View 一次。往返次数从 K(Feature View 数)降为“唯一 join key 签名数”——在常见场景下即为 1。
从源码看,get_historical_features先构造fv_by_proj(按投影名name_to_use()索引 Feature View)、fv_mongo_name(投影名 → MongoDB 判别值)、fv_mapped_join_keys(投影名 → 映射后的 join key)等映射表,再对每个投影逐一遍历执行管道;同一 join key 集合的 Feature Views 共享一次$match {entity_id: {$in: batch_ids}}的实体 ID 批量查询,每个批次的实体 ID 上限由_MONGO_BATCH_SIZE = 10_000控制。测试test_k_collapse_multiple_feature_views验证了driver_stats_k与vehicle_stats_k两个共享driver_id的 Feature View 在同一次检索中被正确解析。
此外,当entity_df行数超过_CHUNK_SIZE = 50_000时,数据会被分块处理(_chunk_dataframe),各块结果按原始行号_row_idx排序拼接,保证输出顺序与输入一致。
写入数据:offline_write_batch与feast materialize
写入侧使用offline_write_batch,它由feast materialize自动调用。README 给出直接调用方式:
store.write_to_offline_store(feature_view_name, df)写入语义为纯追加(append-only),不做 upsert;冲突在读取时裁决——pull_latest与评分路径均通过$sort created_at DESC → $group $first(或 merge_asof 前的created_at排序)选择created_at最高的文档。
从源码看offline_write_batch的处理流程:
- 使用原始(未映射)join key 名序列化实体键,确保与
get_historical_features的序列化字节一致(源码注释明确说明该点,测试test_offline_write_batch_round_trip和test_int32_entity_key均验证了“写入字节 = 读取字节”); - 时间戳列统一规范化为带 UTC 时区的
datetime; - 特征值逐行提取:NaN 跳过、numpy 标量经
.item()转为 Python 原生类型、非标量(list/dict)保留; created_at缺省时取当前 UTC 时间;- 按每批 10,000 条
insert_many(..., ordered=False)写入,progress回调在每批后上报行数。
文档判别值取自feature_view.batch_source.feature_view_name,因此通过 push /write_to_offline_store写入的数据与初始 ingest 落在同一集合分区。
内存行为:$match先行,而非全量加载
README 明确指出存储的内存占用特性:存储按实体键在$match阶段过滤,而不是把整个集合加载到内存。因此内存占用上界为“唯一实体 ID 数 × 每实体文档数”,与集合总体积无关。这一特性对海量历史数据尤其重要——$match配合复合索引在 MongoDB 服务端完成裁剪,Python 侧只接收与请求实体相关的候选文档。
结果持久化与类型推断
- 结果落盘:
MongoDBRetrievalJob.persist()支持将检索结果写为 Parquet 文件,需配合SavedDatasetFileStorage;目标文件已存在且未设置allow_overwrite=True时抛出SavedDatasetLocationAlreadyExists(对应测试test_persist_writes_parquet、test_persist_raises_if_file_exists、test_persist_allow_overwrite)。 - 列类型推断:
MongoDBSource.get_table_column_names_and_types通过读取feature_view对应的一条样本文档来推断列名与类型——event_timestamp/created_at映射为datetime,features子文档中的值按 Python 类型映射为bool/int64/float64/string/list/dict/object。类型字符串到 FeastValueType的映射见 type_map.py 的mongodb_to_feast_value_type(如"int64"→INT64、"float64"→DOUBLE、"list[int]"→INT64_LIST,无法识别的类型映射为UNKNOWN)。
测试验证与使用前提
该存储的单元测试位于 test_mongodb.py,使用testcontainers.mongodb.MongoDbContainer(mongo:latest)起真实 MongoDB 实例,Docker 不可用时相关用例被@_requires_docker跳过。测试覆盖了本文涉及的几乎所有关键行为:pull_latest每实体最新行、训练路径逐行 PIT 连接、created_at平局裁决、评分路径服务端去重、pull_all全量窗口、TTL 过期置NULL、K-collapse 多 Feature View 合并、混合 join key 基数、异构时间戳回退训练路径、重叠特征名(full_feature_names=True时的fv__feature列命名)、复合 join key、entity_df 额外标签列不污染实体键序列化、INT32 实体键字节一致性、persist 行为、offline_write_batch写读往返以及strict_pit三态语义。
使用前请注意以下前提与限制(均以当前仓库源码为准):
- 依赖
pymongo,未安装时会抛出FeastExtrasDependencyImportError("pymongo", "mongodb"); get_historical_features不支持 SQL 字符串形式的entity_df,传入字符串会抛出ValueError,请使用 pandas DataFrame;entity_key_serialization_version必须与写入侧一致,否则字节不匹配会导致查询结果为空(测试中统一使用 version 3);- TTL(Feature View 的
ttl)在读取侧执行:评分路径与训练路径都会把超过 TTL 的过期观测置NULL(对应测试test_ttl_excludes_stale_features); - 该离线存储位于
contrib(社区贡献)目录,属于自定义离线存储实现,可参考 adding-a-new-offline-store.md 了解 Feast 离线存储扩展点(OfflineStore抽象类定义了pull_latest_from_table_or_query、get_historical_features、offline_write_batch等接口)。
【免费下载链接】feastThe Open Source Feature Store for AI/ML项目地址: https://gitcode.com/GitHub_Trending/fe/feast
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考