A one-person SaaS cannot afford to bind catalog ingestion, retrieval, and generation to one provider. The weekly shipping cadence matters more than squeezing one more feature into the first release. For messy product descriptions, I would choose semantic matching as the primary retrieval path and keep keyword search as a narrow fallback for exact identifiers. The boundary is clear: if the catalog is small, terminology is stable, and queries mostly contain SKUs or literal attribute names, keyword search should stay primary.
TL;DR: Put a small, typed retrieval contract between the application and both search methods. Semantic matching handles paraphrases such as "weatherproof walking shoe" against copy that says "water-resistant trail sneaker." Keyword matching protects exact strings such as SKU-1842, model codes, dimensions, and regulated material terms. This hybrid is slightly more work than either method alone, but it buys provider portability without turning the product-enrichment pipeline into an infrastructure project.
Should ask-your-docs use semantic search embeddings or keyword search?
Catalog enrichment is not ordinary document search. The input is uneven: a supplier may send a polished paragraph, a terse title, or a list of fragments. The output is structured data such as category, color, material, and use case. Retrieval has to find relevant taxonomy notes and merchandising rules before a model proposes those fields.
The familiar SaaS help center question still applies: should an ask-your-docs feature use embeddings or keyword search? A Node.js service can expose either architecture behind the same interface. The catalog changes the risk. A weak help-center result wastes a reader's time; an unsupported material or dimension can become bad product data. That makes evidence, exact-code handling, and abstention central to the design.
Keyword search is attractive because its behavior is inspectable. A query containing SKU-1842 can match the same literal token, and an operator can explain why. It also avoids an embedding dependency. But literal overlap is weak when suppliers and merchandisers use different words for the same idea. Synonym lists can extend lexical search, yet every list becomes another asset to maintain.
Semantic matching takes the opposite trade. It represents text for similarity lookup, so matching can survive vocabulary changes. The catch is operational: the query and catalog chunks need compatible representations, stored vectors need a known dimension, and switching representation providers requires re-indexing. That last point decides the design for a solo operator. The index is disposable. The source text, chunk IDs, metadata, and accepted enrichment records are not.
Source data wins.
This is a revenue-per-hour decision. I would outsource the undifferentiated representation call behind an adapter, but keep the corpus and evaluation cases in application-owned storage. Shipping weekly remains realistic because a provider change touches one adapter and a rebuild job, not every call site.
The smallest portable implementation
The first version needs three boundaries: a text representation adapter, two retrieval implementations, and a policy that combines their results. It does not need a universal AI abstraction. Broad abstractions tend to leak while hiding the few fields that matter.
export type CatalogChunk = {
id: string;
productId: string;
text: string;
sku?: string;
};
export type Match = {
chunk: CatalogChunk;
score: number;
source: "semantic" | "keyword";
};
export interface TextRepresentation {
readonly modelKey: string;
readonly dimensions: number;
embed(texts: readonly string[]): Promise<readonly number[][]>;
}
export interface CatalogRetriever {
search(query: string, limit: number): Promise<readonly Match[]>;
}
modelKey is an application value, not a vendor name. Persist it beside each indexed vector. When the mapping changes, write a new index generation and swap only after the new generation passes evaluation. Never overwrite the old vectors in place.
The combination policy can stay boring. Exact SKU matches get a deterministic lane. Everything else uses semantic results first, then keyword results that add new chunks. Scores from two retrieval systems are not assumed to share a scale. Rank order is enough for this first release.
function isExactIdentifier(query: string): boolean {
return /^(?:SKU-)?[A-Z0-9][A-Z0-9._-]{3,31}$/i.test(query.trim());
}
export async function retrieveCatalogContext(
query: string,
semantic: CatalogRetriever,
keyword: CatalogRetriever,
limit = 8,
): Promise<readonly Match[]> {
if (isExactIdentifier(query)) return keyword.search(query, limit);
const [meaningMatches, literalMatches] = await Promise.all([
semantic.search(query, limit),
keyword.search(query, limit),
]);
const merged = new Map<string, Match>();
for (const match of [...meaningMatches, ...literalMatches]) {
if (!merged.has(match.chunk.id)) merged.set(match.chunk.id, match);
}
return [...merged.values()].slice(0, limit);
}
Eight candidates are a starting configuration, not a quality claim. Make it configurable and settle it with the catalog's own test set. A business using ISBNs, GTINs, or manufacturer part numbers should replace the sample expression with its validated formats.
Consider one concrete record. Its title says Ridge Runner 1842, its supplier paragraph says water-resistant trail sneaker, and a merchandising rule describes weatherproof walking shoes. Semantic retrieval can connect the descriptive language, while keyword retrieval should own an exact request for SKU-1842. The enrichment code must still refuse to turn water-resistant into a stronger waterproof claim. That is why the retrieved chunk ID travels with every candidate field: a reviewer can see which text supported use_case: hiking, while validation can reject a material or performance claim absent from the source. I would accept the extra adapter and dual-index bookkeeping for that traceability. I would not accept a provider-specific object leaking into the stored product record, because changing providers would then become a catalog migration rather than a rebuild of disposable search data.
Keep generation downstream from retrieval. Category must exist in the current taxonomy. Numeric dimensions need units. A proposed material should either be supported by retrieved text or be withheld. Search relevance and field correctness are separate tests.
How do you know semantic matching earned its place?
Build a compact evaluation set from the catalog shape you serve. Include paraphrases, exact identifiers, ambiguous product names, sparse supplier copy, conflicting descriptions, and queries that deserve no match. Each case needs expected useful chunk IDs or an explicit empty result.
Run keyword-only, semantic-only, and hybrid retrieval against the same cases. Record whether a useful chunk appears near the top, whether an unsupported chunk outranks it, and how long retrieval takes. Then inspect failures. One aggregate score can hide the important split: semantic search may recover paraphrases while losing exact codes, and keyword search may do the reverse.
Use two gates before enabling semantic retrieval. It must improve the paraphrase slice without materially damaging identifier cases, and the added dependency must fit the service's latency and failure budget. Those thresholds are local product decisions; universal numbers would be false precision.
The tempting first design is to concatenate the description, title, and every attribute into one large record. That makes indexing easy, but obscures which evidence supported a field and makes partial updates expensive. Stable chunks work better operationally: one product summary, separate specification groups, and explicit taxonomy guidance, each with a durable ID.
Test deletion too. Removing a product from source storage must remove its chunks from both retrieval paths and every active index generation. A deleted chunk returning in an enrichment request is a correctness failure, even when its text would have been relevant.
Failure handling that does not stall catalog work
The ingestion job should be resumable. Store a content hash with each chunk, skip unchanged text, batch representation requests, and checkpoint completed IDs. If a batch fails, retry that batch according to the dependency's documented policy; do not restart the whole catalog. Keep accepted enrichment data separate so rebuilding retrieval cannot erase business records.
At query time, failure behavior follows the input. If semantic retrieval is unavailable, keyword retrieval can still serve literal and taxonomy matches. If keyword retrieval is unavailable, semantic retrieval can offer candidates, but exact identifiers should not silently become fuzzy matches. Return no automatic enrichment for that case and queue it for review. Wrong data compounds quickly.
Observe the boundary with fields you control: retrieval mode, index generation, model key, chunk count, duration, fallback reason, and selected chunk IDs. Avoid logging full supplier descriptions by default because they may contain private commercial data. Logs should answer "which path produced this candidate?" without becoming a shadow catalog.
If the UI streams progress while an enrichment runs, Server-Sent Events are a standards-based option for one-way updates. The browser EventSource interface opens a persistent HTTP connection and receives events in the text/event-stream format. Streaming is presentation. Job state still belongs in durable storage so a refresh does not lose the result.
What I would change at scale
The simple hybrid starts to strain when the catalog spans languages, tenants need isolated taxonomies, or re-indexing no longer fits inside a routine deployment window. Then I would add versioned background indexing, tenant-scoped quotas, and a reranking stage only if evaluation shows the initial candidate order is the bottleneck.
I would replace rank-order merging with a tested fusion method once there is enough labeled data to judge it. More machinery without evidence is negative revenue per hour. Keep the exact-match lane. Keep raw source text. Keep old index generations long enough to roll back a bad rebuild.
Keyword-only retrieval wins for a small, controlled catalog dominated by exact language and identifiers. Semantic-first hybrid retrieval wins when messy supplier wording and shopper language routinely diverge, provided the team can own an evaluation set and rebuild vectors during a provider change. That is the choice I would ship: portable boundaries around volatile pieces, deterministic guardrails around exact facts, and no provider assumptions in the catalog domain.
Ship the boundary.
Top comments (0)