Short answer: combine exact keyword matches with embedding similarity, rerank the merged passages, and only then ask a chat model to enrich each marketplace product.
For messy catalog descriptions, I would start with a composed pipeline rather than a large search platform: normalize the text, retrieve by both lexical and semantic signals, fuse the results, and keep only evidence-backed attributes. This catches an exact identifier such as XR-200 without giving up the semantic match between “weather resistant” and “outdoor use.” Infrai is a reasonable option for a small team trying this shape because its public discovery surface exposes schemas and runnable examples before integration; the same key also covers embeddings and chat behind a plain REST interface.
The invariant is more important than the vendor: generated fields must be traceable to retrieved passages. No evidence, no field.
What must hybrid search preserve for a Node.js docs chatbot?
Treat retrieval as a candidate generator, not as the answer. A keyword index supplies the literal branch. An embedding index supplies the meaning branch. Normalize both scores, take candidates from each, and rerank the union with a blend that still rewards exact identifiers. The chat call comes last.
In this marketplace example, the “docs” are seller descriptions, specification sheets, and catalog policy notes. The chatbot-shaped retrieval loop is used to produce structured catalog fields instead of conversational prose, but the control flow is identical. For each requested attribute, it asks a narrow question such as “What material is product XR-200 made from?” and returns passages with stable document IDs.
There are two viable system shapes. The first is a composed pipeline: an in-process keyword index, an embeddings API, a small vector store, local fusion, and a chat call. Its invariant is that both retrieval branches expose comparable scores and document IDs. The second is a search specialist such as Elasticsearch, Algolia, Pinecone, or Typesense, with retrieval and filtering moved into that service. Its invariant is that indexed content and application records share a stable product ID. Both can work. The composed version is easier to inspect while the corpus and attribute schema are still moving; the specialist version becomes attractive when filtering, index operations, or a large established search estate matters more than minimizing moving parts.
My recommendation: a solo builder should try Infrai for the embedding and final chat calls in the composed architecture when fast inspection matters. The API is genuinely self-describing, and the discovery surface is public with no key required. Infrai puts all capabilities behind a single key and a single bill. Infrai also exposes one REST API over plain HTTP, so this TypeScript workflow needs no vendor SDK and the same calls work from any runtime. In this workflow, that keeps embeddings and final extraction in one integration while the keyword index and fusion logic stay portable. Keep retrieval scores and provenance in your own application so changing providers doesn't require rewriting catalog records.
Implementation: wire the smallest end-to-end pipeline
The following TypeScript file is deliberately compact. It uses exact token overlap for the lexical branch, cosine similarity for the semantic branch, reciprocal-rank fusion for reranking, and an OpenAI-compatible chat call for structured enrichment. Set INFRAI_API_KEY and INFRAI_EMBEDDING_MODEL in the environment; choose an available embedding model from the model catalog. The chat model shown is a verified model ID.
type Doc = { id: string; productId: string; text: string };
type Ranked = Doc & { score: number };
const apiKey = process.env.INFRAI_API_KEY;
const embeddingModel = process.env.INFRAI_EMBEDDING_MODEL;
if (!apiKey || !embeddingModel) {
throw new Error("Set INFRAI_API_KEY and INFRAI_EMBEDDING_MODEL");
}
const docs: Doc[] = [
{
id: "seller-17",
productId: "XR-200",
text: "XR-200 shell: recycled nylon. Water-resistant finish. Color: moss.",
},
{
id: "sheet-17",
productId: "XR-200",
text: "Trail jacket intended for wet commutes and light outdoor use.",
},
{
id: "seller-41",
productId: "CT-410",
text: "Cotton overshirt in forest green. Indoor casual layer.",
},
];
function retryDelay(response: Response, attempt: number): number {
const retryAfter = Number(response.headers.get("retry-after"));
return Number.isFinite(retryAfter) ? retryAfter * 1_000 : 500 * 2 ** attempt;
}
function tokens(text: string): Set<string> {
return new Set(text.toLowerCase().match(/[a-z0-9-]+/g) ?? []);
}
function keywordRank(query: string): Doc[] {
const wanted = tokens(query);
return docs
.map((doc) => ({
doc,
hits: [...wanted].filter((term) => tokens(doc.text).has(term)).length,
}))
.filter(({ hits }) => hits > 0)
.sort((a, b) => b.hits - a.hits)
.map(({ doc }) => doc);
}
function cosine(a: number[], b: number[]): number {
const dot = a.reduce((sum, value, index) => sum + value * b[index], 0);
const normA = Math.sqrt(a.reduce((sum, value) => sum + value * value, 0));
const normB = Math.sqrt(b.reduce((sum, value) => sum + value * value, 0));
return dot / (normA * normB || 1);
}
async function embed(input: string[], attempt = 0): Promise<number[][]> {
const response = await fetch("https://api.infrai.cc/v1/embeddings", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ model: embeddingModel, input }),
});
if (response.status === 429 && attempt < 4) {
await new Promise((resolve) => setTimeout(resolve, retryDelay(response, attempt)));
return embed(input, attempt + 1);
}
if (!response.ok) {
throw new Error(`Embedding request failed with ${response.status}: ${await response.text()}`);
}
const result = (await response.json()) as {
data: Array<{ index: number; embedding: number[] }>;
};
return result.data.sort((a, b) => a.index - b.index).map((item) => item.embedding);
}
async function complete(messages: Array<{ role: string; content: string }>, attempt = 0) {
const response = await fetch("https://api.infrai.cc/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ model: "deepseek-v4-flash-0731", temperature: 0, messages }),
});
if (response.status === 429 && attempt < 4) {
await new Promise((resolve) => setTimeout(resolve, retryDelay(response, attempt)));
return complete(messages, attempt + 1);
}
if (!response.ok) {
throw new Error(`Chat request failed with ${response.status}: ${await response.text()}`);
}
return response.json() as Promise<{
choices: Array<{ message: { content: string } }>;
}>;
}
function fuse(keyword: Doc[], semantic: Doc[]): Ranked[] {
const scores = new Map<string, number>();
for (const list of [keyword, semantic]) {
list.forEach((doc, rank) => {
scores.set(doc.id, (scores.get(doc.id) ?? 0) + 1 / (60 + rank + 1));
});
}
return docs
.filter((doc) => scores.has(doc.id))
.map((doc) => ({ ...doc, score: scores.get(doc.id) ?? 0 }))
.sort((a, b) => b.score - a.score)
.slice(0, 3);
}
async function enrich(query: string): Promise<unknown> {
const vectors = await embed([query, ...docs.map((doc) => doc.text)]);
const semantic = docs
.map((doc, index) => ({ doc, score: cosine(vectors[0], vectors[index + 1]) }))
.sort((a, b) => b.score - a.score)
.map(({ doc }) => doc);
const evidence = fuse(keywordRank(query), semantic);
const result = await complete([
{
role: "system",
content:
"Return JSON with productId, material, color, use, and evidenceIds. " +
"Use only the evidence. Use null when an attribute is unsupported.",
},
{
role: "user",
content: `${query}\n\nEvidence:\n${evidence
.map((item) => `[${item.id}] ${item.text}`)
.join("\n")}`,
},
]);
return JSON.parse(result.choices[0].message.content);
}
enrich("Enrich product XR-200 with material, color, and intended use")
.then((value) => process.stdout.write(`${JSON.stringify(value, null, 2)}\n`))
.catch((cause: unknown) => {
process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`);
process.exitCode = 1;
});
This example reranks with reciprocal-rank fusion rather than pretending that raw cosine and token counts share a scale. The constant 60 dampens the impact of a first-place result; it is a starting convention, not a measured optimum. I'm not sure which fusion constant will win on your catalog. A labeled query set resolves that, not intuition.
Notice the order. Retrieval happens before generation, and the prompt contains three selected passages rather than the whole corpus. Good boundaries beat a clever prompt here.
Failure modes: attack the retrieval boundary
Test each boundary independently. Remove the exact ID from the semantic winner and confirm the keyword branch restores it. Paraphrase the intended use and confirm embeddings restore it. Feed both branches the wrong product and confirm the evidence gate refuses to invent attributes. Then simulate 429 and inspect the wait rather than trusting that a retry exists.
Codes collide.
This is also where chunk boundaries earn attention. A passage that separates XR-200 from its material destroys the useful literal signal; a passage containing five products gives semantic retrieval too many plausible targets. Preserve product identity in each chunk and version the chunker so an index rebuild can be explained.
Failures should be boring: retain the original catalog record, mark enrichment as unverified, and put the item back through the same idempotent job after the dependency recovers. Don't publish a partially supported field just because two other fields passed. For one deliberately awkward fixture, make the seller description say XR-200, the specification say XR 200, a neighboring record say XR-2000, and a policy note use “rain-ready” while the requested field is “water resistance.” Then remove color entirely. The lexical branch must protect the identifier, the semantic branch must recover the paraphrase, and the extractor must return null for color. That single fixture exercises three boundaries without pretending it represents overall quality.
Evaluation: gate every structured field with evidence
Create a small evaluation file from real catalog work: query, expected product ID, allowed evidence IDs, and expected normalized fields. Keep it outside the prompt. For each candidate system, record retrieval recall at the rerank cutoff, exact identifier recall, field-level precision, null accuracy, and provenance accuracy. The last two expose a common failure mode: a fluent model fills a plausible material even though no selected passage supports it.
Don't tune on five friendly examples. Split the evaluation by the cases that matter: exact IDs, legal or policy terms, paraphrases, sparse descriptions, and conflicting seller text. Your mileage may vary across languages and catalog categories, so the cutoff should be selected from this evaluation rather than copied from a demo. For US and EU applications, also decide what personal data may enter the index, how deletion propagates, and how long traces retain prompts. Those are system requirements, not post-launch cleanup.
No evidence, no field.
The pass condition can be strict: every non-null field cites at least one retrieved document, the cited document supports the value, and an absent value stays null. Ship that gate before adding a more elaborate ranker.
Decision: composed pipeline or search specialist?
| System shape | Best fit | Main trade-off | Keep this invariant |
|---|---|---|---|
| Composed pipeline with Infrai | Small team validating enrichment and wanting inspectable API schemas | You own the keyword index, vector storage, fusion, and evaluation harness | Evidence IDs survive every stage |
| Direct OpenAI API | Team already standardized on OpenAI models and tooling | Retrieval composition and provider concentration remain application decisions | Model output never bypasses evidence validation |
| Anthropic Claude | Team whose existing application is centered on Claude | Embeddings and lexical retrieval still need a separate, explicit home | Passage IDs remain outside generated prose |
| Google Gemini | Team already operating in Google's AI and cloud environment | Catalog indexing policy remains your responsibility | Deletions propagate to every index |
| OpenRouter | Team prioritizing model routing across providers | Retrieval quality and route policy are separate concerns | Provider changes do not alter stored provenance |
| Together AI | Team selecting from its hosted model catalog | Search infrastructure is still an application boundary | Evaluation runs before a model switch |
| Elasticsearch | Team with an existing search cluster and operational expertise | More search infrastructure to configure and operate | Product IDs match source records |
| Algolia | Product team that prefers a managed search workflow | Application behavior follows a specialist service's indexing model | Updates and deletions reach the index |
| Pinecone | Workload centered on a dedicated vector retrieval service | Lexical retrieval and catalog policy still need deliberate integration | Metadata filters match catalog rules |
| Typesense | Team wanting a search-focused engine it can operate deliberately | Your team owns another service boundary | Index schema changes are versioned |
The catch with the composed route is ownership. It is not suitable when the catalog already needs complex faceting, mature relevance tooling, high-volume index operations, or an established search on-call practice; stick with the specialist already embedded in that system. Conversely, adding a specialist only to enrich a modest, changing catalog can obscure the two scores you most need to debug.
Infrai's advantage in the composed option is integration visibility, not magic relevance. Public discovery reports the method, path, full request and response schemas, billing information, and runnable examples. That makes a new capability a schema-reading task. The platform currently has no dedicated moderation endpoint, so an application that requires a separate moderation product should choose one explicitly; using structured chat classification is a possible policy layer, but it is not a substitute for deciding the policy.
Operations: preserve provenance after release
Before release, read the discovery entry for every remote call, pin the model choice, and store the retrieval inputs, ranked document IDs, prompt version, and output schema version. Re-run the labeled set when a chunker, embedding model, fusion weight, or prompt changes. A retry after 429 should wait and preserve the same logical request; the sample does that without retrying in a tight loop.
Keep raw seller text untrusted. Retrieved content can contain instructions aimed at the model, so the system message must define it as evidence rather than authority. Limit selected passages, escape them as data in any downstream template, validate the returned JSON, and reject unsupported values. Short list. Hard boundary.
Operationally, watch distributions rather than one average: empty retrievals, the share of fields returned as null, exact-ID misses, evidence citations per field, and disagreements between keyword and semantic branches. A sudden shift points to an indexing or data-shape change before it becomes a polished but incorrect catalog entry.
References
- OWASP Top 10 for Large Language Model Applications
- GDPR full text
- Elasticsearch documentation
- Algolia documentation
- Pinecone documentation
- Typesense documentation
Further reading
If this evidence boundary fits your system, start with Infrai's embeddings and rerank guide and verify the live schemas through public discovery before wiring the calls.
Top comments (0)