Civitai 图片 Feed 迁移实战:将 getImagesFromSearchPostFilter 迁入 event-engine-common 统一 Feed 系统
2026/9/18 7:13:11 网站建设 项目流程

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);
  • 标签 / 工具 / 技巧过滤
  • 模型版本过滤(自动/手动资源,即modelVersionIdsmodelVersionIdsManual);
  • Remix 过滤remixOfIdremixesOnlynonRemixesOnly);
  • POI / 未成年人内容过滤
  • 审核员(Moderator)专属特性blockedForpoiOnlyminorOnlynotPublishedscheduled)。

从实现细节看,它还有几个值得注意的行为:

  • 无条件追加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)
NsfwLevelNSFW 内容等级(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 | PG13nsfwBrowsingLevelsFlag = R | X | XXXallBrowsingLevelsFlag为两者并集。这是 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 个数据源、游标分页与完整指标填充。一个值得注意的差异是:指标字段被同时标记为sortablefilterable,这是为了在按指标排序时也能配合游标/offset 分页做一致性约束。

Phase 3:createDocuments实现

复刻metrics-images.search-index.ts的文档构建逻辑,落地实现见 apps/event-engine/src/common/feeds/images.feed.ts。整体流程分六步:

  1. 从 PostgreSQL 拉取基础图片数据(等价于原 pullData step 0):一条JOIN "Post"的 SQL 拿到sortAt(取GREATEST(publishedAt, scannedAt, createdAt))、hasMeta/hasPositivePrompt(基于metaJSON 与hideMeta的 CASE 表达式)、onSite(是否存在civitaiResources/workflow)、postedToIdremixOfId(从meta->'extra'->>'remixOfId'提取)等;
  2. 从 ClickHouse 拉取指标(step 1):经ctx.metric.fetch(ids)获取ReactionHeart/Like/Laugh/CryCommentCollection
  3. 从缓存拉取标签(step 2):ctx.cache.fetch('imageTagIds', imageIds)
  4. 从 PostgreSQL 拉取工具/技巧(step 3):分别查询ImageToolImageTechnique
  5. 从 PostgreSQL 拉取模型版本(step 4):ImageResourceNew JOIN ModelVersion JOIN Model聚合出baseModel(仅 Checkpoint)、modelVersionIdsAuto(detected=true)、modelVersionIdsManual(detected != true)与资源级poi标志;
  6. 变换合并(transformData):产出最终文档。

实现要点:

  • 支持'full''metrics'两种更新类型'metrics'只更新三个指标计数(reactionCountcommentCountcollectedCount),显著降低增量刷新成本;
  • 大 ID 集合分批处理:使用chunk(ids, 1000)按 1000 个一批执行;
  • POI 检测回退链image.poi ?? resource.poi,图片级未标注时使用资源级;
  • combinedNsfwLevel 计算nsfwLevelLocked为真时取nsfwLevel,否则取Math.max(nsfwLevel, aiNsfwLevel)
  • flags 提取promptNsfwremoveEmpty清洗,非空才写入文档。

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
RemixremixOfId = xremixesOnly → remixOfId >= 0nonRemixesOnly → remixOfId NOT EXISTS
标签/工具/技巧tagIds/toolIds/techniqueIdsIN [...]
类型type IN [image, video, audio]
时间段sortAtUnix > snapToInterval(now - periodMs)(Day/Week/Month/Year)
用户userId = xexcludedUserIds → userId NOT IN [...]、followed/hidden 走 DB 查询后拼 IN
POI/未成年人poi != trueminor != true(审核员可= true精确匹配)
元数据hasMeta = trueonSite = truerequiringMeta → 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 TurboSVDSVD XTStable CascadeSD 3系列等许可受限模型。

2. 构建排序

ImageSort枚举映射为 Meilisearch 排序字符串:

ImageSortMeilisearch sort
Most ReactionsreactionCount:desc
Most CommentscommentCount:desc
Most CollectedcollectedCount:desc
NewestsortAt:desc
OldestsortAt: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。核心内容:

  1. 后过滤(post-filtering):这是迁移计划中明确要求的逻辑落点,逐条检查:

    • url的文档直接剔除;
    • availability === 'Private'且非本人/非审核员 → 剔除;
    • blockedFor非空且非本人/审核员 → 剔除;
    • 未发布/定时发布(publishedAtUnix > snappedNow)且非本人 → 剔除;
    • 未扫描内容(nsfwLevel === 0)且非本人 → 剔除;
    • acceptableMinor仅本人可见;
    • 需审核内容(needsReview)仅本人或「审核员且浏览等级含 NSFW」可见。
  2. 存在性检查(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
  3. 指标填充:从 ClickHouse 经 metric service 拉取,构建stats对象,包含likeCountAllTimeheartCountAllTimelaughCountAllTimecryCountAllTimedislikeCountAllTimecommentCountAllTimecollectedCountAllTimetippedAmountCountAllTimeviewCountAllTime

  4. 用户与社交数据增强

    • 用户数据(username、avatar、deletedAt、profilePictureId);
    • 用户对图片的反应(ImageReaction表按userId查询);
    • 头像(profile pictures)、用户已装备的装扮(cosmetics);
    • 标签完整信息(id、name、type、nsfwLevel);
    • 图片自身装扮(UserCosmeticequippedToType = 'Image'的记录);
    • 视频元数据与缩略图(视频缩略图以Image记录存储,通过metadata->'thumbnailId'关联)。
  5. 产出形态对齐getAllImagesIndex:最终PopulatedImage额外带有modelVersionId(由postedToId转换)、createdAt(由sortAt转换)、metadata(宽高 + 视频元数据)、ingestionScanned/Blocked/NotFound,依据最终 NSFW 等级推导)、thumbnailUrl、重算后的nsfwLevel(取缩略图与文档的较大值)。

  6. 已看图片追踪:完成后将图片 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内容
imageTagIdsimage:tagIds图片关联的标签 ID,带 WD14/Rekognition 标签去重逻辑(TTL 12h,有效 24h)
tagDatatag:data标签完整信息(name、type、nsfwLevel,TTL 24h)
cosmeticDatacosmetic:data装扮信息(TTL 24h)
userCosmeticsuser:cosmetics用户已装备的装扮(TTL 24h)
profilePicturesuser: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, });

新旧实现的关键差异

改进点

  1. 类型安全:所有类型从 Schema 配置推断,编译期即发现错误;
  2. 模块化:缓存可在多个 Feed 间复用;
  3. 可测试性:三个方法可独立测试;
  4. 一致性:与其他 Feed(模型、用户等)采用同一套模式;
  5. 可维护性:关注点清晰分离(查询 / 填充 / 建文档)。

已知限制与 TODO

  1. NSFW 许可限制:目前基于NSFW_RESTRICTED_BASE_MODELS常量实现,注释标记为「需要动态配置」;
  2. 游标分页:Meilisearch 使用 offset 分页,与旧实现的游标分页存在差异,可能需要调整;
  3. 自适应批大小:旧实现的后过滤有自适应批大小逻辑,新实现未落地;
  4. 后过滤逻辑:存在性检查与权限校验已实现在populateDocuments,但部分边界场景(如 Flipt 特性开关接入)仍在推进中;
  5. Flipt 集成:计划在 apps/event-engine/src/common/feeds/base.ts 的 Feed 上下文中加入可选 Flipt 客户端,用于 feature-flagged 存在性检查。

迁移策略与灰度方案

迁移计划给出了五步走策略,核心原则是先并行、后灰度、再替换

  1. 并行实现:保留现有代码继续工作,新 Feed 与旧路径并存;
  2. Feature Flag:复用现有的FEED_POST_FILTER开关将流量路由到新实现;
  3. 逐步灰度:先用小比例流量测试;
  4. 监控对比:对比新旧实现的性能与结果一致性;
  5. 全量迁移:验证通过后删除旧代码。

设计决策记录

根据迁移过程中的评审反馈,最终确定的关键决策如下:

决策项结论
后过滤逻辑归属✅ 实现在populateDocuments中,通过ctx.pgctx.cache访问数据源
prioritizedUserIds❌ 不实现(旧实现getImagesFromSearchPostFilter当前也不支持)
仅指标更新⏳ 可选优化,'metrics'类型已实现,后续可进一步推广
Flipt 客户端⏳ 可作为可选接口加入 apps/event-engine/src/common/feeds/base.ts 的 Feed 上下文,用于特性开关控制存在性检查

扩展:同模式复制到其他实体

迁移总结明确指出,这套「types → caches → feeds → createFeed 导出」的模式可以推广到 Posts、Articles 等其他实体。对任何新的 Feed,你只需要:

  1. 在 apps/event-engine/src/common/types 定义实体类型、枚举与辅助函数;
  2. 在 apps/event-engine/src/common/caches 用createCache声明所需缓存并更新index.ts导出;
  3. 在 apps/event-engine/src/common/feeds 实现createDocuments/queryDocuments/populateDocuments三个方法,并用createFeed导出;
  4. 在 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询