The first version of an LLM catalog translation pipeline usually works and is still wrong. It re-translates fields nobody touched, and it happily publishes a description where the model rewrote a voltage, dropped a placeholder, or turned a SKU into something that looks nicer.
I have built LLM pipelines that translate and adapt product catalogs for international audiences. Translation quality was never the part that kept me up. The two mechanisms below are what turn a demo script into something you can point at a live catalog: a cache key that captures every input that can change the output, and validators that treat the model's response as untrusted until proven safe.
The unit of work is a field, not a product
If you translate a product as one blob, any edit to any attribute invalidates the whole thing, and you cannot tell which part of the output the model changed. Worse, you lose the ability to say "the title is reviewed, the description is not".
So the pipeline operates on (product_id, field, target_lang) triples. Each one is normalized before anything else happens, because unstable whitespace and encoding differences from a scraper will otherwise produce cache misses for text that did not actually change.
import hashlib
import re
import unicodedata
WHITESPACE = re.compile(r'\s+')
def normalize(text: str) -> str:
text = unicodedata.normalize('NFC', text)
text = text.replace('\u00a0', ' ')
return WHITESPACE.sub(' ', text).strip()
Enter fullscreen mode Exit fullscreen mode
That normalization is not cosmetic. Catalog sources emit non-breaking spaces, trailing tabs and mixed Unicode forms that change from crawl to crawl. Without it, a large share of your spend goes to re-translating text that is byte-different and semantically identical.
The cache key must include everything that can change the output
The mistake I have seen most often is keying the cache on source text alone. Then someone edits the system prompt, or the model version is bumped, and the catalog becomes a mix of two generations of output with no way to tell them apart.
Everything that participates in producing the string belongs in the key:
PROMPT_VERSION = 'catalog-v4'
def cache_key(
source: str,
field: str,
target_lang: str,
model: str,
glossary_version: str,
) -> str:
payload = '\u241f'.join([
normalize(source),
field,
target_lang,
model,
PROMPT_VERSION,
glossary_version,
])
return hashlib.sha256(payload.encode('utf-8')).hexdigest()
Enter fullscreen mode Exit fullscreen mode
The separator matters more than it looks: joining with a plain comma or space lets two different tuples collide into the same key. Use a character that cannot appear in the inputs.
Store the key next to the translation, along with the model and prompt version as their own columns. When you bump the prompt, you can then answer the only question that matters operationally — which rows are stale, and what does it cost to refresh them — with a query instead of a guess. A prompt change becomes a migration you can run in slices, per language or per category, rather than a full re-translation of the catalog.
Guard rails: extract invariants from the source, assert them on the output
A translation model is a text generator, not a transformer with guarantees. It will occasionally localize a decimal separator, convert units it was not asked to convert, or drop a templating placeholder because the sentence reads better without it. None of these throw an exception. They land in your catalog and surface later as a support ticket about a wrong specification.
The fix is not a better prompt. It is a validator that pulls the invariants out of the source and checks they survived.
PLACEHOLDER = re.compile(r'\{[a-z_]+\}')
HTML_TAG = re.compile(r'</?[a-z][a-z0-9]*[^>]*>', re.IGNORECASE)
NUMBER = re.compile(r'\d+(?:[.,]\d+)?')
class InvariantError(Exception):
pass
def numbers(text: str) -> list[str]:
return [n.replace(',', '.') for n in NUMBER.findall(text)]
def check_invariants(source: str, translated: str) -> None:
src_ph = sorted(PLACEHOLDER.findall(source))
out_ph = sorted(PLACEHOLDER.findall(translated))
if src_ph != out_ph:
raise InvariantError(f'placeholders changed: {src_ph} -> {out_ph}')
src_tags = sorted(t.lower() for t in HTML_TAG.findall(source))
out_tags = sorted(t.lower() for t in HTML_TAG.findall(translated))
if src_tags != out_tags:
raise InvariantError('markup changed')
src_nums = sorted(numbers(source))
out_nums = sorted(numbers(translated))
if src_nums != out_nums:
raise InvariantError(f'numbers changed: {src_nums} -> {out_nums}')
Enter fullscreen mode Exit fullscreen mode
Comparing sorted multisets rather than sequences is deliberate: word order legitimately moves between languages, numeric values do not. The decimal-separator normalization before comparison is there because a model translating into a locale that uses commas will reformat 1.5 as 1,5, which is a rendering decision, not a value change — and you want your formatter to own that decision, not the model.
This validator is intentionally cheap and mechanical. It does not judge whether the translation is good. It only proves the model did not quietly alter something that carries commercial or legal meaning.
The failure policy is the actual design decision
When validation fails, you have three options, and choosing between them is the part that requires an opinion.
def translate_field(client, source, field, lang, model, glossary_version):
key = cache_key(source, field, lang, model, glossary_version)
cached = store.get(key)
if cached is not None:
return cached
violation = None
for _ in range(2):
out = client.translate(
source=source,
field=field,
target_lang=lang,
model=model,
correction=violation,
)
try:
check_invariants(source, out)
except InvariantError as exc:
violation = str(exc)
continue
store.put(key, out, status='ok')
return out
store.put(key, source, status='needs_review', reason=violation)
return source
Enter fullscreen mode Exit fullscreen mode
One retry, and the retry carries the specific violation back to the model — not a generic "try again". Naming the constraint that was broken is what makes the second attempt materially different from the first; a bare retry mostly resamples the same failure.
After that, stop. Fall back to the source string and flag the row. Publishing untranslated English text into a localized catalog is a visible, honest degradation. Publishing a translation with a changed specification is an invisible one, and invisible failures in catalog data behave exactly like invisible failures in payment attribution: nobody finds them until a human complains, and by then you cannot tell how long it has been wrong or how many rows are affected.
The needs_review queue is the deliverable here, not a nice-to-have. It is the only place where the pipeline's real error rate is observable.
What I would do differently
I would keep the glossary — brand names, product terms that must not be translated, unit spellings — as a versioned data table from day one, not as text pasted into the prompt. Once it lives in the prompt, every glossary edit silently invalidates the entire cache, and you cannot slice a refresh by the terms that actually changed.
I would also stop letting free-form placeholders into translatable text. Regex-matching {price} works until someone writes a template with nested braces or a currency symbol adjacent to a placeholder. Splitting the string into structured segments before translation, and reassembling after, removes a whole class of validator false negatives — at the cost of a more complex renderer.
The wider point: an LLM in a data pipeline is a non-deterministic component sitting where you previously had a deterministic one. Everything above is what you would build around any unreliable dependency — an idempotency key, invariant checks at the boundary, an explicit failure state. The model is new. The engineering around it is not.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.