tiché selhání je default
Většina týmů, které shipují RAG feature, je pořád testuje jako demo: proklikají staging UI, položí tři neškodné otázky, uvidí uvěřitelnou odpověď s badge citace a mergují. O týden později někdo posune gpt-4.1-mini na novější snapshot, přehodí embeddings z text-embedding-3-small na text-embedding-3-large, přebuildí pgvector indexy s jinými chunk boundaries a celé se to zhorší, aniž by spadla jediná exception. Žádné 500, žádné crash loopy, žádná červená linka v Grafaně, jen horší odpovědi.
Tenhle typ selhání je nepříjemný právě tím, že každá vrstva dál vrací data, která vypadají validně. PostgreSQL 16 s pgvector 0.7.4 vám bez problému vrátí nearest neighbors, reranker je bez problému seřadí, LLM bez problému vygeneruje plynulý nesmysl a produktový tým bude bez problému předpokládat, že je všechno v pořádku, dokud se nezačnou hromadit support tickety. Ve Steezr to vidíme pořád u document processingu a zákaznických portálů, hlavně ve chvíli, kdy feature přeroste z úzkého pilotu na korpus plný chaotických PDF, duplicitních policy dokumentů, starých CRM exportů a uživatelů, kteří se ptají na věci, které autora promptu nikdy nenapadly.
Potřebujete regresní testy, které s pipeline zachází jako se softwarem, ne jako s divadlem. Deterministické snapshoty pro stabilní případy, generované counterexamples pro ošklivé edge casy a contract testy kolem retrievalu a attributionu, aby systém failnul nahlas dřív, než to udělá produkce. Jestli je vaše jediná kontrola kvality eyeballing trace v LangSmithu nebo OpenAI logách, nemáte testovací strategii. Máte rituál.
zmrazte, co jde zmrazit
Začněte u částí, které umíte skutečně udělat deterministicky. Nastavte temperature=0, top_p=1, zafixujte verzi modelu, pokud to provider dovolí, pinujte system prompt, pinujte chunking logiku, pinujte retrieval parametry a ukládejte přesně ty vstupy, které vedly ke známému správnému výstupu. Zní to samozřejmě, přesto týmy polovinu z toho přeskočí a pak si stěžují, že jsou snapshoty flaky. Jasně že jsou flaky, změnili jste tři proměnné a čekali, že test pozná, která z nich byla důležitá.
Pro Python stack může být snapshot test brutálně jednoduchý. Golden casy obvykle držíme v Postgresu, protože data tam už stejně žijí a verzování řádků je jednodušší, než si většina lidí myslí. Třeba taková tabulka úplně stačí:
1create table rag_golden_cases (2 id uuid primary key,3 case_name text not null unique,4 question text not null,5 document_set_version text not null,6 prompt_version text not null,7 model_name text not null,8 expected_answer text not null,9 expected_citations jsonb not null,10 created_at timestamptz not null default now()11);
Pak v pytest pustíte pipeline se zmraženým nastavením a porovnáváte normalizovaný výstup, ne syrovou rovnost stringů, pokud nejde o malý a úzce vymezený task. Normalizujte whitespace, odstraňte volatilní timestampy, případně dejte citation labely do lowercase, pokud renderer mění formátování. Porovnání musí být dost přísné, aby zachytilo drift, ale ne tak přísné, aby padalo na nesmyslech kvůli neškodné interpunkci. Třeba takhle:
1def test_policy_refund_snapshot(rag_app, golden_case):2 result = rag_app.answer(3 question=golden_case.question,4 temperature=0,5 top_k=6,6 rerank=True,7 prompt_version=golden_case.prompt_version,8 document_set_version=golden_case.document_set_version,9 model=golden_case.model_name,10 )1112 assert normalize(result.answer) == normalize(golden_case.expected_answer)13 assert result.citations == golden_case.expected_citations
Tímhle zachytíte prompt drift okamžitě. Zároveň to vynutí disciplínu kolem verzování, kterou většina RAG codebase zoufale postrádá. Jestli vám developer nedokáže říct, která prompt template a která sada dokumentů vyrobila passing test, pipeline už je moc kluzká na to, aby se dala rozumně provozovat.
syntetické counterexamples jsou lepší než happy path
Snapshot testy pokrývají jen známé dobré příklady. Před podivnými vstupy, které si uživatelé vymyslí pět minut po launchi, vás nezachrání. Potřebujete generované casy, které se retrieval aktivně snaží rozbít a cpou edge casy přesně tam, kde pipeline nejčastěji lže.
Hypothesis je na to ideální, i když ho komunita kolem property-based testingu občas prodává až moc. Použijte ho na generování adversariálních promptů kolem nejednoznačnosti, skoro stejných entit, negace, hraničních dat, OCR bordelu a citation baitu. Pokud máte v korpusu „ACME Standard Plan“ a „ACME Standard Plus Plan“, generujte prompty, které vynutí disambiguaci. Pokud zdrojové dokumenty míchají 2023-01-02 a 02/01/2023, generujte zmatek kolem locale. Pokud některá PDF obsahují hlavičky jako Page 1 of 42 CONFIDENTIAL, vkládejte otázky, které retriever lákají k junk chunkům.
Hrubý příklad:
1from hypothesis import given, strategies as st23company = st.sampled_from(["ACME", "Acmé", "Acme Ltd"])4plan = st.sampled_from(["Standard", "Standard Plus", "Enterprise"])5year = st.integers(min_value=2021, max_value=2026)6negation = st.sampled_from(["can", "cannot", "is allowed to", "is not allowed to"])78@given(company=company, plan=plan, year=year, negation=negation)9def test_refund_policy_disambiguation(company, plan, year, negation, rag_app):10 q = f"Under the {company} {plan} contract in {year}, who {negation} request a refund after renewal? Cite the source."11 result = rag_app.answer(question=q, temperature=0)12 assert no_hallucinated_citations(result)13 assert answer_mentions_scope(result.answer, plan, year)
Nejde o to mít krásné generátory. Jde o objem a nepříjemnost. Chcete desítky nebo stovky levných útoků mířených přesně na slabá místa retrieval chainu. Děláme to takhle i u document pipeline, které tahají entity z faktur a smluv, protože generovaný odpad odhalí špatné předpoklady rychleji než jakkoliv slušně připravená fixture data. Jeden vygenerovaný prompt, který vytáhne citaci z handbooku špatného tenantu, má větší hodnotu než dvacet zelených testů nad čistým marketingovým copy.
Každý failující generovaný case si navíc nechte. Povýšte ho do golden sady. Přesně takhle testovací korpus časem chytřeje, místo aby zůstal zaseknutý na úrovni dema z prvního sprintu.
contract testy pro retrieval vrstvu
RAG pipeline má tvrdou hranici v místě, kde retrieval předává evidence generování. Dejte tam kontrakty. Jestli testujete jen finální text odpovědi, uteče vám nejběžnější třída problémů: retrieval potichu vrací nerelevantní chunky a model to pak uhladí sebevědomou prózou.
Retrieval kontrakt by měl kontrolovat aspoň čtyři věci. Za prvé, očekávaný zdrojový dokument je v top K. Za druhé, ranking chunku drží nad thresholdem, ať už podle cosine similarity, reranker skóre nebo obojího. Za třetí, vrácený chunk skutečně obsahuje fakta použitá v odpovědi. Za čtvrté, citace se mapují na stabilní document ID a offsety, ne na display names, které se rozbijí ve chvíli, kdy někdo přejmenuje employee_handbook_final_v3.pdf.
Konkrétní příklad: řekněme, že retriever vrací řádky jako (document_id, chunk_id, score, content, start_offset, end_offset). Pak může test ověřovat skutečné rozhraní místo mlhavého pocitu relevance:
1def test_retrieval_contract_refund_policy(retriever, corpus_version):2 hits = retriever.search(3 query="Can a customer request a refund within 30 days of renewal?",4 corpus_version=corpus_version,5 top_k=8,6 )78 assert len(hits) >= 59 assert hits[0].score >= 0.7810 assert any(h.document_id == "policy-refunds-2024" for h in hits[:3])11 assert any("30 days of renewal" in h.content.lower() for h in hits[:5])12 assert all(h.start_offset < h.end_offset for h in hits)
Pak otestujte attribution po generování:
1def test_attribution_contract(rag_app):2 result = rag_app.answer(3 question="Can a customer request a refund within 30 days of renewal?",4 temperature=0,5 )6 for citation in result.citations:7 chunk = load_chunk(citation.document_id, citation.chunk_id)8 assert cited_span_exists(chunk.content, citation.quote)
Právě ten poslední assert je důležitější, než si lidé připouštějí. Spousta systémů emituje citation objekty, které vypadají legitimně, a přitom vůbec nepodporují samotnou odpověď. Viděl jsem trace, kde odpověď tvrdila „refunds are unavailable after renewal“, zatímco citovaný chunk říkal přesný opak, a UI stejně vykreslilo pěknou source card, jako by tím bylo všechno vyřešené.
verzujte korpus jako kód
Za většinu problémů s kvalitou RAG se viní prompty, protože jsou vidět. Skutečný viník bývá mnohem častěji drift v korpusu. Někdo znovu naparsuje PDF s jiným OCR nastavením, změní chunk size z 800 tokenů na 400, při preprocessingu zahodí tabulky nebo po migraci znovu naembeduje půlku kolekce, a chování retrievalu se posune natolik, že staré předpoklady přestanou platit.
Berete sadu dokumentů jako verzovaný artefakt. Ukládejte hash zdrojového souboru, verzi extraction pipeline, verzi chunkeru, embedding model a nastavení indexu. Pokud jedete na Postgresu, může to žít vedle content tabulek bez většího dramatu:
1create table document_versions (2 version text primary key,3 extractor_version text not null,4 chunker_version text not null,5 embedding_model text not null,6 source_manifest jsonb not null,7 created_at timestamptz not null default now()8);
Každý golden test pak navažte na document_set_version. Když po rebuildu korpusu test spadne, jedině dobře, aspoň hned víte proč. Můžete diffnout manifesty, prohlédnout změněné chunky a rozhodnout, jestli je nové chování zlepšení, nebo regrese. Bez tohohle končí týmy u debugování ve stylu duchařiny: jeden inženýr přísahá, že se zhoršil prompt, druhý, že OpenAI něco změnilo upstream, a nikdo si nevšimne, že chunker už dva deploye nezachovává section headings.
Tohle jsme si odžili na document-heavy interních nástrojích, kde kvalita extrakce byla stejně důležitá jako finální odpověď. Jediná změna v parsování tabulek může rozbít retrieval pro pricing rules, SLA výjimky nebo invoice line items, zatímco každé API volání dál vrací 200. Verzování korpusu z toho udělá něco, o čem se dá rozumně přemýšlet. Zároveň dostanete čistou promotion cestu: buildněte corpus 2026-03-12.1, pusťte sadu, zkontrolujte diffy a pak ho označte jako production-ready.
udělejte pády čitelné
Červený test, který vypíše assert False, je k ničemu. Regresní testy pro LLM potřebují failure output, který vás dovede k rozbité vrstvě do minuty. Jinak je celá sada jen dekorace a lidé jí přestanou věřit.
Vypisujte query, verzi promptu, verzi korpusu, vrácené chunky se skóre, finální odpověď a stav validace citací. Když se změní snapshot, ukažte diff, ne jen dva neodpovídající stringy. Když retrieval minul očekávaný dokument, přidejte top deset hitů a jejich skóre. Když spadne attribution, vypište citovaný quote a skutečné okno textu chunku kolem údajného span. Failure report má automaticky odpovědět na první debugging otázky, protože nikdo nechce po každém CI runu celé kolečko ručně přehrávat v notebooku.
V praxi to znamená napsat si kolem RAG stacku malý test harness místo předstírání, že samotný pytest stačí. Ten náš obvykle generuje JSON artefakt pro každý failující case, třeba takhle:
1{2 "case": "refund-policy-renewal",3 "model": "gpt-4.1-mini-2025-02-14",4 "prompt_version": "answer_v12",5 "document_set_version": "corpus_2026_03_12_1",6 "retrieval": [{"doc":"policy-refunds-2023","score":0.81}],7 "answer": "Refunds are not available after renewal.",8 "citations_valid": false9}
Tenhle artefakt může skončit v CI logách, v S3 nebo na malé interní review stránce. Na tom moc nezáleží. Důležité je, aby engineer na callu okamžitě viděl, jestli problém vznikl v retrievalu, generování nebo attributionu. Jakmile tohle máte, upgrade modelů přestane působit jako pověra. Je to normální engineering práce: pusťte sadu, projděte rozbité casy, rozhodněte, jestli je delta přijatelná, a buď shipněte, nebo vraťte změnu zpátky.
Tohle je standard. Cokoliv měkčího nechává příliš moc peněz viset na pocitech.
