LobeHub 产品级全文检索架构与运维全指南:从 FtsSearchRepo 到 Elasticsearch 迁移
导读
本篇技术指南系统讲解 LobeHub(.agents/skills/full-text-search/SKILL.md 定义的"产品全文检索"技能域)中用于检索产品自有数据的全文搜索引擎:涵盖 agents、topics、messages、files、知识库、documents、chatGroups、memories 等全部可检索实体,以及其背后的 PostgreSQL(pg_search)与 Elasticsearch 双后端架构、数据捕获(Capture)/全量重建(Reindex)/增量同步(Sync)三大运维管线、权限与可观测性约束。读完本文,你将掌握 LobeHub 全库搜索从"一次搜索请求如何走到后端"到"如何安全切换搜索提供商"的完整链路,并理解为什么该仓库用一整套 Outbox 捕获与修订栅栏机制来保证重建与增量同步的一致性。
范围说明:本文聚焦搜索 LobeHub 产品自有数据,不涉及 Agent 的外部联网搜索(
apps/server/src/services/search/)与 builtin 网页浏览工具,二者是相互独立的模块。
架构总览:一条稳定的读路径
SKILL 首先给出产品搜索的唯一稳定读路径,所有入口(router/service)都必须沿此路径执行:
router/service -> createFtsSearchRepo -> FtsSearchRepo -> selected backend -> existing result schema
整条链路在代码库中被切分为职责清晰的四层,每一层都有明确的"归属"边界:
| 层次 | 代码位置 | 职责 |
|---|---|---|
| 服务工厂层 | apps/server/src/services/ftsSearch/ | 请求级 provider 选择、Elasticsearch 配置加载、ES HTTP 客户端、后端遥测 |
| 仓储门面层 | packages/database/src/repositories/ftsSearch/ | provider 无关契约 + PostgreSQL / Elasticsearch 两套具体实现 |
| 共享类型层 | packages/types/src/ftsSearch.ts | 可搜索实体清单与搜索领域类型 |
| 文档投影层 | packages/database/src/repositories/ftsSearchDocument/ | ES 文档 schema、mapping、可查询字段、源表到文档的投影 |
服务工厂:createFtsSearchRepo
Routers 与领域服务必须调用 apps/server/src/services/ftsSearch/index.ts 中导出的 createFtsSearchRepo 来获得仓储实例,绝不允许自己 new 一个 provider。该工厂的职责是"先解析部署级 provider,再构造稳定的门面",其核心签名如下(从源码结构看,入参以 CreateFtsSearchRepoInput 为载体):
db:LobeChatDatabase实例;userId/workspaceId:决定搜索范围(scope);callerAgentVisibility:调用方 Agent 可见性('private' | 'public' | null);usage:本次搜索用途(见下文可观测性一节),贯穿整个请求用于埋点归因。
工厂内部的核心不变量是:缺失 Elasticsearch 配置或 provider 初始化失败都必须保持可见——loadElasticsearchFtsSearchConfig() 若缺少 ES_URL、索引命名空间或 API Key(非 insecure 模式),工厂会直接放弃构造;最终因 createFtsSearchBackendForProvider 返回空而抛出 FtsSearchBackendUnavailableError。该文件还显式导出了 FTS_SEARCH_PROVIDERS = { elasticsearch, pgSearch } 常量和 FtsSearchProvider 类型,作为仓库内唯一定义 provider 身份的地方。
仓储门面:FtsSearchRepo
packages/database/src/repositories/ftsSearch/index.ts 中定义的 FtsSearchRepo 是对外保持稳定、对后端保持中立的搜索门面:
- 构造时若无显式 backend,默认装配
PgSearchFtsSearchBackend; search()是主要入口:当type未指定时会并行并发地检索 agents、chatGroups、topics、messages、files、folder/page documents、userMemories、knowledgeBases 等多类实体,每类走独立的FtsSearchBackendRequest;- 各类实体的每类结果配额由
calculateLimits()控制(默认每类limitPerType = 5,上下文场景如 agent/page/resource 有不同的 3/6 配额梯度); - 门面永远只返回既有产品结果类型与展示顺序,
response.items直接汇入既有 hydrate 结果 schema,routers 无需理解任何 provider 特有的结果形状。
门面还提供两个专用入口:ftsSearchCandidates()(仅取候选 ID,供遗留/混合路径自行做 PG 水合,需 ftsSearchCandidateEnabled 开启,而该开关只在 provider 为 elasticsearch 时打开)与 searchKnowledgeBaseDocuments()(在指定知识库集合内检索文档,返回 FtsSearchKnowledgeBaseDocumentHit[])。
共享领域类型
packages/types/src/ftsSearch.ts 维护着全文搜索"能搜什么"的权威清单:
export const FTS_SEARCH_DOCUMENT_ENTITIES = [
'agents', 'topics', 'files', 'knowledgeBases', 'userMemories', 'chatGroups',
'memoryContexts', 'memoryPreferences', 'memoryActivities', 'memoryIdentities',
'memoryExperiences', 'personaDocuments', 'documents', 'messages',
] as const;
同时该文件定义了重建运行状态机:每个实体的 FtsSearchReindexEntityStatus 为 'pending' | 'backfilling' | 'completed';整个重建任务 FtsSearchReindexRunStatus 为 'backfilling' | 'ready_for_incremental_sync' | 'completed' | 'failed'——这四种整体状态对应下文 fts-search:reindex --status 的输出语义。
Provider 与权限不变量(Invariants)
SKILL 用整节强调了一组部署级不变量,理解它们才能安全地改动任何一层:
-
FTS_SEARCH_PROVIDER是部署级选择器,当前取值仅pg_search与elasticsearch,它不是功能开关、更不是用户灰度(rollout)。只有当某 provider 被端到端实现后,才允许增加新的枚举值。该语义在 packages/env/src/ftsSearch.ts 中落实为FTS_SEARCH_PROVIDER: z.enum(['elasticsearch', 'pg_search']).default('pg_search')——即默认回落到 PostgreSQL 实现。 -
绝不静默降级:ES 报错、配置缺失、候选行为不受支持时错误必须暴露给调用方;禁止"静默重试 PostgreSQL"或加
ilike兜底。SKILL 特别强调"候选检索不得扩大调用方作用域"。 -
每实体路由禁止:在引入 ES 之前,必须有覆盖测试证明 ES 后端支持 provider 中立契约中的每一个实体,且不得在 provider 之间做"按实体分流"(per-entity routing)。
-
权限边界始终归 PostgreSQL:即便 ES 只回传候选 ID,PostgreSQL 水合(hydration)与父级校验(parent checks)仍是权威的权限裁决点;当既有结果路径需要数据库授权或水合时,不得直接把裸 ES hit 返回给用户。这也是
packages/database/src/repositories/ftsSearch/elasticsearch/下同时存在 candidates.ts 与 hydration.ts 的原因——从源码结构看,ES 后端负责 candidate 召回,PG 水合与作用域过滤(userId / workspaceId / callerAgentVisibility)随后把关。
新增/修改一个可搜索实体:必须作为跨层变更处理
把一个实体加进全文索引(或修改其文档投影),在 LobeHub 中是一个贯穿六层的单一变更,任何一层遗漏都会造成搜索行为与数据不一致。SKILL 给出逐项核对清单:
- packages/types/src/ftsSearch.ts 的
FTS_SEARCH_DOCUMENT_ENTITIES与共享请求/结果类型; - packages/database/src/repositories/ftsSearchDocument/ 下的文档 schema、mapping、文本字段、过滤器、fixtures 与 mapping parity(映射一致性)测试——目录内可看到配套的 schema.ts、mappings.ts、builder.ts 及其
schema.test.ts/mappings.test.ts/builder.test.ts; FtsSearchDocumentBuilder(builder 层),包括软删除与来自相关源行的 fanout(扇出);- PostgreSQL 与 Elasticsearch 两个后端的候选行为、权限水合、分页、排序;
- 捕获函数/触发器或
captureInfrastructure.ts中的 fanout 查询; - 重建 checkpoint、Outbox 排空、指标与自托管文档。
一致性硬性要求:schema 字段、mapping、builder、固定 fixtures 必须完全一致——不在文档 schema 中的字段,绝不能出现在 mapping 或查询字段列表里。
查询预算:multi_match 的叶分句上限
SKILL 特别提醒一个易踩坑的约束:Elasticsearch multi_match 查询长度由"共享叶分句预算 ÷ 所选查询字段数"决定。新增一个查询字段会降低该实体的安全查询长度;而如果更换查询分析器导致"每个 Unicode 码点产生多个 term",就必须重估预算及其字段数回归测试。从服务层遥测指标(见可观测性一节)也能侧面印证这一设计:存在 executedQueryChars 与 originalQueryChars 两组直方图,分别记录"预算截断后实际发出的查询"与"原始查询"的字符数,用于监控截断行为。
数据捕获、全量重建与增量同步
这是 SKILL 中实操价值最高的一节,也是自托管用户最需要小心编排的部分。
职责划分:migration 与 runtime installer 的边界
- 数据库迁移(regular migrations)负责"持久 schema":修订序列(revision sequence)、Outbox 表与索引、schema 托管的源表索引(例如 Memory fanout 的 GIN 索引)。它们随版本升级自动安装。
- 捕获函数与触发器(capture functions/triggers)只在算子显式启用 ES 路径时才安装。入口脚本为 scripts/installFtsSearchSyncCapture/index.ts,它要求
DATABASE_URL就绪,通过runWithLockRetry包装仓库方法installCaptureInfrastructure()(该方法的持久化实现位于 packages/database/src/repositories/ftsSearchSyncOutbox/),安装成功/失败分别打印明确的✅ .../❌ ...前缀日志。SKILL 要求其语义为:事务性、definition-checked(定义校验)、对完全一致的安装幂等、对部分安装或定义被改动时 fail-closed(关闭即失败)。 - 反向约束同样重要:不要为纯 PG 实例安装捕获设施,也不要把普通 schema 索引挪进 runtime installer 仅仅因为它恰好支撑捕获。
Outbox 并发与顺序保证
Outbox(变更日志表,schema 见 packages/database/src/schemas/ftsSearchSyncOutbox.ts)是整个增量链路的持久化基石,SKILL 明确其语义:
- 按
(entity, document_id)合并(coalesce);更新的捕获在锁定冲突行之后重置重试/死信状态并分配新修订号,从而保证同一文档的提交顺序不被打乱。 - 序列分配是非事务的:必须使用既有写入栅栏(write fences)与"已提交修订边界",绝不能仅凭
last_value就断言更早的 Outbox 行都已可见。 - 认领(claim)使用精确租约时间戳作为 fencing token:被其他 worker 回收的工作,陈旧 worker 不得再 ack / fail / release。
- 死信:永久失败与重试耗尽变成持久死信;任何"创建或观察到死信"的排空过程必须失败,而不是继续发布一个虚假的成功切换信号。
支持算子入口(可直接运行的命令)
SKILL 给出的全部运维入口如下(均可从仓库根目录执行,且都能在根 package.json 中找到对应 script):
# 1) 安装 ES 路径的捕获设施(触发器/函数,事务性、幂等)
bun run db:install-fts-search-capture
# 实际映射: cross-env MIGRATION_DB=1 tsx ./scripts/installFtsSearchSyncCapture/index.ts
# 2) 查看可恢复的全量回填状态(重建到哪个实体/进度)
bun run fts-search:reindex -- --status
# 实际映射: node scripts/elasticsearchReindex/runner.mjs
# 3) 执行/确认全量回填(apply 模式,--yes 免二次确认)
bun run fts-search:reindex -- --apply --yes
# 4) 执行一段增量同步(最多 8 步,--yes 免确认)
bun run fts-search:sync -- --max-steps=8 --yes
# 实际映射: cross-env MIGRATION_DB=1 bun run scripts/elasticsearchSync/cli.ts
# 5) 查看 PG 侧清理任务状态
bun run scripts/pgSearchCleanup/index.ts --status
# 6) 执行 PG 侧清理(apply 模式)
bun run scripts/pgSearchCleanup/index.ts --apply --yes
fts-search:reindex 的可恢复(resumable)全量回填实现在 scripts/elasticsearchReindex/(含 runner.mjs、options.ts、preparation.ts、runtime/ 子目录,以及 __tests__),其任务状态机 'backfilling' | 'ready_for_incremental_sync' | 'completed' | 'failed' 与共享类型层定义严格对齐;回填期间按实体维护 'pending' | 'backfilling' | 'completed' 的 checkpoint,配合 --status 支持断点续跑。持续增量排空则分别存在于服务端 apps/server/src/services/ftsSearchSync/ 与独立 CLI scripts/elasticsearchSync/(SKILL 还提示同步常驻受 FTS_SEARCH_SYNC_ENABLED 环境变量门控,该变量定义于 packages/env/src/ftsSearch.ts)。
上线顺序(cutover)红线
全量回填 ≠ 增量同步,回填本身不切换产品流量。 必须让应用继续跑在 PostgreSQL 上,直到:ES 别名(aliases)就绪 且 Outbox 已排空且保持稳定,随后才显式切换 provider。
在执行任何流程编排之前,请先通读自托管文档 docs/self-hosting/advanced/elasticsearch-migration.mdx(或其中文版 elasticsearch-migration.zh-CN.mdx)。当涉及数据库发布或索引成本影响设计时,SKILL 要求配合 db-migrations skill,并在真实 Dev 数据库上实测相关操作后再决定是否引入手工/延迟发布步骤。
Elasticsearch 环境变量与配置语义
SKILL 指明通用 ES 环境变量的归属在 packages/env/src/ftsSearch.ts。结合该文件与 apps/server/src/services/ftsSearch/index.ts 的 loadElasticsearchFtsSearchConfig 加载逻辑,可整理出完整的配置矩阵:
| 变量 | 类型/默认 | 说明 |
|---|---|---|
FTS_SEARCH_PROVIDER |
elasticsearch / pg_search,默认 pg_search |
部署级 provider 选择器 |
ES_URL |
URL(可选) | ES 端点;缺失则 ES 后端不可用 |
ES_API_KEY |
字符串(可选) | ES API Key;除 insecure 模式外必填,绝不通过明文 HTTP 传输 |
ES_INDEX_NAMESPACE |
字符串(可选) | 索引命名空间;开发环境(NODE_ENV=development)缺省为 lobehub-dev,生产必须显式配置 |
ES_ALLOW_INSECURE_HTTP |
'true' / 'false'(可选) |
显式放行私有容器网络上禁用安全的 ES 节点:允许对非 loopback 主机使用明文 http:// 并可省略 ES_API_KEY |
FTS_SEARCH_SYNC_ENABLED |
'true' / 'false'(可选) |
门控持续增量同步 |
配置加载的决策逻辑(来自 loadElasticsearchFtsSearchConfig):缺少 ES_URL 或索引命名空间即返回空;缺少 ES_API_KEY 且未开启 insecure 模式同样返回空——任一情况都会令 createFtsSearchRepo 在 provider 为 elasticsearch 时抛出 FtsSearchBackendUnavailableError,把"配置缺失"这一事实如实暴露给调用方,而不是悄悄回退。
可观测性与产品分析
SKILL 对"埋点放什么、放哪层"有严格边界,仓库实现集中在 apps/server/src/services/ftsSearch/observability.ts。
服务端指标:成本与延迟
服务端通过 OTel Meter/Tracer 采集,指标命名带 fts-search-backend / fts.search.backend 前缀,覆盖:
- 操作级:
fts_search_backend_operations_total、fts_search_backend_operation_duration,按provider × entity × operation × result分组;operation区分candidate_query、pg_hydration、product_path; - 结果漏斗:
fts_search_backend_result_count记录requested / candidate / product三阶段数量,用于观察"召回候选 → PG 水合后实际结果"的收敛; - ES 请求级:
fts_search_elasticsearch_requests_total及请求耗时、请求/响应字节、ES 服务端took、返回 hits 数等直方图,并记录error_code(如index_not_found、too_many_clauses、rate_limited、timeout等十余种); - 记忆检索决策:
fts_search_user_memory_lexical_decisions_total与查询字符数,来源限定为api | memory_extraction | tool | topic_retrieval。
标签有界原则:SKILL 明确 entity、provider、operation、outcome、粗粒度错误类型可以作标签;原始查询、用户 ID、文档 ID、索引内容一律不得作为标签(observability.ts 中 buildFtsSearchBackendMetricAttributes 的实现正是只取这五个有界维度)。usage(如 home_search、unified_search、knowledge_base、memory_tool、message_search)作为成本归因维度贯穿各计数器。
前端事件:用户体验
用户实际感知的搜索行为归口到开源的 Command Menu 埋点文件 src/features/CommandMenu/analytics.ts:产品分析可覆盖端到端时长、渲染结果数、空结果、结果点击与放弃(abandonment)。SKILL 给出的分工很明确——后端指标解释 provider 成本与延迟,前端事件解释用户体验,二者不可互相替代。
遥测失败的黄金法则
无论服务端还是前端,测量或分析失败绝不能改变所选 provider 的结果或错误。这一法则在代码中有双重落实:withFtsSearchBackendObservability 把埋点包装在业务调用之外,任何 recordSafely 捕获到的埋点异常只走 diag.error 日志;FtsSearchRepo.recordMeasurement 中 hook 的异常被 try/catch 后仅 console.error,且返回 Promise 的 hook 用 catch 吞掉拒绝——业务结果不受影响。
验证与测试策略
改动任何 provider、mapping、builder、capture、sync 或分析模块,SKILL 要求"在变更模块旁补充或更新聚焦测试",并长期保留三类边界测试:
- 调用方必须走
FtsSearchRepo(禁止绕过门面直连 provider); - provider 不得泄漏裸结果形状(混合/遗留路径必须经过 PG 水合与权限检查);
- 遥测失败不影响搜索行为(对应 apps/server/src/services/ftsSearch/observability.test.ts 与仓储层的 boundary 测试)。
每当以下路径被改动,必须补测:授权裁剪候选之后的分页、删除/作用域变更的优先级、Outbox 并发认领与陈旧结算、重试/死信行为、重建断点续跑的标识一致性、捕获定义不匹配(capture-definition mismatch)。仓储层的既有测试骨架可参考 packages/database/src/repositories/ftsSearch/tests/elasticsearch.test.ts、boundary.test.ts 与 packages/database/src/repositories/ftsSearch/elasticsearch/ 下成组的 *.test.ts。
本地验证命令(SKILL 原文):
# 从仓库根目录对改动文件运行全量静态检查(类型/lint 等)
bun run check <changed-files...>
涉及 migration 或数据库运行时变更时,SKILL 要求遵循 db-migrations 与 testing skill 的流程,并在真实 Dev 数据库上验证,而不是只在本地空库上想当然。
小结:给运维与开发者的三条行动准则
- 改实体 = 跨六层变更:从 packages/types/src/ftsSearch.ts 的实体清单、ftsSearchDocument 的 schema/mapping/builder、双后端行为、captureInfrastructure 的捕获函数,到重建 checkpoint 与自托管文档,任何一跳缺失都会造成索引与结果不一致;schema、mapping、fixtures 必须逐字段对齐。
- 切 ES 前先满足全部前置:跑
db:install-fts-search-capture安装定义校验过的捕获设施 →fts-search:reindex --status/--apply --yes完成可恢复全量回填 → 在 Outbox 稳定排空且别名就绪后,才通过FTS_SEARCH_PROVIDER=elasticsearch显式切换,全程不得静默回退 PG、不得在 provider 间做实体分流。 - 让错误与观测都"诚实":ES 配置缺失、provider 失败保持可见;埋点标签永远有界(entity/provider/operation/outcome/usage),遥测永远不影响搜索结果;遇到数据库/索引成本相关的取舍时,先实测再上线。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00