Civitai 图片 Feed 迁移实战:将 getImagesFromSearchPostFilter 迁入 event-engine-common 统一 Feed 系统
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
导读
本文围绕 docs/image-feed-migration-plan.md 及其配套实现总结 docs/image-feed-implementation-summary.md 展开,完整讲解 Civitai 如何将图片搜索/订阅流的核心函数getImagesFromSearchPostFilter与 Meilisearch 索引构建任务metrics-images.search-index.ts迁移到全新的event-engine-commonFeed 系统。读完本文,你将掌握:Feed 系统的统一抽象(createDocuments/queryDocuments/populateDocuments三段式架构)、30+ 字段的 Meilisearch 索引 Schema 设计、20+ 种过滤条件的映射方法,以及一套「并行实现 + Feature Flag 灰度 + 全量替换」的平滑迁移策略。
迁移背景:为什么要把图片搜索迁入 Feed 系统
Civitai 的图片流(Image Feed)承载着网站的核心浏览体验:用户按热度/最新排序浏览图片、按模型版本筛选、按 NSFW 等级过滤、查看关注与隐藏内容等。历史上,这套能力被拆散在两个不同的代码路径中:
- 查询侧:
getImagesFromSearchPostFilter(定义于 src/server/services/image.service.ts)负责向 Meilisearch 发起复杂过滤查询、做结果后处理(存在性检查、权限过滤)、从 ClickHouse 补充指标,并返回带游标的分页结果。 - 索引侧:
metrics-images.search-index.ts(定义于 src/server/search-index/metrics-images.search-index.ts)负责从 PostgreSQL 拉取图片基础数据、从 ClickHouse 拉取指标、从数据库拉取标签/工具/技巧/模型版本,变换合并后写入 Meilisearch。
这两条路径各写各的逻辑,存在大量重复的过滤规则与数据组装代码,类型也不安全。迁移的目标,是把它们统一到event-engine-common的 Feed 系统下,形成「查询、填充、创建文档」三个高度内聚的方法,提供统一、类型安全的接口来查询、填充和创建 Meilisearch 中的图片文档。
旧实现的职责拆解
1.getImagesFromSearchPostFilter:查询与后处理
从源码 src/server/services/image.service.ts 可以看到,它的职责包括:
- 以复杂过滤条件查询 Meilisearch;
- 对结果做后处理(存在性检查、权限过滤、定时发布内容过滤);
- 从 ClickHouse 填充指标;
- 返回带游标的分页结果。
它支持的关键特性非常丰富:
- 自适应批大小后过滤(adaptive batch sizing for post-filtering);
- Feature Flag 控制的存在性检查(Redis 缓存 + DB 回退);
- NSFW 等级过滤(浏览等级 browsingLevel);
- 复杂权限过滤(私有/封禁内容、定时发布内容);
- 时间段过滤(Day / Week / Month / Year / AllTime);
- 标签 / 工具 / 技巧过滤;
- 模型版本过滤(自动/手动资源,即
modelVersionIds与modelVersionIdsManual); - Remix 过滤(
remixOfId、remixesOnly、nonRemixesOnly); - POI / 未成年人内容过滤;
- 审核员(Moderator)专属特性(
blockedFor、poiOnly、minorOnly、notPublished、scheduled)。
从实现细节看,它还有几个值得注意的行为:
- 无条件追加
postId IS NOT NULL过滤,只展示归属于帖子(Post)的图片; hidden(隐藏)与followed(关注)过滤会先查ImageEngagement/UserEngagement表拿到 ID 集合,再拼成id IN [...]/userId IN [...],若集合为空则直接返回空结果;username会先解析成userId(读库找不到再回退写库,找不到抛NotFound);- NSFW 过滤在
useCombinedNsfwLevel为真时使用combinedNsfwLevel,否则使用nsfwLevel,并允许作者本人看到自己未扫描(nsfwLevel=0)的内容。
2.metrics-images.search-index.ts:索引构建
该模块负责把数据库中的图片变换为 Meilisearch 文档,文档结构如下:
{ id, index, postId, url, nsfwLevel, aiNsfwLevel, nsfwLevelLocked, width, height, hash, hideMeta, sortAt, type, userId, publishedAt, hasMeta, onSite, postedToId, needsReview, minor, promptNsfw, blockedFor, remixOfId, hasPositivePrompt, availability, poi, acceptableMinor, // Transformed: combinedNsfwLevel, baseModel, modelVersionIds, modelVersionIdsManual, toolIds, techniqueIds, publishedAtUnix, existedAtUnix, sortAtUnix, tagIds, flags, reactionCount, commentCount, collectedCount }需要移植的类型清单
迁移的第一步,是把主代码库中散落的类型与枚举移植进event-engine-common:
枚举(Enums):
| 枚举 | 用途 |
|---|---|
ImageSort | 排序选项(Most Reactions / Most Comments / Most Collected / Newest / Oldest) |
NsfwLevel | NSFW 内容等级(NotProcessed=0, PG=1, PG13=2, R=4, X=8, XXX=16, Blocked=32) |
Availability | 内容可用性(Public / Private / Unsearchable) |
BlockedReason | 封禁原因(tos / moderated / CSAM / AiNotVerified) |
MediaType | 媒体类型(image / video / audio) |
输入类型(Input Types):ImageSearchInput(由GetInfiniteImagesOutput派生并补充额外字段)。
文档类型(Document Types):ImageMetricsSearchIndexRecord(Meilisearch 文档结构)、SearchBaseImage(PostgreSQL 基础图片数据)。
辅助类型(Helper Types):浏览等级 flags/数组、NSFW 受限基础模型列表。
分阶段实施计划
Phase 1:类型定义(event-engine-common/types/image-feed-types.ts)
把 event-engine-common 中尚不存在的类型与枚举移植过来,包括图片搜索输入过滤器、排序选项、NSFW/可用性枚举与文档类型。落地实现见 apps/event-engine/src/common/types/image-feed-types.ts,其中还附带了几个关键辅助函数:
// 判断浏览等级 flag 是否包含 NSFW 等级 export function includesNsfwContent(browsingLevel: number): boolean { return (browsingLevel & nsfwBrowsingLevelsFlag) !== 0; } // 将浏览等级 flag 展开为等级数组 export function browsingLevelToArray(flag: number): NsfwLevel[] { const levels: NsfwLevel[] = []; for (const level of allBrowsingLevelsArray) { if ((flag & level) !== 0) levels.push(level); } return levels; } // 剔除 Blocked 等级,默认回退到 PG export function onlySelectableLevels(level?: number): number { if (!level) return NsfwLevel.PG; return level & ~NsfwLevel.Blocked; } // 将时间戳向下取整到 5 分钟间隔,便于缓存命中 export function snapToInterval(timestamp: number, intervalMs: number = 5 * 60 * 1000): number { return Math.floor(timestamp / intervalMs) * intervalMs; }其中浏览等级常量使用位标志设计:sfwBrowsingLevelsFlag = PG | PG13,nsfwBrowsingLevelsFlag = R | X | XXX,allBrowsingLevelsFlag为两者并集。这是 Civitai 浏览等级过滤的核心编码方式,查询时先onlySelectableLevels剔除 Blocked,再browsingLevelToArray展开成数组供 MeilisearchIN [...]使用。
Phase 2:Schema 定义(event-engine-common/feeds/image.feed.ts)
定义与现有 Meilisearch 索引完全匹配的完整 Schema。落地实现位于 apps/event-engine/src/common/feeds/images.feed.ts:
const schema = { // Primary id: { type: 'number', primary: true, filterable: true }, index: { type: 'number', sortable: true }, // Basic fields sortAt: { type: 'Date', sortable: true }, sortAtUnix: { type: 'number', filterable: true }, type: { type: 'string', filterable: true }, userId: { type: 'number', filterable: true }, postId: { type: 'number', filterable: true }, // Model/Resource fields modelVersionIds: { type: 'array', arrayType: 'number', filterable: true }, modelVersionIdsManual: { type: 'array', arrayType: 'number', filterable: true }, postedToId: { type: 'number', filterable: true }, baseModel: { type: 'string', filterable: true }, // NSFW/Content Safety nsfwLevel: { type: 'number', filterable: true }, combinedNsfwLevel: { type: 'number', filterable: true }, availability: { type: 'string', filterable: true }, blockedFor: { type: 'string', filterable: true }, poi: { type: 'boolean', filterable: true }, minor: { type: 'boolean', filterable: true }, // Tags/Tools/Techniques tagIds: { type: 'array', arrayType: 'number', filterable: true }, toolIds: { type: 'array', arrayType: 'number', filterable: true }, techniqueIds: { type: 'array', arrayType: 'number', filterable: true }, // Metadata hasMeta: { type: 'boolean', filterable: true }, onSite: { type: 'boolean', filterable: true }, publishedAtUnix: { type: 'number', filterable: true }, existedAtUnix: { type: 'number', filterable: true }, remixOfId: { type: 'number', filterable: true }, // Flags 'flags.promptNsfw': { type: 'boolean', filterable: true }, // Metrics - sortable + filterable 以支持游标分页 reactionCount: { type: 'number', sortable: true, filterable: true }, commentCount: { type: 'number', sortable: true, filterable: true }, collectedCount: { type: 'number', sortable: true, filterable: true }, } as const;相比迁移前的示例 Feed(仅 11 个字段、3 种过滤),新 Schema 有30+ 字段、20+ 过滤类型、5 个数据源、游标分页与完整指标填充。一个值得注意的差异是:指标字段被同时标记为sortable与filterable,这是为了在按指标排序时也能配合游标/offset 分页做一致性约束。
Phase 3:createDocuments实现
复刻metrics-images.search-index.ts的文档构建逻辑,落地实现见 apps/event-engine/src/common/feeds/images.feed.ts。整体流程分六步:
- 从 PostgreSQL 拉取基础图片数据(等价于原 pullData step 0):一条
JOIN "Post"的 SQL 拿到sortAt(取GREATEST(publishedAt, scannedAt, createdAt))、hasMeta/hasPositivePrompt(基于metaJSON 与hideMeta的 CASE 表达式)、onSite(是否存在civitaiResources/workflow)、postedToId、remixOfId(从meta->'extra'->>'remixOfId'提取)等; - 从 ClickHouse 拉取指标(step 1):经
ctx.metric.fetch(ids)获取ReactionHeart/Like/Laugh/Cry、Comment、Collection; - 从缓存拉取标签(step 2):
ctx.cache.fetch('imageTagIds', imageIds); - 从 PostgreSQL 拉取工具/技巧(step 3):分别查询
ImageTool与ImageTechnique; - 从 PostgreSQL 拉取模型版本(step 4):
ImageResourceNew JOIN ModelVersion JOIN Model聚合出baseModel(仅 Checkpoint)、modelVersionIdsAuto(detected=true)、modelVersionIdsManual(detected != true)与资源级poi标志; - 变换合并(transformData):产出最终文档。
实现要点:
- 支持
'full'与'metrics'两种更新类型:'metrics'只更新三个指标计数(reactionCount、commentCount、collectedCount),显著降低增量刷新成本; - 大 ID 集合分批处理:使用
chunk(ids, 1000)按 1000 个一批执行; - POI 检测回退链:
image.poi ?? resource.poi,图片级未标注时使用资源级; - combinedNsfwLevel 计算:
nsfwLevelLocked为真时取nsfwLevel,否则取Math.max(nsfwLevel, aiNsfwLevel); - flags 提取:
promptNsfw经removeEmpty清洗,非空才写入文档。
Phase 4:queryDocuments实现
复刻getImagesFromSearchPostFilter的过滤逻辑,落地实现见 apps/event-engine/src/common/feeds/images.feed.ts。整体分为三块:
1. 构建 Meilisearch 过滤条件
| 过滤类别 | 字段 / 逻辑 |
|---|---|
| NSFW 等级 | browsingLevel → combinedNsfwLevel/nsfwLevel,审核员可见0,作者可见自己未扫描内容 |
| NSFW 许可限制 | NOT (nsfwLevel IN [R,X,XXX] AND baseModel IN [受限模型]) |
| 模型版本 | postedToId/modelVersionIds(自动)/modelVersionIdsManual(手动)三路 OR |
| Remix | remixOfId = x、remixesOnly → remixOfId >= 0、nonRemixesOnly → remixOfId NOT EXISTS |
| 标签/工具/技巧 | tagIds/toolIds/techniqueIds的IN [...] |
| 类型 | type IN [image, video, audio] |
| 时间段 | sortAtUnix > snapToInterval(now - periodMs)(Day/Week/Month/Year) |
| 用户 | userId = x、excludedUserIds → userId NOT IN [...]、followed/hidden 走 DB 查询后拼 IN |
| POI/未成年人 | poi != true、minor != true(审核员可= true精确匹配) |
| 元数据 | hasMeta = true、onSite = true、requiringMeta → blockedFor = 'AiNotVerified' |
| 发布状态 | 普通用户:publishedAtUnix <= snapToInterval(now)(利于缓存);审核员:NOT EXISTS(未发布)/> now(定时) |
| 审核特性 | blockedFor IN [...] |
NSFW 受限模型常量定义在 apps/event-engine/src/common/constants/feed.constants.ts:NSFW_RESTRICTED_LEVELS = [16, 32, 64](即 R/X/XXX),NSFW_RESTRICTED_BASE_MODELS包含SDXL Turbo、SVD、SVD XT、Stable Cascade、SD 3系列等许可受限模型。
2. 构建排序
将ImageSort枚举映射为 Meilisearch 排序字符串:
| ImageSort | Meilisearch sort |
|---|---|
| Most Reactions | reactionCount:desc |
| Most Comments | commentCount:desc |
| Most Collected | collectedCount:desc |
| Newest | sortAt:desc |
| Oldest | sortAt:asc |
实现中特意没有追加id:desc二级排序,以匹配现有getAllImagesIndex的返回顺序。
3. 分页处理
分页信息从 Feed 上下文(ctx.pagination)读取,采用offset 分页;查询时取limit + 1条,多取的一条用于判定是否还有下一页,并以此生成sortAtUnix:id形式的游标(见 Feed 导出处的getCursor: (doc) => String(doc.sortAtUnix))。
注意:原
getImagesFromSearchPostFilter使用游标分页并对后过滤采用自适应批大小;新实现基于 Meilisearch 的 offset 分页,这两点在迁移后仍属于待验证/待对齐项。
Phase 5:populateDocuments实现
在查询结果基础上增强文档数据,落地实现见 apps/event-engine/src/common/feeds/images.feed.ts。核心内容:
后过滤(post-filtering):这是迁移计划中明确要求的逻辑落点,逐条检查:
- 无
url的文档直接剔除; availability === 'Private'且非本人/非审核员 → 剔除;blockedFor非空且非本人/审核员 → 剔除;- 未发布/定时发布(
publishedAtUnix > snappedNow)且非本人 → 剔除; - 未扫描内容(
nsfwLevel === 0)且非本人 → 剔除; acceptableMinor仅本人可见;- 需审核内容(
needsReview)仅本人或「审核员且浏览等级含 NSFW」可见。
- 无
存在性检查(existence checking):通过
input.enableExistenceCheck开关选择两种路径:- 基础 DB 检查(默认):
SELECT id FROM "Image" WHERE id = ANY($1)后与结果集比对; - 智能缓存检查(Feature Flag 开启):先查
system:image-exists:{id}缓存(命中'true'/'false'),未命中的批量查 DB 后回写缓存,TTL 10 分钟(EX: 600)。缓存键前缀定义于 apps/event-engine/src/common/constants/feed.constants.ts 的FEED_REDIS_KEYS.CACHES.IMAGE_EXISTS。
- 基础 DB 检查(默认):
指标填充:从 ClickHouse 经 metric service 拉取,构建
stats对象,包含likeCountAllTime、heartCountAllTime、laughCountAllTime、cryCountAllTime、dislikeCountAllTime、commentCountAllTime、collectedCountAllTime、tippedAmountCountAllTime、viewCountAllTime。用户与社交数据增强:
- 用户数据(username、avatar、deletedAt、profilePictureId);
- 用户对图片的反应(
ImageReaction表按userId查询); - 头像(profile pictures)、用户已装备的装扮(cosmetics);
- 标签完整信息(id、name、type、nsfwLevel);
- 图片自身装扮(
UserCosmetic中equippedToType = 'Image'的记录); - 视频元数据与缩略图(视频缩略图以
Image记录存储,通过metadata->'thumbnailId'关联)。
产出形态对齐
getAllImagesIndex:最终PopulatedImage额外带有modelVersionId(由postedToId转换)、createdAt(由sortAt转换)、metadata(宽高 + 视频元数据)、ingestion(Scanned/Blocked/NotFound,依据最终 NSFW 等级推导)、thumbnailUrl、重算后的nsfwLevel(取缩略图与文档的较大值)。已看图片追踪:完成后将图片 ID 写入
queues:seen-imagesRedis Set(FEED_REDIS_KEYS.QUEUES.SEEN_IMAGES),供浏览去重与个性化使用。
Phase 6:集成与测试
1. 导出 Feed 类
落地实现见 apps/event-engine/src/common/feeds/images.feed.ts:
export const ImagesFeed = createFeed({ entityType: 'Image', name: 'metrics_images_v1', connection: { host: process.env.FEED_IMAGE_HOST, apiKey: process.env.FEED_IMAGE_API_KEY, }, schema, createDocuments, queryDocuments, populateDocuments, // base.ts 会把 sortAtUnix 与 offset 组合为 "offset|sortAtUnix" 游标 getCursor: (doc) => String(doc.sortAtUnix), });Feed 上下文(FeedContext<'Image'>)通过createFeed(定义于 apps/event-engine/src/common/feeds/base.ts)注入pg(PostgreSQL 查询)、cache(Redis 缓存)、metric(ClickHouse 指标)、index(Meilisearch 索引)与pagination等能力,三个方法签名分别为createDocuments(ctx, ids, type)、queryDocuments(ctx, input)、populateDocuments(ctx, documents, input)。
2. 测试场景
迁移计划要求覆盖以下测试:
- 各种过滤条件下的基础查询;
- 分页正确性;
- 文档创建/更新(full 与 metrics 两种类型);
- 与现有实现的性能基准对比。
缓存层的落地:5 个新 Cache
为了实现「多数据源 + 可复用」,迁移同时新增了 5 个 Feed 兼容缓存(定义于 apps/event-engine/src/common/caches/imageData.cache.ts),全部使用createCache接口:
| 缓存 | Redis Key | 内容 |
|---|---|---|
imageTagIds | image:tagIds | 图片关联的标签 ID,带 WD14/Rekognition 标签去重逻辑(TTL 12h,有效 24h) |
tagData | tag:data | 标签完整信息(name、type、nsfwLevel,TTL 24h) |
cosmeticData | cosmetic:data | 装扮信息(TTL 24h) |
userCosmetics | user:cosmetics | 用户已装备的装扮(TTL 24h) |
profilePictures | user:profilePicture | 用户头像数据(TTL 24h) |
其中imageTagIds的实现值得一提:当一张图同时带有 WD14 与 Rekognition 标签时,会过滤掉 Rekognition 标签,仅保留Moderation类型或位于ALWAYS_INCLUDE_TAGS(anime、cartoon、comics、manga、man、woman 等风格/主体词)白名单中的标签,避免两个识别系统的标签互相污染。
迁移后的使用方式
索引任务侧(替换imagesMetricsDetailsSearchIndex)
原来通过createSearchIndexUpdateProcessor实现的多步处理器,现在简化为:
import { ImagesFeed } from 'event-engine-common/feeds'; // 批量更新文档 await feed.upsert(imageIds, 'full'); // 仅刷新指标 await feed.upsert(imageIds, 'metrics'); // 删除文档 await feed.delete(imageIds);API 查询侧(替换getImagesFromSearchPostFilter)
import { ImagesFeed } from 'event-engine-common/feeds'; import { meilisearch, clickhouse, pg, metricService, cacheService } from '...'; // 初始化 Feed const feed = new ImagesFeed(meilisearch, clickhouse, pg, metricService, cacheService); // 带过滤条件的查询(含填充) const images = await feed.populatedQuery({ limit: 100, sort: 'Most Reactions', browsingLevel: NsfwLevel.PG | NsfwLevel.PG13, period: 'Week', tags: [123, 456], currentUserId: 789, });新旧实现的关键差异
改进点
- 类型安全:所有类型从 Schema 配置推断,编译期即发现错误;
- 模块化:缓存可在多个 Feed 间复用;
- 可测试性:三个方法可独立测试;
- 一致性:与其他 Feed(模型、用户等)采用同一套模式;
- 可维护性:关注点清晰分离(查询 / 填充 / 建文档)。
已知限制与 TODO
- NSFW 许可限制:目前基于
NSFW_RESTRICTED_BASE_MODELS常量实现,注释标记为「需要动态配置」; - 游标分页:Meilisearch 使用 offset 分页,与旧实现的游标分页存在差异,可能需要调整;
- 自适应批大小:旧实现的后过滤有自适应批大小逻辑,新实现未落地;
- 后过滤逻辑:存在性检查与权限校验已实现在
populateDocuments,但部分边界场景(如 Flipt 特性开关接入)仍在推进中; - Flipt 集成:计划在 apps/event-engine/src/common/feeds/base.ts 的 Feed 上下文中加入可选 Flipt 客户端,用于 feature-flagged 存在性检查。
迁移策略与灰度方案
迁移计划给出了五步走策略,核心原则是先并行、后灰度、再替换:
- 并行实现:保留现有代码继续工作,新 Feed 与旧路径并存;
- Feature Flag:复用现有的
FEED_POST_FILTER开关将流量路由到新实现; - 逐步灰度:先用小比例流量测试;
- 监控对比:对比新旧实现的性能与结果一致性;
- 全量迁移:验证通过后删除旧代码。
设计决策记录
根据迁移过程中的评审反馈,最终确定的关键决策如下:
| 决策项 | 结论 |
|---|---|
| 后过滤逻辑归属 | ✅ 实现在populateDocuments中,通过ctx.pg、ctx.cache访问数据源 |
prioritizedUserIds | ❌ 不实现(旧实现getImagesFromSearchPostFilter当前也不支持) |
| 仅指标更新 | ⏳ 可选优化,'metrics'类型已实现,后续可进一步推广 |
| Flipt 客户端 | ⏳ 可作为可选接口加入 apps/event-engine/src/common/feeds/base.ts 的 Feed 上下文,用于特性开关控制存在性检查 |
扩展:同模式复制到其他实体
迁移总结明确指出,这套「types → caches → feeds → createFeed 导出」的模式可以推广到 Posts、Articles 等其他实体。对任何新的 Feed,你只需要:
- 在 apps/event-engine/src/common/types 定义实体类型、枚举与辅助函数;
- 在 apps/event-engine/src/common/caches 用
createCache声明所需缓存并更新index.ts导出; - 在 apps/event-engine/src/common/feeds 实现
createDocuments/queryDocuments/populateDocuments三个方法,并用createFeed导出; - 在 apps/event-engine/src/common/feeds/index.ts 更新导出后,即可在索引任务与 API 查询两侧统一接入。
结语
图片 Feed 迁移是 Civitai 将「查询 + 填充 + 索引」三条历史路径收敛到event-engine-common统一抽象上的典型范例:以 docs/image-feed-migration-plan.md 为蓝图,经过 docs/image-feed-implementation-summary.md 中记录的实现与决策,最终产出 30+ 字段 Schema、20+ 过滤类型、5 数据源合并的ImagesFeed。这套方案不仅让图片流代码更类型安全、更易测试,也为 Posts、Articles 等后续 Feed 的迁移铺平了道路。
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考