社交产品的搜索框通常只有一行,但它背后要回答多种问题:输入“设计系统”是在找帖子,还是找名为“设计系统”的社群?输入 @demo_yunshu 是精确找人,还是也要返回正文中提及该 handle 的回复?一条评论作为独立内容被搜索到时,点击后应该跳到哪里?帖子已删除、作者刚刚拉黑搜索者,旧索引还能否返回摘要?
如果搜索只是 WHERE body LIKE '%keyword%',很快会遇到相关度、分词、排序和分页问题;如果把搜索索引当作最终真相,又会遇到权限泄漏、删除延迟和不可点击结果。一个可靠方案需要把搜索拆成三部分:
- 索引构建:把权威领域事实变成适合检索的文档;
- 候选检索:按查询意图、相关度和时间从索引召回候选 ID;
- 安全水化:回到当前权限和卡片事实,过滤、补位并生成可导航结果。
本文以文档型搜索投影说明方法。底层可以使用 MongoDB Atlas Search、Elasticsearch、OpenSearch 或其他倒排索引;不论选择哪种产品,权威事实、版本门禁、最终权限和重建能力都不可由搜索引擎替代。
适用范围与规模假设
本文面向需要同时搜索内容、回复、用户和社群,并存在删除、拉黑、私密关系与社区权限的产品。若数据规模很小、只需后台管理查询,数据库全文索引可能已经足够;当相关度、混合实体和高频公开搜索成为核心体验时,再引入独立搜索投影。
一、定义搜索边界:什么必须被搜到
一个完整社交搜索至少包含三类实体:
- 内容:原创、回复、引用、转发;
- 用户:handle、显示名、简介;
- 社群:名称、handle、简介、公开状态。
评论回复必须纳入内容搜索。若系统已把评论建模为 kind=REPLY 的 Post,它天然拥有正文、作者、发布时间、根帖和直接父节点,索引时不应只接受 ORIGINAL。推荐的可索引 kind:
const indexablePostKinds = new Set([
"ORIGINAL",
"REPLY",
"QUOTE",
"REPOST",
]);
但“可建索引”不等于“查询时总能返回”。纯转发可能没有正文,仍可通过来源摘要或作者关系被找到;私密、未发布、已删除内容则不应进入公共搜索文档。
二、总体架构:索引只负责候选,不负责最终授权

整个链路包含:
- 内容、用户、社群 owner 在 PostgreSQL 提交变更和 outbox;
- Kafka 传播
PostPublished、PostDeleted、ProfileUpdated等事件; - Search Index Worker 严格校验事件契约,按实体 ID 获取重建锁;
- Worker 回读权威数据,判断当前是否应该索引;
- 以
sourceVersion幂等 upsert 或删除 MongoDB 搜索文档; - 查询服务解析意图,从索引取候选;
- 权限服务与卡片服务批量水化,并对被过滤项继续补位。
这个架构的核心约束是:搜索索引可以决定相关度,不能决定访问权。索引中即使保存了 visibility=PUBLIC,那也只是构建时快照;查询时仍要考虑最新删除、拉黑、关注审批和社区成员关系。
三、索引文档要面向检索,而不是复制数据库行
一份帖子搜索文档可以是:
type SearchPostDocument = {
schemaVersion: number;
sourceVersion: string;
postId: string;
authorUserId: string;
kind: "ORIGINAL" | "REPLY" | "QUOTE" | "REPOST";
bodyText: string;
normalizedText: string;
hashtags: string[];
mentionedHandles: string[];
language: string | null;
hasImage: boolean;
hasVideo: boolean;
publishedAt: Date;
indexedAt: Date;
};
刻意不内嵌完整 UserCard、权限矩阵和媒体对象。搜索索引的职责是快速找 ID 与排序;结果展示从专用卡片投影水化。这样用户改头像不需要重建所有帖子搜索文档,媒体展示模型升级也不会触发全量重索引。
正文为空如何处理
bodyText 可以为空字符串,而不是把“该内容暂无文字摘要”作为索引文本。纯媒体帖可通过话题、替代文本、媒体类型或来源摘要检索;纯转发是否索引来源文本要谨慎,否则同一原帖的所有转发会淹没结果。通常可以:
- 将纯转发降权;
- 搜索文档记录
sourcePostId,查询结果按来源弱去重; - 卡片展示时仍显示转发动作主体和来源卡片。
回复的导航字段放哪里
搜索文档只需保存用于排序/过滤的轻量关系字段,如 rootPostId、replyToPostId。点击结果时走正式详情接口,由详情 DTO 返回根帖、直接父评论和当前回复。不要在搜索索引复制一整棵对话树。
四、索引事件要窄,构建过程要回读权威事实
索引事件可以只携带:
{
"eventId": "evt_...",
"eventName": "PostPublished",
"aggregateId": "post_...",
"aggregateVersion": "18",
"payloadVersion": 1
}
Worker 收到后回读帖子、话题和提及,并执行 shouldIndex:
function shouldIndex(post: PostSearchSource): boolean {
return post.status === "PUBLISHED"
&& post.deleted === false
&& post.visibility === "PUBLIC"
&& post.publishedAt !== null;
}
如果不满足条件,执行 REMOVE,而不是静默不动;否则一份旧文档会继续留在索引里。PostDeleted 事件也执行删除,但即使删除事件丢失,后续任意重建事件回读当前 owner 状态后仍会移除文档。

sourceVersion 如何产生
可以使用 owner 的单调 readVersion;如果多个 owner 表共同组成搜索文档,可把稳定字段与各自版本做规范化哈希:
sourceVersion = sha256(
post.readVersion + taxonomy.version + mention.version
)
写入时仅接受不旧于当前文档的版本。版本比较必须有明确定义;普通哈希不能直接比较大小,此时可将它用于“是否相同”,另带单调 owner revision 用于新旧判断。
五、查询意图解析比“把 q 传给搜索引擎”更重要
同一个字符串可能包含:
- 普通词:
设计系统 - handle:
@demo_yunshu - 话题:
#产品设计 - 混合:
@demo_yunshu 缓存 #后端 - 纯数字、URL、中文与英文组合。
第一步应做长度、Unicode、空白和控制字符校验,然后解析结构化意图:
type SearchIntent = {
raw: string;
normalized: string;
terms: string[];
handles: string[];
hashtags: string[];
looksLikeHandleOnly: boolean;
};
纯 handle 查询优先精确匹配用户 handle,并可补充正文提及结果;普通词走分词和模糊匹配;#topic 对规范化话题字段加权。这样才能避免用户明明存在,却因为搜索服务只查正文而显示“用户不存在”。
相关度与最新排序
建议提供至少两种排序:
RELEVANCE:文本分、handle/话题精确命中、质量和新鲜度组合;LATEST:以publishedAt DESC, postId DESC稳定排序。
相关度游标不能只编码 _id。它需要包含排序分数的量化值、发布时间、稳定 ID 和查询指纹。若搜索引擎支持 search-after,应使用排序元组而非 offset。
六、结果水化:过量召回、权限过滤、继续补位
假设页面需要 20 条结果。如果只从索引取 20 个 ID,水化时删除 8 个、拉黑过滤 5 个,用户最终只看到 7 条。正确做法是 overfetch + refill:
页面目标 20
→ 索引先召回 40
→ 权限与存活过滤
→ 卡片批量水化
→ 不足 20 则继续 search-after
→ 达到 20、候选耗尽或 scan budget 后停止
结果顺序应按搜索候选顺序恢复,不能依赖数据库批量查询返回顺序。去重键通常是实体 ID;纯转发还可按来源做产品级弱去重。
查询服务应输出内部诊断:
type SearchPageDiagnostics = {
candidatesScanned: number;
resultsReturned: number;
filteredDeleted: number;
filteredPermission: number;
hydrationMissing: number;
refillRounds: number;
};
这些字段不必都暴露给客户端,但必须进入指标和 trace。否则“搜索有四条结果却一条也点不开”很难定位是索引陈旧、投影缺失还是权限错误。
七、删除与权限变更需要双保险
1. 索引侧及时移除
内容删除、账号封禁、社区转私密或可见性收紧时,应产生索引失效事件。Worker 回读 owner,删除对应文档。定期 repair job 比对 owner 与索引,清理孤儿。
2. 查询侧最终拦截
事件传播存在延迟,查询必须最终拦截。对不可见内容不要返回正文摘要、作者敏感信息或“可推断存在”的精确错误。对公开页面可返回中性提示:目标不可用;对已获得卡片但点击时权限变化,则详情接口返回稳定占位。
两层缺一不可:只靠查询过滤会长期保留大量垃圾候选,性能下降;只靠索引删除会存在传播窗口,形成安全风险。
八、缓存设计:query fingerprint 决定结果身份
搜索缓存键应包含:
search:{entityType}:{queryFingerprint}:{sort}:{cursorHash}:{viewerScopeVersion}
queryFingerprint 由规范化查询、语言、媒体筛选、时间范围等参数稳定计算。带用户权限的最终卡片页很难广泛共享缓存,因此可分层:
- 公共索引候选缓存可跨用户复用;
- 权限过滤后的 ID 列表按 viewer 短缓存;
- UserCard/PostCard 使用独立实体缓存。
不要把后端异常产生的空结果长时间缓存。缓存层需要区分 MISS、CACHED_EMPTY 和 ERROR,并为真实空结果使用较短 TTL。
九、索引重建、回填和在线切换
搜索系统必须预设“索引可以全部删除”的恢复能力。
- 创建新 schema/index version;
- 从 owner 按稳定主键分片扫描;
- 复用在线
buildDocument与shouldIndex; - 记录 checkpoint,允许中断续跑;
- 同时消费增量事件,或记录重建起始水位后补齐;
- 比对数量、抽样正文、kind 分布、删除孤儿和查询金样例;
- 切换 active alias/version;
- 观察后清理旧版本。
回填最近帖子可以作为短期修复,但不应直接插“伪热门数据”。正确做法是从 PostgreSQL 读取最近符合 shouldIndex 的真实内容,走同一构建函数和版本门禁。
Repair Job 的三类输出
MISSING_IN_INDEX → owner 存在且应索引,执行 upsert
ORPHAN_IN_INDEX → owner 不存在或不应索引,执行 remove
VERSION_MISMATCH → 两边存在但 sourceVersion 不同,重建
修复要有限速、可审计,并避免与在线更新互相覆盖;版本门禁是最后防线。
十、搜索 API 设计
GET /api/search?q=设计系统&tab=posts&sort=relevance&cursor=...
GET /api/search?q=@demo_yunshu&tab=users&cursor=...
GET /api/search?q=产品&tab=communities&cursor=...
统一响应结构:
{
"query": "设计系统",
"tab": "posts",
"items": [],
"nextCursor": null,
"hasMore": false,
"tookMs": 23
}
不同实体使用不同 item contract,不要用大量 nullable 字段拼成“万能结果”。搜索建议与正式结果也应分开:建议追求极低延迟和前缀匹配,正式结果承担完整权限和水化。
十一、降级与错误语义
| 场景 | 对用户的行为 | 系统动作 |
|---|---|---|
| 查询为空 | 显示“未找到结果”和清除筛选建议 | 记录 zero-result query |
| 索引暂不可用 | 显示可重试错误,不伪装为空 | 熔断、告警、可选回退有限 SQL 搜索 |
| 部分卡片缺失 | 补位;达到预算后返回短页 | 触发单实体重建 |
| 目标点击后删除 | 详情显示删除占位 | 清索引与缓存 |
| handle 无用户 | 明确“没有找到该用户” | 不影响帖子词项搜索 |
“用户不存在”只能用于已经确认查找目标是用户且权威用户事实不存在。回复的父评论缺失、根帖子删除、用户卡片投影暂缺,都不应该复用这句提示。
十二、测试与观测
测试矩阵应包括:
- 四种 PostKind 全部可按正文和话题被检索;
- 回复结果点击后返回根帖、直接父评论和当前回复;
@handle精确命中用户,混合查询仍可搜内容;- 删除事件、乱序旧发布事件和重复事件不会复活文档;
- 拉黑、私密和社区退出后即使旧索引命中也不返回;
- 前一批候选全部被过滤时 refill 能继续返回后续结果;
- 索引不可用返回错误态而非 0 条;
- 重建后金样例查询排序与预期一致。
关键指标包括索引延迟、consumer lag、文档 upsert/remove/stale 数、查询 P95、零结果率、候选过滤率、水化缺失率、refill 轮数、点击后目标不可用率,以及 owner/index 差异率。
十三、方案权衡
权威回读构建索引增加数据库读取,却显著降低旧事件污染和契约膨胀;对更新频繁的实体,可用事件携带经过审查的必要字段并定期校正。查询最终权限门禁增加尾延迟,却是社交关系动态变化下不可省略的安全成本;可通过批量接口、短缓存和候选过量召回优化。
MongoDB Atlas Search 让业务团队复用文档存储,但并不改变架构原则。换成 Elasticsearch 后,owner truth、事件版本、最终权限、水化补位和重建切换仍然成立。
结语
可靠搜索不是“把文本放进倒排索引”,而是一条从领域事实到用户可点击结果的完整链路。它要覆盖真正的内容类型,理解 handle 与话题意图,抵抗重复和乱序事件,在索引之外执行当前权限,并在删除、投影缺失和依赖故障时给出正确语义。
当索引被定义为可重建候选层,搜索就不再是孤立外挂,而会成为内容系统的一种读模型:快,但不越权;最终一致,但可修复;结果丰富,同时每一张卡片都能回到真实业务对象。