Skip to content
This repository was archived by the owner on Oct 14, 2026. It is now read-only.

Commit 3479136

Browse files
tb-adbeTanase Butcaru
andauthored
feat(data-access): batched semantic index writer and suggestion lookup (LLMO-7445) (#1966)
<!-- mysticat-pr-skill --> ## 1. Abstract Moves the semantic-index helpers to the redesigned tables: one batched writer and two readers for opportunities and suggestions. The type registries are removed from utils. ## 2. Reasoning The first version of the semantic index had separate shapes per table, a `source_type` column, and type registries in utils, so every new producer or text kind needed a shared release. Each producer would also have had to embed, batch, and dedupe on its own. The data-service redesign ([#1153](adobe/mysticat-data-service#1153)) gives all three tables the same text columns and splits `source_type` into `match_type` and `match_field_type`. This PR moves the shared helpers to that shape and puts the embed and write logic in one place. ## 3. High-level overview of the changes **utils** - Removes `OPPORTUNITY_SEMANTIC_SOURCE_TYPES`, `OPPORTUNITY_SEMANTIC_ENTITY_TYPES`, and the suggestion equivalents, along with their `.d.ts` types. This reverts #1959. **data-access** (`semantic-index.utils.js`) - **The match type is never a parameter.** Each dimension has its own functions that set `match_type` internally, so callers can't index or search under the wrong lookup. Today that's topics; claims follow the same pattern when that dimension lands. `SEMANTIC_MATCH_TYPES` is internal. - `indexSemanticTopics(pg, embeddingClient, { target, siteId, entities: [{ entityId, entityType, fields: [{ matchFieldType, texts }] }] })` replaces `syncOpportunitySemantic`. `target` is `opportunity` or `suggestion`. - It full-replaces each (entity, `matchFieldType`) group: it keeps unchanged texts, embeds and inserts new ones, and deletes stale ones. Fields that aren't listed (and other match types) are left alone, and `texts: []` clears a field. - A stored row from another embedding model or dims counts as stale: it is re-embedded and overwritten in place on the entity's next refresh (a model change is a hard cutover per entity; mysticat-data-service#1153 review). - Texts are deduped across entities, looked up in the query cache first, and the misses are embedded in batches of 256. - It reads `semantic_query_embedding` but never writes to it. - `embedQueries(pg, embeddingClient, texts, { timeoutMs, log })` is the query side. It normalizes the texts, serves cached vectors, embeds the distinct misses in one call within a time budget, and returns the cache writes as a best-effort `cacheWrites` promise. - It rejects empty or over-2048-character texts (once normalized) and more than `MAX_QUERY_TEXTS` (256) texts with a `ValidationError`, and treats a cache read failure as all misses (logged and returned as `cacheError`). - `lookupOpportunitiesByTopic` and the new `lookupSuggestionsByTopic` replace `lookupOpportunitiesByVectors`. They take optional `matchFieldTypes` / `entityTypes` filters (free-form, deduped, max 100 values). - `lookupSuggestionsByTopic` checks `statuses` against `Suggestion.STATUSES` and adds `opportunityStatuses`. - No function takes the embedding generation: the writer, `embedQueries` and both lookups read `SEMANTIC_MATCHING_CONFIG.embedding` themselves. - `siteId` and `entityId` must be UUIDs, and each writer field reports a `rejected` count (texts dropped as empty or too long). - `entityType` and `matchFieldType` are free-form, length-bounded strings, so there are no registries left. - New exports: `SEMANTIC_TARGETS`, `SEMANTIC_SEARCH_RPCS` (target → RPC), `MAX_QUERY_TEXTS` and `EmbeddingUnavailableError`. - `getQueryEmbeddings`, `upsertQueryEmbeddings` and `touchQueryEmbeddings` are now internal, so only `embedQueries` writes the cache that the writer and readers trust. The cache row stores the normalized `text`. **Release type.** This is a breaking change to the semantic API in both packages, released as `feat` (minor). No external code consumes these helpers yet: the writer in audit-worker is behind `OFFSITE_SEMANTIC_INDEX_ENABLED` (off), consumers pin exact versions, and their PRs adopting this API come next. The old helpers also stop working once mysticat-data-service#1153 runs. Removed exports (to list in the squash commit body): - utils: `OPPORTUNITY_SEMANTIC_SOURCE_TYPES`, `OPPORTUNITY_SEMANTIC_ENTITY_TYPES`, `SUGGESTION_SEMANTIC_SOURCE_TYPES`, `SUGGESTION_SEMANTIC_ENTITY_TYPES` and their `.d.ts` types; - data-access: `syncOpportunitySemantic`, `lookupOpportunitiesByVectors`, `SEMANTIC_SEARCH_RPC`, the re-exported `OPPORTUNITY_SEMANTIC_SOURCE_TYPES` / `OPPORTUNITY_SEMANTIC_ENTITY_TYPES`, and the cache helpers `getQueryEmbeddings` / `upsertQueryEmbeddings` / `touchQueryEmbeddings` (no consumer imports them); the lookups no longer accept `model` / `dims`. ## 4. Required information - Jira / issue: [LLMO-7445](https://jira.corp.adobe.com/browse/LLMO-7445) - ADR(s): `docs/adr/0002-embedding-client-and-semantic-index-utils.md` (Decision 4 amended; outdated paragraphs marked superseded) - Other: [mysticat-data-service#1153](adobe/mysticat-data-service#1153) (table and RPC redesign) ## 6. Affected / used mysticat-workspace projects - mysticat-data-service: contract. The helpers write the redesigned tables and call the new RPC signatures (`p_match_type`, `p_match_field_types`) from #1153. - spacecat-audit-worker: consumed. It switches its writer to `indexSemanticTopics` when it bumps data-access ([spacecat-audit-worker#27](https://github.com/Adobe-AEM-Sites/spacecat-audit-worker/pull/27), draft; the writer is flag-off today). - spacecat-api-service: consumed. The by-topics endpoint switches to `embedQueries`, `lookupOpportunitiesByTopic` and `matchFieldTypes`, and drops `sourceTypes`, when it bumps data-access ([spacecat-api-service#79](https://github.com/Adobe-AEM-Sites/spacecat-api-service/pull/79), draft). ## 8. Test plan (a) Nothing beyond unit tests yet. The helpers are first exercised end to end when the audit-worker and api-service follow-ups run against a local data-service with #1153 applied. (b) dev: after #1153 is deployed and the consumer PRs ship, enable `OFFSITE_SEMANTIC_INDEX_ENABLED` and run an offsite audit. Check that rows land in `opportunity_semantic_embedding` with `match_type='topic'`, then call the by-topics endpoint and confirm it returns matches. ## 9. Deployment & merge order - [mysticat-data-service#1153](adobe/mysticat-data-service#1153): depends-on. Its RPC signatures must be deployed before any consumer uses this release. - spacecat-audit-worker / spacecat-api-service follow-ups: blocked by this PR (they bump to its release). Order: data-service #1153 → this PR (release) → audit-worker + api-service bumps. 🤖 Generated with [review-kit](https://github.com/adobe/experience-success-skills) --------- Co-authored-by: Tanase Butcaru <tbutcaru@adobe.com>
1 parent c784590 commit 3479136

12 files changed

Lines changed: 1356 additions & 996 deletions

File tree

‎docs/adr/0002-embedding-client-and-semantic-index-utils.md‎

Lines changed: 23 additions & 10 deletions
Large diffs are not rendered by default.

‎packages/spacecat-shared-data-access/README.md‎

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -142,45 +142,58 @@ See the package `CLAUDE.md` ("URL Index") for the canonicalization, single-write
142142

143143
## Semantic Index
144144

145-
For the topic vector index (`opportunity_semantic_embedding`) and the global query-embedding cache (`semantic_query_embedding`), use the shared helpers so the writer and reader share one text normalization and one storage path. They store and read pre-embedded vectors; embedding is the caller's job (e.g. `AzureEmbeddingClient` from `@adobe/spacecat-shared-gpt-client`).
145+
For the vector indexes (`opportunity_semantic_embedding`, `suggestion_semantic_embedding`) and the global query-embedding cache (`semantic_query_embedding`), use the shared helpers so the writer and reader share one text normalization, one embedding generation and one storage path. The helpers take an embedding client with `createEmbeddings(texts)` (e.g. `AzureEmbeddingClient` from `@adobe/spacecat-shared-gpt-client`) and do the embedding themselves.
146146

147147
```js
148148
import {
149-
syncOpportunitySemantic, lookupOpportunitiesByVectors,
150-
getQueryEmbeddings, upsertQueryEmbeddings, touchQueryEmbeddings,
151-
OPPORTUNITY_SEMANTIC_SOURCE_TYPES, OPPORTUNITY_SEMANTIC_ENTITY_TYPES, SEMANTIC_MATCHING_CONFIG,
149+
indexSemanticTopics, embedQueries,
150+
lookupOpportunitiesByTopic, lookupSuggestionsByTopic,
151+
SEMANTIC_TARGETS,
152152
} from '@adobe/spacecat-shared-data-access';
153153
154154
const { postgrestClient } = dataAccess.services;
155-
const scope = { ...SEMANTIC_MATCHING_CONFIG.embedding }; // { model, dims }
156-
const { TOPIC } = OPPORTUNITY_SEMANTIC_SOURCE_TYPES;
157-
const { CITED_ANALYSIS } = OPPORTUNITY_SEMANTIC_ENTITY_TYPES;
158155
159-
// writer (needs the postgrest_writer role): full-replace an opportunity's topic vectors
160-
await syncOpportunitySemantic(postgrestClient, {
161-
siteId, entityId, entityType: CITED_ANALYSIS, sourceType: TOPIC, sources: [{ text, vector, ...scope }],
156+
// writer (needs the postgrest_writer role): full-replace each (entity, matchFieldType) topic group
157+
// in one call. Unchanged texts are kept, new ones embedded in batches (cache-first), stale ones
158+
// deleted; fields not listed are left alone. texts: [] clears a field.
159+
const result = await indexSemanticTopics(postgrestClient, embeddingClient, {
160+
target: SEMANTIC_TARGETS.OPPORTUNITY, // or SEMANTIC_TARGETS.SUGGESTION
161+
siteId,
162+
entities: [{
163+
entityId, entityType: 'cited-analysis',
164+
fields: [{ matchFieldType: 'topic', texts: ['running shoes'] }],
165+
}],
162166
});
167+
// result: { entities: [{ entityId, fields: [{ matchFieldType, rejected, inserted, deleted, unchanged, ... }] }],
168+
// embedded, cacheHits, cacheError? }
163169
164-
// query cache, batched: one entry per text, in input order (null on a miss)
165-
const cached = await getQueryEmbeddings(postgrestClient, { texts, ...scope });
166-
await upsertQueryEmbeddings(postgrestClient, { entries: [{ text, vector }], ...scope });
167-
await touchQueryEmbeddings(postgrestClient, { textHashes, ...scope });
170+
// query side: validate (at most MAX_QUERY_TEXTS texts, each up to 2048 chars once normalized), read the
171+
// cache (best-effort), embed the distinct misses in one call within a time budget.
172+
// cacheWrites (touch hits + store misses) is best-effort; await it or hand it to waitUntil.
173+
const { vectors, cacheWrites, cacheError } = await embedQueries(postgrestClient, embeddingClient, texts, { log });
168174
169175
// reader, batched: one best-first list of { entityId, entityType, score } per query vector.
170-
// entityTypes is optional (omitted or [] searches all entity types). statuses is optional too
171-
// (omitted or [] searches all; values from Opportunity.STATUSES); it is applied before k, so
172-
// filtered-out opportunities never take result slots. Needs a data-service with p_statuses.
173-
const matches = await lookupOpportunitiesByVectors(postgrestClient, {
174-
siteId, sourceTypes: [TOPIC], entityTypes: [CITED_ANALYSIS], statuses: ['NEW', 'IN_PROGRESS'],
175-
vectors, k: 10, minScore: 0.5, ...scope,
176+
// matchFieldTypes / entityTypes / statuses are optional filters (omitted or [] means no filter).
177+
// statuses is applied before k, so filtered-out rows never take result slots.
178+
// Searches run in the SEMANTIC_MATCHING_CONFIG.embedding generation, like the writer.
179+
const opportunities = await lookupOpportunitiesByTopic(postgrestClient, {
180+
siteId, matchFieldTypes: ['topic'], entityTypes: ['cited-analysis'],
181+
statuses: ['NEW', 'IN_PROGRESS'], vectors, k: 10, minScore: 0.5,
182+
});
183+
// suggestions: entityTypes are parent opportunity types, statuses are Suggestion.STATUSES,
184+
// opportunityStatuses filter on the parent opportunity.
185+
const suggestions = await lookupSuggestionsByTopic(postgrestClient, {
186+
siteId, opportunityStatuses: ['NEW'], vectors,
176187
});
177188
```
178189

179-
`OPPORTUNITY_SEMANTIC_SOURCE_TYPES` (kinds of embedded text) and `OPPORTUNITY_SEMANTIC_ENTITY_TYPES` (opportunity types) are the registries for `source_type` and `entity_type`: the writer and reader reject any value outside them. They are defined in `@adobe/spacecat-shared-utils` and re-exported here. To index a new kind or opportunity type, add it to the utils registry first.
190+
The match type (`topic`, later `claim`) is never a parameter: each dimension has its own writer and readers (`...Topics` / `...ByTopic`), which set `match_type` internally. `matchFieldType` (which field the text came from) and `entityType` are free-form, length-bounded strings: there is no registry, so a producer can index a new field or entity type without a shared release.
191+
192+
`SEMANTIC_MATCHING_CONFIG.embedding` (`model`, `dims`) is the embedding generation the writer and reader both use; the writer, `embedQueries` and the lookups read it from there rather than taking it as a parameter. It is a code constant, not env config, so writer and reader agree as long as both run the same data-access version; changing it means a re-embed plus upgrading both consumers. The re-embed is automatic: the writer treats rows from another generation as stale and overwrites them on the entity's next refresh.
180193
181-
`SEMANTIC_MATCHING_CONFIG.embedding` (`model`, `dims`) is the embedding generation the writer and reader both use; spread it into the helpers rather than hardcoding the values. It is a code constant, not env config, so writer and reader agree as long as both run the same data-access version; changing it means a re-embed plus upgrading both consumers.
194+
The writer reads the query cache but never writes it; only `embedQueries` does. The low-level cache helpers are internal, so nothing outside data-access can write vectors that the writer and readers trust.
182195
183-
The batched helpers group their calls internally (search in groups of up to 20 vectors, cache reads/touches in groups of 50 hashes), so callers pass whole lists rather than looping per item.
196+
Calls are grouped internally (embeddings in batches of 256, writes and searches in groups of 20, id/hash reads and deletes in groups of 50), so callers pass whole lists rather than looping per item.
184197
185198
## Field Mapping Behavior
186199

‎packages/spacecat-shared-data-access/src/service/index.js‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,25 +52,30 @@ export * from '../models/index.js';
5252
export * from '../util/index.js';
5353
// Not in util/index.js: semantic-index imports the Opportunity model, which imports util/index.js.
5454
export {
55+
SEMANTIC_TARGETS,
5556
SEMANTIC_INDEX_TABLES,
57+
SEMANTIC_SEARCH_RPCS,
5658
QUERY_EMBEDDING_TABLE,
57-
SEMANTIC_SEARCH_RPC,
5859
SEMANTIC_CHUNK_SIZE,
5960
QUERY_HASH_CHUNK_SIZE,
61+
EMBEDDING_BATCH_SIZE,
62+
MAX_QUERY_TEXTS,
6063
MAX_SOURCE_TEXT_LENGTH,
61-
OPPORTUNITY_SEMANTIC_SOURCE_TYPES,
62-
OPPORTUNITY_SEMANTIC_ENTITY_TYPES,
64+
MAX_MATCH_FIELD_TYPE_LENGTH,
65+
MAX_ENTITY_TYPE_LENGTH,
66+
MAX_TYPE_FILTER_ITEMS,
67+
EMBEDDING_TIMEOUT_MS,
6368
SEMANTIC_MATCHING_CONFIG,
69+
EmbeddingUnavailableError,
6470
normalizeText,
6571
hashText,
6672
cleanTopicText,
6773
serializeVector,
6874
parseVector,
69-
syncOpportunitySemantic,
70-
lookupOpportunitiesByVectors,
71-
getQueryEmbeddings,
72-
upsertQueryEmbeddings,
73-
touchQueryEmbeddings,
75+
indexSemanticTopics,
76+
embedQueries,
77+
lookupOpportunitiesByTopic,
78+
lookupSuggestionsByTopic,
7479
} from '../util/semantic-index.utils.js';
7580

7681
const createPostgrestService = (config, client = undefined) => {

0 commit comments

Comments
 (0)