International e-commerce platform with 30 locales, product configurators, AI chatbot, and fully automated order flow: Stripe → Zoho CRM → Airtable → Mailgun →
RAG system on OpenAI and Upstash Vector with 30 language support. Includes streaming chatbot, hybrid search (semantic + keyword), AI ticket classifier, and incremental indexing with SHA-256 caching.
Databases
Key Results

After the e-commerce platform launched across 32 countries, the support inbox became unmanageable. Customers from Germany, Estonia, Russia, the Netherlands were writing in their native languages every day, asking the same questions: delivery times to their country, sizing recommendations, installation on different wall types, which product was right for their situation.
The support team was spending three to four hours daily on copy-paste replies — reading a question in German, looking up the shipping table for Germany, translating the answer, drafting the email. Response times were slipping. The business was growing, more markets were being added, and the support load was only going to increase. Hiring more support staff to handle questions that were 70% identical was not a viable path.
I built a RAG system that indexes the entire knowledge base — 1,614 documents covering products, delivery times, sizing guides, and installation FAQs — and serves answers through two interfaces: a streaming chat widget on the site and an AI classifier embedded in Zoho Desk.
The chatbot handles context correctly across languages. A customer asking "And to Germany?" as a follow-up gets the right answer without having to repeat the full question, because the query is enriched with prior conversation context before retrieval. The system does not filter by locale at the retrieval stage — cross-lingual vector search means a question asked in Dutch can match a document indexed in English or Finnish when the semantic content is the same.
The Zoho Desk classifier is a separate two-stage pipeline. It first extracts the core question from the email (stripping greetings, order numbers, and pleasantries), then runs RAG retrieval, then classifies the ticket into one of 18 categories and generates a draft reply with a confidence score. High-confidence tickets are handled automatically; anything below the threshold is routed to a human with the extracted question and relevant context already prepared.
The support team went from spending half their day on repetitive emails to reviewing flagged exceptions. Seventy percent of queries are now resolved without human involvement, at any hour, in any of 30 languages.
| Metric | Value |
|---|---|
| Knowledge base | 1,614 documents |
| Languages | 30 (multilingual retrieval) |
| Vector dimensions | 3,072 (text-embedding-3-large) |
| RAG retrieval | <500ms (P95) |
| Ingestion script | 1,843 lines |
| Incremental updates | SHA-256 cache (only changed docs) |
The incremental indexing means the knowledge base stays current — when shipping rates or product details change, only the affected documents are re-embedded.
| Aspect | Implementation |
|---|---|
| Documents | Indexed for each of 30 locales separately |
| Retrieval (chat) | No locale filter — cross-lingual search |
| Retrieval (search) | With locale filter — results in UI language |
| Product detection | i18n dictionary (EN/RU/FI/NL/DE) |
| Fuzzy matching | Normalization: й→и, ё→е, ä→a, ö→o, ü→u, ß→ss |
| LLM response | Auto-switch to user's language |
| Brand names | Localized: Pikkuna / Пиккуна / Πίκκουνα / 皮库娜 |
┌─────────────────────────────────────────────────────────────────────┐
│ DATA INGESTION (Offline) │
│ │
│ Sources: Pipeline: │
│ ├─ src/locales/*.json ┌─────────────┐ ┌──────────────┐ │
│ │ (30 langs) ──────►│ Extract & │───►│ SHA-256 Hash │ │
│ ├─ docs/**/*.md │ Chunk │ │ Cache Check │ │
│ └─ next-intl.config.js └─────────────┘ └──────┬───────┘ │
│ Changed only │
│ ┌─────────────┐ ┌──────▼────────┐ │
│ │ OpenAI │◄──│ Generate │ │
│ │ Embedding │ │ Embeddings │ │
│ │ 3-large │ └───────────────┘ │
│ └──────┬──────┘ │
│ ┌──────▼──────┐ │
│ │ Upstash │ │
│ │ Vector │ │
│ │ (1614 docs)│ │
│ └─────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ RUNTIME (Online, per request) │
│ │
│ ┌───────────┐ ┌───────────┐ ┌─────────────────────────┐ │
│ │ ChatBot │ │ Search │ │ Ticket Classifier │ │
│ │ (Stream) │ │ (Hybrid) │ │ (Zoho Desk webhook) │ │
│ └─────┬─────┘ └─────┬─────┘ └───────────┬─────────────┘ │
│ └───────────────┼─────────────────────┘ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ retrieveContext() │ │
│ │ 1. Embed query │ │
│ │ 2. Detect product │ │
│ │ 3. Vector search │ │
│ │ 4. Re-rank │ │
│ └──────────┬──────────┘ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ OpenAI LLM │ │
│ │ OpenAI model (chat)│ │
│ │ OpenAI model (cls) │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
For the technically curious, here is how the core pieces are built.
A single retrieveContext function used in both the chatbot and the ticket classifier:
// src/lib/rag-chat.ts
export const retrieveContext = async (
query: string,
locale: string = "en"
): Promise<RetrievalResult[]> => {
// 1. Detect product by keywords (multilingual dictionary)
const detectedProducts = detectProductFromQuery(query);
// → ['pikkuna'] | ['pikkuroof'] | ['general']
// 2. Generate query embedding
const { embedding } = await embed({
model: openai.embedding("text-embedding-3-large"),
value: query,
});
// 3. Vector search (no locale filter — all languages available)
const results = await vectorIndex.query({
vector: embedding,
topK: 15, // candidates for filtering
includeMetadata: true,
});
// 4. Re-ranking with product boost
const reranked = results
.filter((r) => r.score >= 0.7)
.map((r) => {
let score = r.score;
const productTag = r.metadata?.productTag;
// +30% boost if product matches query
if (productTag && detectedProducts.includes(productTag)) {
score = Math.min(1.0, score * 1.3);
}
// -40% penalty if product doesn't match (and not general)
else if (productTag && productTag !== "general") {
score *= 0.6;
}
return { ...r, score };
})
.sort((a, b) => b.score - a.score)
.slice(0, 5);
return reranked;
};
// i18n dictionary for product detection
const PRODUCT_KEYWORDS = {
pikkuna: [
"side curtain",
"vertical",
"pole",
"post", // EN
"боковые",
"вертикальн",
"столб", // RU
"sivuverho",
"pylväs",
"tolppa", // FI
"zijgordijn",
"verticaal",
"paal", // NL
"seitenvorhang",
"vertikal",
"stütze", // DE
],
pikkuroof: [
"roof",
"pergola",
"horizontal",
"canopy",
"крыша",
"перголы",
"горизонтальн",
"навес",
"katto",
"pergola",
"vaakasuora",
],
};Integration with @assistant-ui/react for a ready-to-use UI with conversation persistence:
// src/app/api/chat/route.ts
export async function POST(req: Request) {
const { messages, locale } = await req.json();
// Handle follow-up questions (context enrichment)
const query = buildContextualQuery(messages);
// "And to Germany?" → "how much is shipping... And to Germany?"
// RAG retrieval
const relevantDocs = await retrieveContext(query, locale);
// Build context for prompt
const contextMessage = relevantDocs
.map((doc, i) => `[${i + 1}] ${doc.metadata?.category}:\n${doc.text}`)
.join('\n\n');
// Streaming response
const result = streamText({
model: openai(process.env.OPENAI_CHAT_MODEL!),
system: SYSTEM_PROMPT + `\n\nKnowledge base context:\n${contextMessage}`,
messages,
temperature: 0.7,
});
// Format compatible with @assistant-ui/react
return result.toUIMessageStreamResponse();
}
// src/components/ChatBot/ChatBot.tsx
const chat = useChat({
api: '/api/chat',
body: { locale },
initialMessages: loadFromLocalStorage(), // persistence
});
const runtime = useAISDKRuntime(chat);
return (
<AssistantRuntimeProvider runtime={runtime}>
<AssistantModal />
</AssistantRuntimeProvider>
);Search endpoint combines vector similarity with fuzzy keyword matching:
// src/app/api/search/route.ts
export async function GET(req: Request) {
const { query, locale, type } = parseParams(req);
// 1. Query embedding
const { embedding } = await embed({
model: openai.embedding("text-embedding-3-large"),
value: query,
});
// 2. Vector search WITH locale filter (unlike chat)
const results = await vectorIndex.query({
vector: embedding,
topK: 50,
filter: `locale = "${locale}"${type ? ` AND type = "${type}"` : ""}`,
includeMetadata: true,
});
// 3. Fuzzy keyword matching
const queryWords = normalizeForSearch(query).split(" ");
// normalizeForSearch: й→и, ё→е, ä→a, ö→o, ü→u, ß→ss
// 4. Hybrid scoring
const scored = results.map((r) => {
const titleMatches = countMatches(r.metadata?.question, queryWords);
const contentMatches = countMatches(r.metadata?.text, queryWords);
// Hybrid score = vector + keyword bonus
const hybridScore = r.score + Math.max(titleMatches * 0.25, contentMatches * 0.08);
return { ...r, hybridScore, hasKeywordMatch: titleMatches > 0 };
});
// 5. Smart filtering
// With keyword matches → threshold 0.62
// Without keyword matches → threshold 0.70 (stricter)
const filtered = scored.filter((r) =>
r.hasKeywordMatch ? r.hybridScore >= 0.62 : r.score >= 0.7
);
return Response.json({
hits: filtered.slice(0, 5),
totalHits: filtered.length,
});
}Auto-categorization of Zoho Desk emails with RAG enrichment:
// src/app/api/zoho/ticket-classifier/route.ts
export async function POST(req: Request) {
const { subject, description } = await req.json();
const fullMessage = `${subject}\n\n${description}`;
// === STAGE 1: Extract core question ===
const extraction = await generateText({
model: openai(process.env.OPENAI_MODEL!),
temperature: 0.1, // strict for extraction
system: EXTRACTION_PROMPT,
prompt: fullMessage,
});
// "Hello! When will my order #12345 arrive? Thanks, John"
// → "When will my order arrive?"
// === STAGE 2: RAG retrieval ===
const context = await retrieveContext(extraction.text, "en");
// === STAGE 3: Classification + Reply Generation ===
const classification = await generateText({
model: openai(process.env.OPENAI_MODEL!),
temperature: 0.3, // strict for JSON output
system: CLASSIFIER_PROMPT, // 18 categories + confidence rules
prompt: `
FULL MESSAGE: ${fullMessage}
CORE QUESTION: ${extraction.text}
KNOWLEDGE BASE: ${context.map((c) => c.text).join("\n\n")}
`,
});
const result = JSON.parse(classification.text);
// {
// language: "en",
// classification: "Order status inquiry",
// confidence: 0.85,
// reply_en: "Orders typically ship within 2-3 business days...",
// }
// If confidence <= 0.3 → reply empty (requires manager)
return Response.json(result);
}
// 18 classification categories
const CATEGORIES = [
"Order status inquiry",
"Quote request",
"Custom drawing",
"Reclamation::Film defect",
"Reclamation::Incorrect size",
"Reclamation::Delivery Issue",
"Partnership",
"Spam",
// ... 10 more
];Only changed documents get re-indexed:
// scripts/ingest-locales-to-upstash.ts
async function ingestDocuments(options: { force?: boolean }) {
// 1. Load hash cache
const cache = await loadCache(); // .cache/rag-hashes.json
// 2. Extract documents from all sources
const documents = [
...extractFAQs(locales), // pages.support.content
...extractProducts(locales), // pages.products.*
...extractDeliveryTimes(config), // next-intl.config.js
...extractShippingCosts(config), // shipping_rates + VAT
...extractMarkdownDocs(docsDir), // docs/**/*.md
];
// 3. Compute SHA-256 hashes
const currentHashes = documents.reduce((acc, doc) => {
acc[doc.id] = crypto.createHash("sha256").update(doc.text).digest("hex").slice(0, 16);
return acc;
}, {});
// 4. Compare with cache
const { added, changed, deleted } = compareWithCache(currentHashes, cache);
if (!options.force && added.length === 0 && changed.length === 0) {
console.log("No changes detected, skipping ingestion");
return;
}
// 5. Delete stale vectors
if (deleted.length > 0) {
await vectorIndex.delete(deleted);
}
// 6. Generate embeddings (batch of 10, rate-limit friendly)
const toUpdate = [...added, ...changed];
const embeddings = await generateEmbeddingsBatch(toUpdate, 10);
// 7. Upsert to Upstash (batch of 100)
await upsertVectors(embeddings, 100);
// 8. Save new cache
await saveCache({ hashes: currentHashes, version: "2.0" });
}
// npm run ingest-rag → incremental
// npm run ingest-rag --force → full rebuild
AvailableNeed something similar?
I build custom solutions — from APIs to full products. Let's talk about your project.
International e-commerce platform with 30 locales, product configurators, AI chatbot, and fully automated order flow: Stripe → Zoho CRM → Airtable → Mailgun →
i18n B2B e-commerce platform for waterless urinal products across 32 European countries with automated VAT handling, PDF invoicing, and CRM integration.
RAG chatbot for e-commerce: resolve 70% of support queries across 25 languages — ingestion pipeline, hybrid search, confidence thresholds, and streaming UI.
Stack
Libraries
Databases
Add AI to an existing product without a rebuild. Three integration patterns, how to pick the right one, and what production-ready AI actually demands.
Stack
Libraries
Databases
Services