<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Schema-Registry on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/schema-registry/</link><description>Recent content in Schema-Registry on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Fri, 22 May 2026 11:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/schema-registry/index.xml" rel="self" type="application/rss+xml"/><item><title>Data versioning for LLMOps: DVC, lakeFS and the reproducible golden dataset challenge</title><link>https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/</link><pubDate>Fri, 22 May 2026 11:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The Data stage of the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a> has a silent link on which everything else depends: &lt;strong>versioning the datasets&lt;/strong> with the same discipline the code is versioned with. It is not optional. An LLM system in production consumes at least four distinct types of dataset (training/fine-tuning, RAG corpus, golden eval set, enriched dataset from the Retrain loop) and each has its own demands. Git solves code but fails on data for two technical reasons (size and a useless binary diff) and one operational one (it does not propagate lineage as far as the weights bucket of the trained model). The two dominant OSS tools, &lt;strong>DVC&lt;/strong> and &lt;strong>lakeFS&lt;/strong>, &lt;strong>unified in November 2025&lt;/strong> under a single organisation with a roadmap oriented towards LLM training and RAG datalakes; they are still complementary projects (file-level vs whole-bucket branching) but now under common governance. The productive pattern the market has consolidated on: identify each artefact with an immutable &lt;code>(dataset_id, version)&lt;/code>, propagate the pair as far as the experiment tracking (MLflow / W&amp;amp;B), version the dataset&amp;rsquo;s schema too (not just its content), apply a strict holdout to the golden eval set so you are not measuring memorisation, and maintain bidirectional traceability &lt;code>dataset_version ↔ model_version ↔ deployment ↔ trace_id&lt;/code>. Without this, the promise of &amp;ldquo;we can audit which model answered what&amp;rdquo; collapses at the first serious incident.&lt;/p>
&lt;h2 id="you-are-here-data-with-a-cross-cutting-effect-on-tune-eval-and-retrain">You are here: Data (with a cross-cutting effect on Tune, Eval and Retrain)&lt;/h2>
&lt;p>This post goes into the detail of the versioning link inside stage &lt;strong>1 · Data&lt;/strong>. Versioning belongs operationally to Data, but the artefacts it produces travel to Tune (training set), Eval (golden set) and Retrain (enriched dataset). That is why the diagram marks Data as active and shows a cross-cutting band indicating end-to-end lineage.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 135" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Data with cross-cutting lineage">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#ff8a4c;stroke-width:3}.idle{fill:#f4f4f4}.cross{fill:#ffe9d6;stroke-width:1.4;stroke:#c66;rx:6}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#444}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#dvm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#dvm)}&lt;/style>
&lt;defs>&lt;marker id="dvm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: DATA · dataset versioning with lineage to the production trace&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box active"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box idle"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;rect x="30" y="98" width="735" height="25" class="cross"/>
&lt;text x="397" y="115" text-anchor="middle" class="sm">Dataset lineage: training set → Tune · golden set → Eval · enriched set → Retrain (back to Data)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-master-analogy-batch-traceability-in-a-serious-factory">The master analogy: batch traceability in a serious factory&lt;/h2>
&lt;p>A serious pharmaceutical factory does not produce without &lt;strong>batch traceability&lt;/strong>. Every box of pills carries a printed batch number; that batch is associated with manufacturing dates, with the specific batches of each raw material used, with the quality tests it passed, and with the technicians who signed off each step. If a patient reports an adverse effect, the factory can rewind within hours: this package → this batch → these raw materials → this shift → this production line → this quality control result. Without that chain, the incident is a permanent mystery.&lt;/p>
&lt;p>A serious LLM system works the same way. The &amp;ldquo;package&amp;rdquo; is the answer a user saw in production. The &amp;ldquo;batch&amp;rdquo; is the combination of model, adapter, prompt, context and configuration that generated it. And the &amp;ldquo;raw materials&amp;rdquo; are the datasets: the training set the base model was trained on, the adapter&amp;rsquo;s fine-tuning dataset, the RAG corpus that feeds retrieval, the golden eval set that validates promotion. If a customer says &lt;em>&amp;ldquo;what data was the model trained on that answered X to my question Y on 14 March?&amp;rdquo;&lt;/em>, without batch traceability the answer is &lt;em>&amp;ldquo;we do not know&amp;rdquo;&lt;/em>. And that, with a customer under compliance pressure, kills the contract.&lt;/p>
&lt;p>Git versions the recipe (the code). Data versioning versions the ingredients. Without both, there is no auditable factory.&lt;/p>
&lt;h2 id="the-four-artefacts-worth-versioning-with-differentiated-demands">The four artefacts worth versioning (with differentiated demands)&lt;/h2>
&lt;p>Not all datasets are versioned the same way or at the same frequency. The typical LLM system in production handles four artefacts worth governing separately.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Artefact&lt;/th>
&lt;th>What it is&lt;/th>
&lt;th>Typical size&lt;/th>
&lt;th>New-version frequency&lt;/th>
&lt;th>Who consumes it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Training / fine-tuning dataset&lt;/strong>&lt;/td>
&lt;td>Input/output pairs (or conversations) that train the adapter or the model.&lt;/td>
&lt;td>10⁴ – 10⁷ examples · 1 – 100 GB&lt;/td>
&lt;td>Per Tune experiment&lt;/td>
&lt;td>Trainer (Axolotl, TRL, Unsloth)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RAG corpus&lt;/strong>&lt;/td>
&lt;td>Indexed documents that feed retrieval.&lt;/td>
&lt;td>10⁵ – 10⁹ chunks · 10 GB – 10 TB&lt;/td>
&lt;td>Almost continuous (streaming ingest)&lt;/td>
&lt;td>Indexer + vector store&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Golden eval set&lt;/strong>&lt;/td>
&lt;td>Curated examples with an expected answer to measure quality.&lt;/td>
&lt;td>10² – 10⁴ examples · MB&lt;/td>
&lt;td>Per product release&lt;/td>
&lt;td>Eval gates in CI&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Enriched retrain dataset&lt;/strong>&lt;/td>
&lt;td>Cases where the system failed + the human correction.&lt;/td>
&lt;td>Hundreds to thousands per quarter&lt;/td>
&lt;td>Per &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain&lt;/a> cycle&lt;/td>
&lt;td>Next Tune&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>All four have common requirements (immutable identity, lineage, schema) and relevant differences:&lt;/p>
&lt;ul>
&lt;li>The &lt;strong>training set&lt;/strong> tends to be large, stable per experiment, and the cost of an error is a lost experiment (expensive but bounded).&lt;/li>
&lt;li>The &lt;strong>RAG corpus&lt;/strong> is enormous, in continuous change, and its versioning is handled through periodic snapshots of the index (not of the raw text). Usually lakeFS or bucket branches; DVC is not the best fit.&lt;/li>
&lt;li>The golden eval set is small but critical: errors here contaminate the whole promotion chain. Here the rigidity of the versioning matters more than anywhere else.&lt;/li>
&lt;li>The &lt;strong>enriched retrain dataset&lt;/strong> is incremental by nature: each Retrain cycle contributes a delta over the previous one. The new version does not overwrite; it inherits and adds.&lt;/li>
&lt;/ul>
&lt;p>Confusing them, treating the RAG corpus as if it were the training set, or the golden eval as if it were just another dataset, is the source of half the operational problems in data versioning.&lt;/p>
&lt;h2 id="why-git-is-not-enough">Why Git is not enough&lt;/h2>
&lt;p>The obvious question: if Git already solves code, why does it not solve data too? Three reasons, two technical and one operational.&lt;/p>
&lt;p>&lt;strong>Reason 1: size.&lt;/strong> A Git repository with a 50 GB dataset becomes unmanageable. &lt;code>git clone&lt;/code> pulls the entire history; &lt;code>git status&lt;/code> walks every file; the pack file in &lt;code>.git/objects&lt;/code> bloats to up to twice the size of the dataset. Git LFS solves the first part (the binary leaves the pack) but introduces its own complexity without addressing the other two reasons.&lt;/p>
&lt;p>&lt;strong>Reason 2: useless binary diff.&lt;/strong> Git assumes text diffs are useful. When a column changes in an 8 GB parquet, the diff is opaque, because the file is binary, compressed and columnar. You cannot do code review on a dataset change the way you do on a function change. You need a semantic diff: how many rows changed, which columns changed, which distribution moved. No native Git gives you that.&lt;/p>
&lt;p>&lt;strong>Reason 3: lineage that crosses repository boundaries.&lt;/strong> This is the most important and the most subtle. The training dataset lives in a bucket. The trainer code lives in a Git repo. The trained model is published to a model registry. Inference in production generates traces in an observability system. Connecting &lt;code>dataset_v3 → adapter_v7 → deployment_d2 → trace t_x9&lt;/code> requires propagating identifiers &lt;strong>across four different systems&lt;/strong>, not inside one repo. Git has no opinion on this.&lt;/p>
&lt;p>Data versioning tools (DVC, lakeFS, Pachyderm, Quilt) exist because they solve the three problems at once: they hang the data outside the Git repo, they offer some form of semantic diff, and they expose stable identities that can be propagated towards experiment tracking and the model registry.&lt;/p>
&lt;h2 id="dvc-vs-lakefs-before-the-unification">DVC vs lakeFS before the unification&lt;/h2>
&lt;p>Until November 2025, the two dominant OSS tools coexisted as complementary approaches.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Axis&lt;/th>
&lt;th>DVC&lt;/th>
&lt;th>lakeFS&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Mental model&lt;/td>
&lt;td>&amp;ldquo;Git for data&amp;rdquo;&lt;/td>
&lt;td>&amp;ldquo;Branching for the data lake&amp;rdquo;&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Granularity&lt;/td>
&lt;td>Individual file&lt;/td>
&lt;td>Whole bucket (with per-branch namespacing)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Storage&lt;/td>
&lt;td>Remote-agnostic (S3, GCS, Azure, MinIO, SSH)&lt;/td>
&lt;td>S3-compatible (S3, MinIO, Ceph)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Workflow&lt;/td>
&lt;td>&lt;code>dvc add&lt;/code> + &lt;code>dvc push&lt;/code> + &lt;code>dvc.yaml&lt;/code> pipelines&lt;/td>
&lt;td>&lt;code>lakectl commit&lt;/code> + branches/merges over the bucket&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Diff&lt;/td>
&lt;td>File hash + external metadata&lt;/td>
&lt;td>Object-level diff + commit log&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Strong cases&lt;/td>
&lt;td>Discrete training datasets, model files, reproducible pipelines&lt;/td>
&lt;td>Large RAG corpora, branching a shared data lake, parallel experiments without duplicating data&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Git integration&lt;/td>
&lt;td>Deep (the &lt;code>.dvc&lt;/code> files are committed to Git)&lt;/td>
&lt;td>Tangential (lakeFS lives alongside)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Who operates it&lt;/td>
&lt;td>MLE team&lt;/td>
&lt;td>Data engineering team&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>In practice, many teams used them at the same time: DVC for the discrete datasets that fed an experiment (they fit in a Git repo thanks to the indirection of the &lt;code>.dvc&lt;/code> pointers), and lakeFS for the large RAG corpus bucket they wanted to branch without duplicating terabytes.&lt;/p>
&lt;h2 id="what-changed-with-the-november-2025-acquisition">What changed with the November 2025 acquisition&lt;/h2>
&lt;p>&lt;a href="https://medium.com/the-modern-scientist/reproducible-ai-versioning-models-prompts-and-data-96dd0337af65">lakeFS acquired DVC&lt;/a> in November 2025. The operational consequence as of May 2026 is modest but relevant:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>There is (as yet) no technical merger of the projects.&lt;/strong> DVC is still DVC and lakeFS is still lakeFS. The current CLIs, formats and workflows have not changed.&lt;/li>
&lt;li>&lt;strong>An explicit combined roadmap towards LLM training and RAG datalakes.&lt;/strong> The merged organisation has stated specific priorities: consistent branching between the dataset and the trained model, native integrations with MLflow / W&amp;amp;B / Langfuse, support for the typical LLM formats (jsonl, parquet with embedded tokenisation), and branch-aware vector indexing.&lt;/li>
&lt;li>&lt;strong>Convergence expected in 2026-2027.&lt;/strong> The market anticipates a single registry with two operating modes (file-level + bucket-branching) under a unified CLI. As of today, teams still combine both.&lt;/li>
&lt;/ul>
&lt;p>The practical reading for 2026: &lt;strong>adopt DVC for discrete training/eval datasets and lakeFS for the RAG corpus&lt;/strong>, but design the lineage so that a future unified registry can absorb both without re-versioning everything. Concretely: use stable identifiers (&lt;code>dataset_id&lt;/code>, &lt;code>version&lt;/code>, &lt;code>commit_hash&lt;/code>) that can be propagated regardless of the tool.&lt;/p>
&lt;h2 id="the-operational-pattern-four-hop-lineage">The operational pattern: four-hop lineage&lt;/h2>
&lt;p>Once you accept that datasets have to be versioned, the question is not &amp;ldquo;which tool&amp;rdquo; but &amp;ldquo;which chain of identifiers connects production to the source data&amp;rdquo;. The pattern the market has consolidated on has four hops:&lt;/p>
&lt;pre tabindex="0">&lt;code>(dataset_id, dataset_version)
│ versioned in DVC or lakeFS
▼
(model_id, model_version)
│ registered in MLflow / W&amp;amp;B with the dataset as input
▼
(deployment_id, prompt_version)
│ registered in the model registry + prompt registry
▼
(trace_id)
│ emitted by the inference engine with OTel
▼
answer visible to the user
&lt;/code>&lt;/pre>&lt;p>Each arrow is a metadata write that crosses the boundary between two systems. If a single arrow is missing, the lineage breaks and the promise of auditability evaporates.&lt;/p>
&lt;p>A concrete example of the flow, using DVC + MLflow:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Data stage: version the dataset&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">dvc add data/finetune_v3.jsonl
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">git add data/finetune_v3.jsonl.dvc data/.gitignore
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">git commit -m &lt;span class="s2">&amp;#34;data: finetune dataset v3&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">dvc push &lt;span class="c1"># uploads the binary to the remote (MinIO/S3)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Tune stage: train while recording lineage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">mlflow run train.py &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -P &lt;span class="nv">dataset_id&lt;/span>&lt;span class="o">=&lt;/span>finetune &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -P &lt;span class="nv">dataset_version&lt;/span>&lt;span class="o">=&lt;/span>v3 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -P &lt;span class="nv">dataset_hash&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="k">$(&lt;/span>dvc get-url data/finetune_v3.jsonl &lt;span class="p">|&lt;/span> sha256sum&lt;span class="k">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># The run records: input dataset + model output&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Eval stage: validate while recording lineage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">mlflow run eval.py &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -P &lt;span class="nv">model_id&lt;/span>&lt;span class="o">=&lt;/span>adapter_customer_v7 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -P &lt;span class="nv">golden_set_id&lt;/span>&lt;span class="o">=&lt;/span>customer_support &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -P &lt;span class="nv">golden_set_version&lt;/span>&lt;span class="o">=&lt;/span>v12
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Deploy stage: the deployment inherits the dataset + golden ids&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Every trace in Observe carries model_version + prompt_version&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># which rewind back to dataset_version&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The equivalent version with lakeFS over the RAG corpus:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Branch for the new corpus embeddings&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">lakectl branch create lakefs://corpus/embed-2026q2 --source main
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Index the corpus on that branch&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">python index_corpus.py --branch embed-2026q2
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Validate before merging to main&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">python eval_retrieval.py --branch embed-2026q2 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --metric recall@10 --threshold 0.78
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># If it passes, merge (changes the corpus that serves production)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">lakectl commit lakefs://corpus/embed-2026q2 -m &lt;span class="s2">&amp;#34;embed: corpus 2026q2&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">lakectl merge lakefs://corpus/embed-2026q2 lakefs://corpus/main
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The virtue of the second flow: while the new corpus is being validated, the production system keeps serving from &lt;code>main&lt;/code> with no interference. The parallel branch works as a real staging area over the whole bucket.&lt;/p>
&lt;h2 id="schema-contracts-data-versioning-without-them-is-an-illusion">Schema contracts: data versioning without them is an illusion&lt;/h2>
&lt;p>Versioning a dataset&amp;rsquo;s content without versioning its &lt;strong>schema&lt;/strong> is a frequent mistake. The problem: a versioned dataset with an implicit schema still breaks silently when a producer (the ingestion team, the annotation team, an ad-hoc script) changes a field.&lt;/p>
&lt;p>A concrete case: a customer support golden eval set, 1000 examples, an &lt;code>expected_output&lt;/code> field originally &lt;code>string&lt;/code>. Someone decides they need to capture several valid answers and changes the field to &lt;code>list[string]&lt;/code>. The eval loader accepts both formats by chance (Python is lax) but the downstream LLM judge receives a different object. The eval keeps passing but now it measures something else.&lt;/p>
&lt;p>Productive pattern: the dataset is versioned with DVC/lakeFS and its schema is versioned with a &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">Schema Registry&lt;/a> (Confluent or Apicurio) or, in less mature systems, with a JSON Schema embedded alongside the dataset. CI blocks any PR that breaks the contract without a version bump.&lt;/p>
&lt;p>A minimal schema for a golden eval entry (illustrative):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">$schema&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">https://json-schema.org/draft/2020-12/schema&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">$id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">https://example.org/schemas/golden_eval_entry/v3.json&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">object&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">required&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">example_id, input, expected_outputs, rubric, segment]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">properties&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">example_id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type: string, format&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">uuid}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">input&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">object&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">required&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">user_query, retrieved_context]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">properties&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">user_query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">string}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">retrieved_context&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type: array, items&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">string}}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">expected_outputs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">array&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minItems&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">items&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">string}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rubric&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">object&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">required&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">must_include, must_not_include, format]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">properties&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">must_include&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type: array, items&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">string}}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">must_not_include&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type: array, items&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">string}}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">format&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">enum&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">text, json, markdown]}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">segment&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">string}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">difficulty&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">enum&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">easy, medium, hard]}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">added_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type: string, format&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">date-time}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">curated_by&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">string}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Operational rules:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Explicit forward/backward compatibility&lt;/strong>: adding an optional field is backward-compatible; removing a required one is breaking. The policy is enforced with a compatibility check in CI.&lt;/li>
&lt;li>&lt;strong>The schema version embedded&lt;/strong> in every row of the dataset (a &lt;code>_schema_version&lt;/code> field). The loader validates that the version matches what the consuming code expects.&lt;/li>
&lt;li>&lt;strong>The schema registry as the single source of truth&lt;/strong>, not as an optional copy of the JSON Schema in four repos.&lt;/li>
&lt;/ul>
&lt;p>Without this level of discipline, &amp;ldquo;we have data versioning&amp;rdquo; means &amp;ldquo;we store the bytes but we do not control what they mean&amp;rdquo;.&lt;/p>
&lt;h2 id="golden-eval-set-the-most-critical-version">Golden eval set: the most critical version&lt;/h2>
&lt;p>Of the four artefacts, the &lt;strong>golden eval set&lt;/strong> is the one that demands the most rigour. A failure here contaminates the whole promotion chain: if the eval lies, the gates approve models that should not pass.&lt;/p>
&lt;p>Three extra disciplines over the golden set:&lt;/p>
&lt;p>&lt;strong>Annotation with measured quality.&lt;/strong> Each example is labelled by a human, and a percentage (10-20 %) is annotated by two independent people. The &lt;strong>inter-annotator agreement&lt;/strong> (Cohen&amp;rsquo;s kappa or pairwise F1) is measured and published; a golden set with kappa &amp;lt; 0.7 is measuring human noise, not model behaviour. Argilla and Label Studio provide the mechanics; what matters is the discipline, not the tool.&lt;/p>
&lt;p>&lt;strong>A strict holdout against contamination.&lt;/strong> The golden set must &lt;strong>never&lt;/strong> enter the training set. The concrete mechanism: hash each golden set &lt;code>input&lt;/code> (sha256 normalised by lowercasing + stripping trivial punctuation) → check in CI against all the training set hashes. If there is an intersection, CI blocks until it is resolved. Without this check, the model passes the eval by memorisation, not by capability. The consequence in production is disastrous: the &amp;ldquo;validated&amp;rdquo; model fails on cases analogous to the golden set that it had not memorised.&lt;/p>
&lt;p>&lt;strong>Additive versioning, never destructive.&lt;/strong> When the golden set grows (each retrain cycle adds cases), &lt;code>golden_v3 = golden_v2 ∪ new_examples&lt;/code>. Never &lt;code>golden_v3 = a different new set&lt;/code>. Only that way can you compare two models trained months apart over the same base + the new delta. If you rewrite the golden set, you cannot tell whether the March model was worse than the May one or whether you were simply measuring different things.&lt;/p>
&lt;p>Summary table of the discipline per artefact:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Practice&lt;/th>
&lt;th>Training set&lt;/th>
&lt;th>RAG corpus&lt;/th>
&lt;th>Golden eval set&lt;/th>
&lt;th>Enriched retrain&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Immutable versioning&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes (snapshots)&lt;/td>
&lt;td>&lt;strong>Yes, critical&lt;/strong>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Schema with a contract&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Recommended&lt;/td>
&lt;td>&lt;strong>Yes, critical&lt;/strong>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Double annotation&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Not applicable&lt;/td>
&lt;td>&lt;strong>Yes (10-20 %)&lt;/strong>&lt;/td>
&lt;td>Yes (10-20 %)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Holdout vs other datasets&lt;/td>
&lt;td>N/A&lt;/td>
&lt;td>N/A&lt;/td>
&lt;td>&lt;strong>Yes, hash check&lt;/strong>&lt;/td>
&lt;td>Yes (vs golden)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Drift check vs previous version&lt;/td>
&lt;td>Recommended&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Recommended&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Lineage to deployment&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="promotion-gates-the-dataset-is-promoted-like-the-model">Promotion gates: the dataset is promoted like the model&lt;/h2>
&lt;p>A candidate dataset (a freshly enriched &lt;code>golden_v13&lt;/code>, an &lt;code>enriched_retrain_2026_q2&lt;/code> resulting from the Retrain cycle) does not enter production just by being in the bucket. It goes through gates equivalent to the model&amp;rsquo;s or the prompt&amp;rsquo;s:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Schema validation&lt;/strong> — the contract is met. Blocked in CI if not.&lt;/li>
&lt;li>&lt;strong>Quality validation&lt;/strong> — a random 5-10 % sample reviewed by a human with a quality score ≥ 4/5. Blocked if the sample fails.&lt;/li>
&lt;li>&lt;strong>Holdout segregation check&lt;/strong> — for golden sets and enriched datasets, a hash check against all the other active datasets. Blocked if there is overlap.&lt;/li>
&lt;li>&lt;strong>Drift check vs previous version&lt;/strong> — a KS test over the distribution of the input embeddings, or simpler metrics (mean length, segment distribution, the ratio of each label). A warning if the drift is high with no documented cause; a block if it is very high.&lt;/li>
&lt;li>&lt;strong>Lineage check&lt;/strong> — the dataset explicitly declares which version it inherits from and what changed. Without that metadata, it does not go in.&lt;/li>
&lt;/ol>
&lt;p>Only when all five gates pass is the dataset tagged &lt;code>production-ready&lt;/code> and the downstream pipelines that depend on it unblocked (the next Tune, the next product release, the next eval cycle).&lt;/p>
&lt;h2 id="the-on-premise-stack-applied">The on-premise stack applied&lt;/h2>
&lt;p>On a generic infrastructure with an &lt;strong>RTX 4090&lt;/strong> (24 GB VRAM, a development / small-batch profile) and a &lt;strong>4×H100 SXM cluster&lt;/strong> (80 GB VRAM each, NVLink, training and production inference), data versioning fits without a dedicated GPU for the versioning itself, since versioning lives on CPU + storage, but it does touch the GPU for the drift checks that require embeddings.&lt;/p>
&lt;p>Typical topology:&lt;/p>
&lt;pre tabindex="0">&lt;code>┌────────────────────────────────────────────────────────────┐
│ Object store (MinIO or Ceph) │
│ buckets: /training-sets /corpus-rag │
│ /golden-evals /enriched-retrain │
└────────────────────────┬───────────────────────────────────┘
│
┌─────────────────┼──────────────────┐
│ │ │
┌───▼────┐ ┌────▼────┐ ┌────▼─────┐
│ DVC │ │ lakeFS │ │ MLflow │
│ remote │ │ branches│ │ Tracking │
└───┬────┘ └────┬────┘ └────┬─────┘
│ │ │
└─────────────────┴──────────────────┘
│
┌──────▼──────┐
│ CI/CD gates │
│ (Forgejo / │
│ GitLab) │
└──────┬──────┘
│
┌──────────┴───────────┐
│ │
┌─────▼──────┐ ┌─────▼─────┐
│ RTX 4090 │ │ 4×H100 │
│ (drift │ │ (training │
│ embeds, │ │ + │
│ validates)│ │ serving) │
└────────────┘ └───────────┘
&lt;/code>&lt;/pre>&lt;p>Operational notes:&lt;/p>
&lt;ul>
&lt;li>The &lt;strong>object store&lt;/strong> (MinIO or Ceph) serves both as the DVC remote and as lakeFS storage. One storage plane, two views.&lt;/li>
&lt;li>The &lt;strong>schema checks&lt;/strong> and &lt;strong>holdout hashing&lt;/strong> are fast CPU-bound tasks; the CI runner executes them without a GPU.&lt;/li>
&lt;li>The &lt;strong>embedding drift check&lt;/strong> needs an encoder; the RTX 4090 handles this without touching the production cluster. A small encoder (BGE-small, E5-small, ~100M parameters) processes 10⁴ examples in a few minutes.&lt;/li>
&lt;li>The &lt;strong>H100 cluster&lt;/strong> stays free for training and serving, with no contamination from versioning jobs.&lt;/li>
&lt;/ul>
&lt;h3 id="when-do-you-not-need-dvclakefs">When do you NOT need DVC/lakeFS?&lt;/h3>
&lt;p>There is an opposite position defended with figures in the &lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">continuous fine-tuning post&lt;/a>: for small systems with a single team, datasets &amp;lt; 1 GB and a handful of adapters, &lt;strong>Postgres + pgvector + an S3 bucket + hashed filenames&lt;/strong> are enough. The operational complexity of DVC/lakeFS does not pay for itself.&lt;/p>
&lt;p>The dividing line is reasonable:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>You do not need DVC/lakeFS&lt;/strong>: a single team, small datasets, few adapters, no multiple products sharing data.&lt;/li>
&lt;li>&lt;strong>You do need it&lt;/strong>: multiple teams, datasets &amp;gt; 10 GB, several products sharing a golden eval set, external compliance demanding batch traceability, or an institutionalised quarterly retrain cycle.&lt;/li>
&lt;/ul>
&lt;p>Adopting DVC + lakeFS before you need them is overhead. Adopting them six months late is losing six months of lineage irrecoverably.&lt;/p>
&lt;h2 id="seven-pitfalls-that-turn-data-versioning-into-theatre">Seven pitfalls that turn data versioning into theatre&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Versioning the data but not the schemas.&lt;/strong> The content is versioned, the contract changes silently, the system breaks without the versioning capturing it. A Schema Registry is not optional; it is half the problem.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>The same S3 path overwritten.&lt;/strong> &amp;ldquo;Upload &lt;code>training.jsonl&lt;/code> to the bucket&amp;rdquo; and the next experiment rewrites the file. S3 versioning (if it is enabled) saves your skin, but without an immutable identifier propagated to MLflow you cannot rewind. The correct pattern: &lt;code>training_v3.jsonl&lt;/code> or &lt;code>training/2026q2/&amp;lt;sha&amp;gt;.jsonl&lt;/code>, never the same name.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>A golden eval set without a strict holdout.&lt;/strong> Without a hash check against training, the model memorises the eval and passes without having learned. It is the LLM equivalent of an exam the teacher announces in advance: everyone passes, nothing has been measured.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Not recording dataset → model lineage.&lt;/strong> When an incident requires knowing what data a certain model was trained on, the correct answer is a query to MLflow / W&amp;amp;B. If the answer is &amp;ldquo;let&amp;rsquo;s ask whoever trained it&amp;rdquo; (assuming they are still on the team), the lineage does not exist.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>DVC added six months late.&lt;/strong> Adopting versioning in month 1 = a nuisance. Adopting it in month 6 = an irrecoverable loss of six months of datasets that can no longer be reconstructed. The curse of &amp;ldquo;we&amp;rsquo;ll add it later&amp;rdquo;.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>lakeFS with branches that never get merged.&lt;/strong> Parallel branches over the corpus are useful for experimenting; kept indefinitely without merging, operations turn into a graveyard of half-updated branches. Explicit policy: merge or destroy within N weeks.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Schema validation only in production.&lt;/strong> The contract is validated when the dataset is already in production and the model already trained. By then, the incident has already happened. The validation has to be &lt;strong>in CI&lt;/strong>, before the merge, over the delta the PR introduces.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="a-datasets-cycle-on-one-screen">A dataset&amp;rsquo;s cycle on one screen&lt;/h2>
&lt;pre tabindex="0">&lt;code>┌─────────────────────────────────────────────────────────────┐
│ Producer (ingest / annotation / retrain loop) │
└────────────────┬────────────────────────────────────────────┘
│
▼ (commit a candidate version)
┌─────────────────────────┐
│ CI gates │
│ - Schema validation │
│ - Quality sampled │
│ - Holdout hash check │ ── fails → PR blocked
│ - Drift vs previous │
│ - Lineage declared │
└────────────┬────────────┘
│ passes
▼
┌─────────────────────────┐
│ DVC tag or lakeFS commit│
│ + MLflow registry │ ← immutable version
│ + Schema Registry │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ Downstream pipeline │
│ Tune / Eval / Deploy │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ Production trace │
│ → rewinds to dataset │
└─────────────────────────┘
&lt;/code>&lt;/pre>&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;p>At top level, this post leaves out:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Vector store versioning&lt;/strong> proper: an embedding index is not versioned like a raw dataset because it depends on the embedding model. Changing the embedder rewrites the whole index. It is a different animal and deserves separate treatment (recall, ANN parameters, index branching vs full re-embedding).&lt;/li>
&lt;li>&lt;strong>Standardised lineage tooling&lt;/strong> (OpenLineage, Marquez): how to emit and consume lineage events interoperably between systems.&lt;/li>
&lt;li>&lt;strong>Data quality frameworks&lt;/strong> (Great Expectations, Soda, Deequ): how to write suites of &amp;ldquo;expectations&amp;rdquo; over a dataset and enforce them on every version.&lt;/li>
&lt;li>&lt;strong>Privacy-preserving versioning&lt;/strong>: federated learning without centralising the dataset, differential privacy applied to the version that gets distributed.&lt;/li>
&lt;li>&lt;strong>Contamination between third-party golden sets&lt;/strong> (HumanEval, MMLU, etc.) and the training datasets of open models: the problem of &amp;ldquo;the model passes HumanEval because HumanEval is in its pretraining&amp;rdquo;.&lt;/li>
&lt;/ul>
&lt;p>Each one is worth a post of its own when the field justifies it.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map where this piece fits, the Data section.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the loop&lt;/a> — how the enriched dataset produced by Retrain returns to Data; this post details how to version it properly.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow&lt;/a> — the other cross-cutting piece of the lineage; the &lt;code>prompt_version&lt;/code> travels alongside the &lt;code>dataset_version&lt;/code> in every trace.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a> — argues for a minimalist stack (Postgres + pgvector + S3) without DVC/lakeFS for small systems; this post explains when you cross the line to the other side.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals for LLMs: the layer after tracing&lt;/a> — the main consumer of the golden eval set.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">LLM-specific MLOps in 2026&lt;/a> — market context for the complete LLMOps stack.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant for ingestion&lt;/a> — how the ingestion that precedes versioning is materialised.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO/IEC 42001: the AI system&amp;rsquo;s operations manual&lt;/a> — the versioning discipline described here materialises the five controls of Annex A.7 (data quality, acquisition, provenance, preparation) directly, with no additional work.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/eu-ai-act-technical-dossier-on-premise-llm-architecture/">EU AI Act: the technical file article by article&lt;/a> — Art. 10 (data and data governance) requires the four datasets versioned + bias analysis + lineage + representativeness. This post covers the technical mechanics; that one covers the legal obligations and the auditable checklist.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://dvc.org/doc">DVC documentation&lt;/a> — versioning workflows, pipelines and remotes.&lt;/li>
&lt;li>&lt;a href="https://docs.lakefs.io/">lakeFS documentation&lt;/a> — branching, merging and commits over the bucket.&lt;/li>
&lt;li>&lt;a href="https://medium.com/the-modern-scientist/reproducible-ai-versioning-models-prompts-and-data-96dd0337af65">lakeFS acquires DVC, November 2025&lt;/a> — announcement and combined roadmap.&lt;/li>
&lt;li>&lt;a href="https://docs.confluent.io/platform/current/schema-registry/index.html">Confluent Schema Registry&lt;/a> and &lt;a href="https://www.apicur.io/registry/">Apicurio&lt;/a> — schema contracts for streaming data.&lt;/li>
&lt;li>&lt;a href="https://openlineage.io/">OpenLineage&lt;/a> and &lt;a href="https://marquezproject.ai/">Marquez&lt;/a> — open standard for lineage events.&lt;/li>
&lt;li>&lt;a href="https://greatexpectations.io/">Great Expectations&lt;/a> — data quality expectations in CI.&lt;/li>
&lt;li>&lt;a href="https://mlflow.org/docs/latest/tracking.html">MLflow Tracking&lt;/a> — input datasets as first-class artefacts since MLflow 2.4.&lt;/li>
&lt;li>&lt;a href="https://www.pachyderm.com/">Pachyderm&lt;/a> and &lt;a href="https://quiltdata.com/">Quilt&lt;/a> — historical alternatives to DVC/lakeFS.&lt;/li>
&lt;li>On eval set contamination: &lt;em>&amp;ldquo;Stop Uploading Test Data in Plain Text&amp;rdquo;&lt;/em> (Magar &amp;amp; Schwartz, 2022) and later work on detecting contamination in pretraining corpora.&lt;/li>
&lt;/ul></description></item></channel></rss>