Product Full-Text Search
This skill covers search over LobeHub-owned product data such as agents, topics, messages, files,
knowledge bases, documents, chat groups, and memories. It does not cover the agent's external web
search providers under apps/server/src/services/search/ or builtin web-browsing tools.
Architecture
Product reads follow one stable path:
router/service -> createFtsSearchRepo -> FtsSearchRepo -> selected backend -> existing result schema
apps/server/src/services/ftsSearch/ owns request-scoped provider selection, Elasticsearch
configuration, its HTTP client, and backend telemetry. Routers and domain services must call
createFtsSearchRepo; they must not construct providers themselves.
packages/database/src/repositories/ftsSearch/ owns the provider-neutral contract and the concrete
PostgreSQL and Elasticsearch implementations. Keep public result shapes stable in FtsSearchRepo.
packages/types/src/ftsSearch.ts owns the shared searchable-entity list and search domain types.
packages/database/src/repositories/ftsSearchDocument/ owns Elasticsearch document schemas,
mappings, queryable fields, and source-to-document projection.
packages/database/src/schemas/ftsSearchSyncOutbox.ts and
packages/database/src/repositories/ftsSearchSyncOutbox/ own durable change capture, claims,
retries, dead letters, leases, revision fences, and capture-definition validation.
scripts/elasticsearchReindex/ owns the resumable full-backfill command and its operational
runtime. Shared database source queries and document construction remain in
packages/database/src/repositories/ftsSearchDocument/. apps/server/src/services/ftsSearchSync/ and
scripts/elasticsearchSync/ own continuous incremental draining.
packages/env/src/ftsSearch.ts owns generic Elasticsearch environment variables.
Provider and Permission Invariants
FTS_SEARCH_PROVIDER is a deployment-level provider selector with current values pg_search and
elasticsearch. It is not a feature flag or a user rollout. Add another enum value only when its
provider is implemented end to end.
- Elasticsearch errors, missing configuration, and unsupported candidate behavior must remain
visible. Never silently retry through PostgreSQL or add an
ilike fallback.
- Before selecting Elasticsearch, require coverage tests proving that it supports every entity in
the provider-neutral backend contract. Do not add per-entity routing between providers.
- Preserve
userId, workspaceId, and caller-agent visibility throughout every provider. Candidate
retrieval must not broaden the caller's scope.
- Where Elasticsearch only supplies candidate IDs, PostgreSQL hydration and parent checks remain
the authoritative permission boundary. Do not return raw Elasticsearch hits directly when the
existing result path requires database authorization or hydration.
- Provider implementations return the existing hydrated response types and ordering contract. Do
not make routers understand provider-specific result shapes.
Changing a Searchable Entity
Treat an entity addition or projection change as one cross-layer change. Inspect and update every
applicable item:
FTS_SEARCH_DOCUMENT_ENTITIES and shared request/result types.
- Search document schema, mapping, text fields, filters, fixtures, and mapping parity tests.
FtsSearchDocumentBuilder, including soft deletion and fanout from related source rows.
- PostgreSQL and Elasticsearch backend behavior, permission hydration, pagination, and ranking.
- Capture functions/triggers or fanout queries in
captureInfrastructure.ts.
- Reindex checkpoints, Outbox draining, metrics, and self-host documentation.
Schema fields, mappings, builders, and fixed fixtures must agree. Current mapping fields plus
explicit FTS_SEARCH_RETAINED_SOURCE_PROPERTIES must equal the current Zod fields, without overlap.
Retained source properties keep older open indexes writable during field removals; keep the builder
producing them until those indexes are closed. They must not appear in current query metadata.
A field outside the document schema must not appear in a mapping or query field list.
Elasticsearch multi_match query length is bounded by a shared leaf-clause budget divided by the
selected query-field count. Adding a field reduces that entity's safe query length, and changing a
query analyzer to emit multiple terms per Unicode code point requires revisiting the budget and its
field-count regression tests.
Capture, Reindex, and Sync
- Regular database migrations own durable schema: the revision sequence, Outbox table and indexes,
and schema-managed source indexes such as the Memory fanout GIN index.
- Capture functions and triggers are installed only when an operator explicitly enables the
Elasticsearch path.
installCaptureInfrastructure() is transactional, definition-checked,
idempotent for an exact installation, and fail-closed for partial or altered definitions.
- Do not install capture for every PostgreSQL-only instance. Do not move a normal schema index into
the runtime installer merely because it supports capture.
- The Outbox coalesces by
(entity, document_id). A newer capture resets retry/dead-letter state and
allocates a new revision only after locking the conflicting row, preserving same-document commit
order.
- Sequence allocation is non-transactional. Use the existing write fences and committed revision
boundary; never treat
last_value alone as proof that all earlier Outbox rows are visible.
- Claims use the precise lease timestamp as a fencing token. A stale worker must not acknowledge,
fail, or release work reclaimed by another worker.
- Permanent failures and exhausted retries become durable dead letters. A drain that creates or
observes dead work must fail instead of continuing to publish a successful cutover signal.
- Full backfill does not replace continuous sync. For the initial PostgreSQL-to-Elasticsearch
cutover, keep the application on PostgreSQL until aliases are ready and the Outbox is empty and
stable, then switch explicitly. Later mapping upgrades keep searches on the existing Elasticsearch
alias until promotion.
The supported operator entrypoints below run from the OSS repository root. A wrapping repository
may expose different package scripts; check its package.json before invoking these or the mapping
migration commands linked below.
bun run db:install-fts-search-capture
bun run fts-search:reindex -- --status
bun run fts-search:reindex -- --apply --yes
bun run fts-search:sync -- --max-steps=8 --yes
bun run scripts/pgSearchCleanup/index.ts --status
bun run scripts/pgSearchCleanup/index.ts --apply --yes
Mapping Generations
Docker startup runs fts-search-elasticsearch-reindex.cjs --startup --yes after PostgreSQL
migrations when the selected provider is Elasticsearch. It blocks serving until index migration
and catch-up succeed. Persist the same checkpoint volume across application and migration
containers; failed namespace locks still require explicit recovery. Continuous sync remains a
separate worker. Hosted and manual workflows keep their explicit migration commands.
For mapping changes, generation operations, repeat/resume behavior, or deployment integration, read
Mapping migration workflow. It routes to the public command guide
and adds recovery, automation, and local Docker rehearsal rules.
Elasticsearch cannot change an existing field's type or index-time analyzer in place. The code
declares the target and Elasticsearch records the live state, per entity:
FTS_SEARCH_INDEX_DEFINITIONS[entity].schemaVersion is the declared generation; the fingerprint is
sha256 of the mapping plus the shared analysis. Add complete changed-entity definitions in a new
ftsSearchDocument/migration/NNNN-meaningful-name/ batch, register it and update current pointers
in migration/index.ts. Append expected fingerprints in __tests__/schemaSnapshots.json; preserve
published batches and baselines. mappings.test.ts and migration/index.test.ts check current
parity, history, version bumps and fingerprint changes. See migration/README.md for ownership.
Shared analysis changes alter
every entity fingerprint and classify as breaking for every entity; bump all affected versions
and rebuild rather than using in-place upgrades.
- Every physical index
<alias>-v<n> carries _meta.{reindex_run_id, schema_version, schema_fingerprint}; the alias marks the live generation. Indexes created before fingerprints
existed may omit the fingerprint, but still need a valid run ID and schema version for sync
readiness. _meta.schema_version wins over the -v<n> suffix because an in-place upgrade advances
_meta without renaming the index.
- The sync runtime accepts an alias that still serves an older generation (upgrade in progress) and
refuses one that serves a newer generation or a different fingerprint of the declared version. It
writes every change to all open
<alias>-v* indexes plus the alias write index, pruning each
document to the fields that index maps (every generation is dynamic: strict), so a new
generation can be backfilled beside the live one; a bulk work item is acknowledged only when
every existing generation accepted it (2xx or 409 conflict).
- One reindex checkpoint per
(namespace, schemaVersion) covers the entities on that generation.
--apply groups the requested entities by declared version, treats existing aliases as an
upgrade (no --fresh-run), leaves existing aliases in place, and emits promotion_pending. A
completed first install creates aliases. Promoting a newer generation requires a completed
checkpoint, a fingerprint match when targeting the declared version, and an idle target-entity Outbox. Rollback
to an older stamped generation can use its metadata without a retained checkpoint;
--retire requires in_sync and only closes old generations; explicit --purge installs
exact-index templates forbidding auto-creation before deleting eligible closed generations.
--in-place requires
mappingChange: additive, widens the live index with PUT _mapping, pins the checkpoint to that
index, and backfills with external_gte so concurrent sync writes win.
- Checkpoints are local files, not Drizzle migration history. Preserve
ES_REINDEX_STATE_DIR across
invocations. Completed runs skip backfill; incomplete runs resume from saved cursors. Mutating CLI
commands share a non-expiring Elasticsearch namespace lock, independent of checkpoint location.
A failed command retains its lock when its outcome may be uncertain. Before --release-lock=<owner> --yes, stop the previous process and resolve pending requests; never assume age proves it stopped.
The lock does not serialize old binaries or external operator actions.
- Reconciliation is exact only on a first install; once an alias serves an entity, concurrent sync
writes make a higher Elasticsearch count legitimate and only a shortfall fails.
scripts/elasticsearchReindex/runtime/generationService.ts owns classification (missing,
unmanaged, in_sync, drift, upgrade_available, rollback_required), promotion, and
retirement; keep them free of Cloud-specific policy.
Read docs/self-hosting/advanced/elasticsearch-migration.mdx or its Chinese counterpart before
changing the operational sequence. When database rollout or index cost affects the design, also
use the db-migrations skill and measure the relevant operation on the actual Dev database before
adding manual or deferred release steps.
Observability and Product Analytics
- Server provider metrics and traces live in
apps/server/src/services/ftsSearch/observability.ts.
Keep labels bounded: entity, provider, operation, outcome, and coarse error type are acceptable;
raw queries, user IDs, document IDs, and index contents are not.
- User-perceived search behavior lives with the open-source Command Menu in
src/features/CommandMenu/analytics.ts. Product analytics may cover end-to-end duration, rendered
result counts, empty results, result clicks, and abandonment without a Cloud business slot.
- Measurement or analytics failures must never alter the selected provider's result or error.
- Backend metrics explain provider cost and latency; frontend events explain what the user actually
experienced. Do not substitute one for the other.
Validation
- Add or update focused tests beside every changed provider, mapping, builder, capture, sync, or
analytics module.
- Preserve boundary tests proving callers use
FtsSearchRepo, providers cannot leak raw result shapes,
and telemetry failures do not affect search behavior.
- Test pagination after authorization drops candidates, delete/scope-change priority, concurrent
Outbox claims and stale settlements, retry/dead-letter behavior, reindex resume identity, and
capture-definition mismatch whenever those paths change.
- Run
bun run check <changed-files...> from the repository root. For migration or database-runtime
changes, follow the db-migrations and testing skills and verify against the actual Dev database.
1---2name: full-text-search3description: Use for product search: FtsSearchRepo, pg_search/Elasticsearch, mapping migrations, projections, Outbox sync, reindexing and performance. Excludes agent web search.4---5
6# Product Full-Text Search
7
8This skill covers search over LobeHub-owned product data such as agents, topics, messages, files,
9knowledge bases, documents, chat groups, and memories. It does not cover the agent's external web
10search providers under `apps/server/src/services/search/` or builtin web-browsing tools.
11
12## Architecture
13
14Product reads follow one stable path:
15
16```text
17router/service -> createFtsSearchRepo -> FtsSearchRepo -> selected backend -> existing result schema
18```
19
20- `apps/server/src/services/ftsSearch/` owns request-scoped provider selection, Elasticsearch
21 configuration, its HTTP client, and backend telemetry. Routers and domain services must call
22 `createFtsSearchRepo`; they must not construct providers themselves.
23- `packages/database/src/repositories/ftsSearch/` owns the provider-neutral contract and the concrete
24 PostgreSQL and Elasticsearch implementations. Keep public result shapes stable in `FtsSearchRepo`.
25- `packages/types/src/ftsSearch.ts` owns the shared searchable-entity list and search domain types.
26- `packages/database/src/repositories/ftsSearchDocument/` owns Elasticsearch document schemas,
27 mappings, queryable fields, and source-to-document projection.
28- `packages/database/src/schemas/ftsSearchSyncOutbox.ts` and
29 `packages/database/src/repositories/ftsSearchSyncOutbox/` own durable change capture, claims,
30 retries, dead letters, leases, revision fences, and capture-definition validation.
31- `scripts/elasticsearchReindex/` owns the resumable full-backfill command and its operational
32 runtime. Shared database source queries and document construction remain in
33 `packages/database/src/repositories/ftsSearchDocument/`. `apps/server/src/services/ftsSearchSync/` and
34 `scripts/elasticsearchSync/` own continuous incremental draining.
35- `packages/env/src/ftsSearch.ts` owns generic Elasticsearch environment variables.
36
37## Provider and Permission Invariants
38
39- `FTS_SEARCH_PROVIDER` is a deployment-level provider selector with current values `pg_search` and
40 `elasticsearch`. It is not a feature flag or a user rollout. Add another enum value only when its
41 provider is implemented end to end.
42- Elasticsearch errors, missing configuration, and unsupported candidate behavior must remain
43 visible. Never silently retry through PostgreSQL or add an `ilike` fallback.
44- Before selecting Elasticsearch, require coverage tests proving that it supports every entity in
45 the provider-neutral backend contract. Do not add per-entity routing between providers.
46- Preserve `userId`, `workspaceId`, and caller-agent visibility throughout every provider. Candidate
47 retrieval must not broaden the caller's scope.
48- Where Elasticsearch only supplies candidate IDs, PostgreSQL hydration and parent checks remain
49 the authoritative permission boundary. Do not return raw Elasticsearch hits directly when the
50 existing result path requires database authorization or hydration.
51- Provider implementations return the existing hydrated response types and ordering contract. Do
52 not make routers understand provider-specific result shapes.
53
54## Changing a Searchable Entity
55
56Treat an entity addition or projection change as one cross-layer change. Inspect and update every
57applicable item:
58
591. `FTS_SEARCH_DOCUMENT_ENTITIES` and shared request/result types.
602. Search document schema, mapping, text fields, filters, fixtures, and mapping parity tests.
613. `FtsSearchDocumentBuilder`, including soft deletion and fanout from related source rows.
624. PostgreSQL and Elasticsearch backend behavior, permission hydration, pagination, and ranking.
635. Capture functions/triggers or fanout queries in `captureInfrastructure.ts`.
646. Reindex checkpoints, Outbox draining, metrics, and self-host documentation.
65
66Schema fields, mappings, builders, and fixed fixtures must agree. Current mapping fields plus
67explicit `FTS_SEARCH_RETAINED_SOURCE_PROPERTIES` must equal the current Zod fields, without overlap.
68Retained source properties keep older open indexes writable during field removals; keep the builder
69producing them until those indexes are closed. They must not appear in current query metadata.
70A field outside the document schema must not appear in a mapping or query field list.
71
72Elasticsearch `multi_match` query length is bounded by a shared leaf-clause budget divided by the
73selected query-field count. Adding a field reduces that entity's safe query length, and changing a
74query analyzer to emit multiple terms per Unicode code point requires revisiting the budget and its
75field-count regression tests.
76
77## Capture, Reindex, and Sync
78
79- Regular database migrations own durable schema: the revision sequence, Outbox table and indexes,
80 and schema-managed source indexes such as the Memory fanout GIN index.
81- Capture functions and triggers are installed only when an operator explicitly enables the
82 Elasticsearch path. `installCaptureInfrastructure()` is transactional, definition-checked,
83 idempotent for an exact installation, and fail-closed for partial or altered definitions.
84- Do not install capture for every PostgreSQL-only instance. Do not move a normal schema index into
85 the runtime installer merely because it supports capture.
86- The Outbox coalesces by `(entity, document_id)`. A newer capture resets retry/dead-letter state and
87 allocates a new revision only after locking the conflicting row, preserving same-document commit
88 order.
89- Sequence allocation is non-transactional. Use the existing write fences and committed revision
90 boundary; never treat `last_value` alone as proof that all earlier Outbox rows are visible.
91- Claims use the precise lease timestamp as a fencing token. A stale worker must not acknowledge,
92 fail, or release work reclaimed by another worker.
93- Permanent failures and exhausted retries become durable dead letters. A drain that creates or
94 observes dead work must fail instead of continuing to publish a successful cutover signal.
95- Full backfill does not replace continuous sync. For the initial PostgreSQL-to-Elasticsearch
96 cutover, keep the application on PostgreSQL until aliases are ready and the Outbox is empty and
97 stable, then switch explicitly. Later mapping upgrades keep searches on the existing Elasticsearch
98 alias until promotion.
99
100The supported operator entrypoints below run from the OSS repository root. A wrapping repository
101may expose different package scripts; check its `package.json` before invoking these or the mapping
102migration commands linked below.
103
104```bash
105bun run db:install-fts-search-capture
106bun run fts-search:reindex -- --status
107bun run fts-search:reindex -- --apply --yes
108bun run fts-search:sync -- --max-steps=8 --yes
109bun run scripts/pgSearchCleanup/index.ts --status
110bun run scripts/pgSearchCleanup/index.ts --apply --yes
111```
112
113## Mapping Generations
114
115Docker startup runs `fts-search-elasticsearch-reindex.cjs --startup --yes` after PostgreSQL
116migrations when the selected provider is Elasticsearch. It blocks serving until index migration
117and catch-up succeed. Persist the same checkpoint volume across application and migration
118containers; failed namespace locks still require explicit recovery. Continuous sync remains a
119separate worker. Hosted and manual workflows keep their explicit migration commands.
120
121For mapping changes, generation operations, repeat/resume behavior, or deployment integration, read
122[Mapping migration workflow](references/mapping-migrations.md). It routes to the public command guide
123and adds recovery, automation, and local Docker rehearsal rules.
124
125Elasticsearch cannot change an existing field's type or index-time analyzer in place. The code
126declares the target and Elasticsearch records the live state, per entity:
127
128- `FTS_SEARCH_INDEX_DEFINITIONS[entity].schemaVersion` is the declared generation; the fingerprint is
129 `sha256` of the mapping plus the shared analysis. Add complete changed-entity definitions in a new
130 `ftsSearchDocument/migration/NNNN-meaningful-name/` batch, register it and update current pointers
131 in `migration/index.ts`. Append expected fingerprints in `__tests__/schemaSnapshots.json`; preserve
132 published batches and baselines. `mappings.test.ts` and `migration/index.test.ts` check current
133 parity, history, version bumps and fingerprint changes. See `migration/README.md` for ownership.
134 Shared analysis changes alter
135 every entity fingerprint and classify as breaking for every entity; bump all affected versions
136 and rebuild rather than using in-place upgrades.
137- Every physical index `<alias>-v<n>` carries `_meta.{reindex_run_id, schema_version,
138schema_fingerprint}`; the alias marks the live generation. Indexes created before fingerprints
139 existed may omit the fingerprint, but still need a valid run ID and schema version for sync
140 readiness. `_meta.schema_version` wins over the `-v<n>` suffix because an in-place upgrade advances
141 `_meta` without renaming the index.
142- The sync runtime accepts an alias that still serves an older generation (upgrade in progress) and
143 refuses one that serves a newer generation or a different fingerprint of the declared version. It
144 writes every change to all open `<alias>-v*` indexes plus the alias write index, pruning each
145 document to the fields that index maps (every generation is `dynamic: strict`), so a new
146 generation can be backfilled beside the live one; a bulk work item is acknowledged only when
147 every existing generation accepted it (2xx or 409 conflict).
148- One reindex checkpoint per `(namespace, schemaVersion)` covers the entities on that generation.
149 `--apply` groups the requested entities by declared version, treats existing aliases as an
150 upgrade (no `--fresh-run`), leaves existing aliases in place, and emits `promotion_pending`. A
151 completed first install creates aliases. Promoting a newer generation requires a completed
152 checkpoint, a fingerprint match when targeting the declared version, and an idle target-entity Outbox. Rollback
153 to an older stamped generation can use its metadata without a retained checkpoint;
154 `--retire` requires `in_sync` and only closes old generations; explicit `--purge` installs
155 exact-index templates forbidding auto-creation before deleting eligible closed generations.
156 `--in-place` requires
157 `mappingChange: additive`, widens the live index with `PUT _mapping`, pins the checkpoint to that
158 index, and backfills with `external_gte` so concurrent sync writes win.
159- Checkpoints are local files, not Drizzle migration history. Preserve `ES_REINDEX_STATE_DIR` across
160 invocations. Completed runs skip backfill; incomplete runs resume from saved cursors. Mutating CLI
161 commands share a non-expiring Elasticsearch namespace lock, independent of checkpoint location.
162 A failed command retains its lock when its outcome may be uncertain. Before `--release-lock=<owner> --yes`, stop the previous process and resolve pending requests; never assume age proves it stopped.
163 The lock does not serialize old binaries or external operator actions.
164- Reconciliation is exact only on a first install; once an alias serves an entity, concurrent sync
165 writes make a higher Elasticsearch count legitimate and only a shortfall fails.
166- `scripts/elasticsearchReindex/runtime/generationService.ts` owns classification (`missing`,
167 `unmanaged`, `in_sync`, `drift`, `upgrade_available`, `rollback_required`), promotion, and
168 retirement; keep them free of Cloud-specific policy.
169
170Read `docs/self-hosting/advanced/elasticsearch-migration.mdx` or its Chinese counterpart before
171changing the operational sequence. When database rollout or index cost affects the design, also
172use the `db-migrations` skill and measure the relevant operation on the actual Dev database before
173adding manual or deferred release steps.
174
175## Observability and Product Analytics
176
177- Server provider metrics and traces live in `apps/server/src/services/ftsSearch/observability.ts`.
178 Keep labels bounded: entity, provider, operation, outcome, and coarse error type are acceptable;
179 raw queries, user IDs, document IDs, and index contents are not.
180- User-perceived search behavior lives with the open-source Command Menu in
181 `src/features/CommandMenu/analytics.ts`. Product analytics may cover end-to-end duration, rendered
182 result counts, empty results, result clicks, and abandonment without a Cloud business slot.
183- Measurement or analytics failures must never alter the selected provider's result or error.
184- Backend metrics explain provider cost and latency; frontend events explain what the user actually
185 experienced. Do not substitute one for the other.
186
187## Validation
188
189- Add or update focused tests beside every changed provider, mapping, builder, capture, sync, or
190 analytics module.
191- Preserve boundary tests proving callers use `FtsSearchRepo`, providers cannot leak raw result shapes,
192 and telemetry failures do not affect search behavior.
193- Test pagination after authorization drops candidates, delete/scope-change priority, concurrent
194 Outbox claims and stale settlements, retry/dead-letter behavior, reindex resume identity, and
195 capture-definition mismatch whenever those paths change.
196- Run `bun run check <changed-files...>` from the repository root. For migration or database-runtime
197 changes, follow the `db-migrations` and `testing` skills and verify against the actual Dev database.