
A RAG system can fail without the model changing at all. The prompt is the same. The answer policy is the same. The application release is the same. But the embedding index behind retrieval was rebuilt, and suddenly the assistant stops finding the document that used to anchor a good answer.
That is the part I care about in this issue. We often treat vector indexes like background data jobs: ingest, embed, upsert, switch the alias, and move on. That is too loose for production RAG. The index is not just storage. It is part of the runtime behavior of the AI system.
In this issue, we build a .NET lifecycle gate for embedding indexes. The companion repo compares a current index and a candidate index, validates metadata, runs frozen recall checks, creates a snapshot, restores it in a drill, writes lineage, and emits a promotion decision. It includes a real OpenAI-compatible embedding integration for live runs, while keeping a deterministic embedding provider for repeatable tests. No hosted vector database is required for the local sample, but the control model is the same shape I would put around Qdrant, Pinecone, Azure AI Search, pgvector, or another retrieval backend.
The Boundary I Care About Here
A RAG index should not become active merely because an ingestion job completed. Completion only tells you that the job ran. It does not tell you whether the candidate index can still retrieve the evidence your product depends on.
The stronger boundary is promotion. You have a current index serving production traffic. You have a candidate index built from a new corpus, new metadata, new chunking, a new embedding model, or a new backend configuration. Before the runtime alias moves, the candidate has to produce evidence.
The topic is an important topic. Qdrant's snapshot documentation treats snapshots as a way to archive, move, and restore collections. Pinecone's backup documentation describes backups as static copies used for restore, copy, and experimentation workflows. OpenLineage gives us the useful vocabulary of jobs, runs, inputs, outputs, and facets.
Those tools give us primitives. They do not decide your promotion policy. That is the engineering work we do here.
What We Are Actually Building
The repository contains a .NET 10 console project named EmbeddingIndexLifecycleGates. It builds vector records from checked-in support knowledge documents through a configured embedding provider, then evaluates a current index and a candidate index through the same release gate.
The app does this:
- loads a current index manifest
- loads a candidate index manifest
- loads a checked-in support knowledge corpus
- builds vectors for both indexes through deterministic or live embeddings
- validates required metadata on every vector record
- runs frozen recall@K checks against both indexes
- blocks recall regressions before alias promotion
- creates a candidate snapshot
- restores the snapshot and replays recall after restore
- writes a JSON report and an OpenLineage-shaped event
The default run uses deterministic token hashing so tests stay repeatable. The repo also includes OpenAiCompatibleEmbeddingClient, which calls a real OpenAI-compatible embedding endpoint such as Microsoft Foundry's /openai/v1/embeddings route with text-embedding-3-small. That gives us both things I want: fast local gate tests and an honest model-provider boundary for live embedding runs.
The Shape Of The Lifecycle Gate
The flow is a release pipeline for retrieval. Current and candidate indexes are built from manifests. The candidate has to pass metadata, recall, snapshot, and restore gates before it can be recommended for promotion.
Notice the boundary: embedding model calls build and query the index, but the promotion decision itself is deterministic. An LLM may eventually use this index. It may summarize retrieved evidence or draft an answer. It does not get to decide whether the candidate index is good enough to serve production retrieval.
The Embedding Model Is A Real Boundary
The application has an IEmbeddingModel interface with two implementations. DeterministicEmbeddingModel is for tests and no-network demonstrations. OpenAiCompatibleEmbeddingClient is the real integration path.
using var response = await _httpClient.PostAsJsonAsync("embeddings", payload, cancellationToken);
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"Embedding request failed with HTTP {(int)response.StatusCode}: {responseText}");
}
var parsed = await response.Content.ReadFromJsonAsync<EmbeddingResponse>(cancellationToken);
var embedding = parsed?.Data.FirstOrDefault()?.Embedding
?? throw new InvalidOperationException("Embedding response did not contain data[0].embedding.");
if (embedding.Length != expectedDimension)
{
throw new InvalidOperationException(
$"Embedding dimension mismatch for model '{_config.ModelId}': returned {embedding.Length}, manifest expects {expectedDimension}.");
}That dimension check matters. If the manifest says the candidate index is built with a 768-dimensional embedding model, the provider has to return 768-dimensional vectors. A model swap that changes vector shape should fail at build time, not halfway through retrieval.
The pipeline also checks the runtime embedding configuration against the manifest before it builds indexes in live mode. If Embedding:ModelId says one deployment and the manifest says another, the run fails before it spends tokens or produces an ambiguous artifact.
For Microsoft Foundry, point the client at the OpenAI-compatible v1 endpoint and set ModelId to your embedding deployment name:
$env:INDEXGATE_Embedding__Provider = "openai-compatible"
$env:INDEXGATE_Embedding__BaseUrl = "https://YOUR-RESOURCE-NAME.services.ai.azure.com/openai/v1"
$env:INDEXGATE_Embedding__ModelId = "text-embedding-3-small"
$env:INDEXGATE_Embedding__Dimensions = "768"
$env:INDEXGATE_Embedding__CredentialHeaderName = "api-key"
$env:INDEXGATE_Embedding__CredentialPrefix = ""
$env:INDEXGATE_EMBEDDING_API_KEY = "<your-foundry-api-key>"
dotnet run --project EmbeddingIndexLifecycleGatesThe endpoint detail is easy to get wrong. For this OpenAI-compatible embeddings path, use the resource endpoint with /openai/v1, not a Foundry project URL such as /api/projects/.... With API-key authentication, the client sends the raw key in the api-key header and ignores CredentialPrefix. The code also normalizes markdown-copied URLs, but the clean configuration value should still be the plain URL.
If you prefer Microsoft Entra ID, use Authorization plus Bearer and pass a short-lived access token from Azure CLI instead of an API key. The Dimensions setting is important here because the manifest expects 768-dimensional vectors. Foundry's text-embedding-3-small can produce that shape when the request includes the dimensions parameter.
The Manifest Is The Artifact Contract
The candidate index starts with a manifest. That manifest says what the index is, which alias it wants to serve, which embedding model and vector dimension were used, which corpus version it came from, and which metadata fields every vector record must carry.
{
"indexName": "support-rag-candidate",
"version": "2026.09.19",
"alias": "support-rag-active",
"environment": "candidate",
"embeddingModelId": "text-embedding-3-small",
"embeddingDimension": 768,
"distanceMetric": "cosine",
"sourceCorpusVersion": "support-knowledge-2026.09.19",
"dataContractVersion": "rag-doc-v1",
"requiredMetadataFields": [
"document_id",
"title",
"owner_team",
"source_system",
"source_uri",
"schema_version",
"sensitivity",
"audience",
"updated_at_utc",
"content_hash_sha256",
"index_version",
"embedding_model_id"
],
"allowedSensitivity": [
"public",
"internal"
],
"allowedAudiences": [
"support-assistant",
"engineering-assistant"
],
"documentIds": [
"KB-101",
"KB-102",
"KB-103",
"KB-104",
"KB-105",
"KB-106",
"KB-107"
]
}This boundary makes the index reviewable before you talk about retrieval quality. A candidate index with the wrong embedding dimension, missing source URIs, unsupported audience metadata, or restricted records should fail before recall numbers enter the conversation.
Metadata Is Part Of Retrieval Quality
Metadata is not decoration on a vector record. It decides what can be filtered, audited, attributed, explained, and removed later. If the index cannot tell you who owns a document or where it came from, the RAG system has already lost an important control surface.
The metadata gate checks missing records, duplicate vectors, vector dimensions, required metadata, sensitivity, audience, and content hash consistency.
foreach (var field in manifest.RequiredMetadataFields)
{
if (!record.Metadata.TryGetValue(field, out var value) || string.IsNullOrWhiteSpace(value))
{
violations.Add(new MetadataGateViolation
{
Scope = record.DocumentId,
Reason = $"missing_required_metadata:{field}"
});
}
}
if (record.Metadata.TryGetValue("sensitivity", out var sensitivity) &&
!manifest.AllowedSensitivity.Contains(sensitivity, StringComparer.OrdinalIgnoreCase))
{
violations.Add(new MetadataGateViolation
{
Scope = record.DocumentId,
Reason = $"sensitivity_not_allowed:{sensitivity}"
});
}That is the kind of check I want before a rebuild becomes active. The model should not be asked to compensate for missing ownership, weak lineage, or unsafe document admission.
Recall Regression Is The Release Gate
A candidate index can pass ingestion and still make the system worse. Maybe chunking changed. Maybe a source connector dropped a category. Maybe a filter field was renamed. Maybe the embedding model changed and now a core query retrieves the wrong document.
The repo uses a frozen recall set. Each query has expected document IDs. The gate searches both the current and candidate indexes and computes recall@K.
var queryVector = await _embeddingModel.EmbedAsync(
evalCase.Query,
index.Manifest.EmbeddingDimension,
cancellationToken);
var topResults = _searchEngine.Search(index, queryVector, searchK);
var topDocumentIds = topResults.Select(result => result.DocumentId).ToHashSet(StringComparer.OrdinalIgnoreCase);
var matched = evalCase.ExpectedDocumentIds
.Where(expected => topDocumentIds.Contains(expected))
.ToList();
var recall = evalCase.ExpectedDocumentIds.Count == 0
? 1.0
: matched.Count / (double)evalCase.ExpectedDocumentIds.Count;The default sample has four recall cases: password reset and MFA, SAML setup, incident escalation, and the new embedding-index lifecycle runbook. The current index does not contain the new lifecycle runbook. The candidate does. That gives us a simple but useful release shape: the candidate improves coverage without regressing existing cases.
In a production system, this eval set should include your painful historical misses, high-traffic intents, compliance-sensitive queries, and representative language from real users. It should be versioned like a test suite, not improvised during a release.
Snapshots Are Not Just Backups
Backups and snapshots are often treated as disaster recovery features. They are that, but for RAG they are also release artifacts. If you cannot restore the candidate index and get the same retrieval behavior, you do not have a deployable artifact. You have a one-time build output.
The local repo writes a JSON snapshot because the sample is intentionally vector-store-free. The drill still checks the important behavior: checksum, manifest, record count, and recall after restore.
var snapshotChecksum = ComputeIndexChecksum(index);
var snapshot = new IndexSnapshotEnvelope
{
SnapshotFormatVersion = "local-json-v1",
CreatedAtUtc = DateTimeOffset.UtcNow,
ChecksumSha256 = snapshotChecksum,
Index = index
};
await File.WriteAllTextAsync(snapshotPath, JsonSerializer.Serialize(snapshot, SnapshotJsonOptions), cancellationToken);
var restored = JsonSerializer.Deserialize<IndexSnapshotEnvelope>(
await File.ReadAllTextAsync(snapshotPath, cancellationToken),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });With a real backend, I would replace this local file with the backend's native snapshot or backup primitive. The gate would still require a restore drill. A snapshot that has never been restored is a promise, not evidence.
Promotion Is A Deterministic Decision
Once metadata, recall, and restore evidence exist, promotion becomes plain code. The candidate does not get promoted because the rebuild looks healthy. It gets promoted because it cleared the configured gates.
if (!candidateMetadata.Passed)
{
reasons.Add("Candidate metadata contract failed.");
return new PromotionRecommendation { Decision = GateDecision.Block, Reasons = reasons };
}
if (candidateRecall.AverageRecall < thresholds.MinCandidateAverageRecall)
{
reasons.Add($"Candidate average recall {candidateRecall.AverageRecall:F3} is below the required {thresholds.MinCandidateAverageRecall:F3}.");
return new PromotionRecommendation { Decision = GateDecision.Block, Reasons = reasons };
}
var averageRecallRegression = currentRecall.AverageRecall - candidateRecall.AverageRecall;
if (averageRecallRegression > thresholds.MaxAverageRecallRegression)
{
reasons.Add($"Candidate average recall regressed by {averageRecallRegression:F3}.");
return new PromotionRecommendation { Decision = GateDecision.Block, Reasons = reasons };
}This is the deterministic boundary. The model can be probabilistic. Retrieval ranking can be approximate. User language can be messy. Promotion should still be a reviewable release decision.
Lineage Makes The Release Explainable
The app writes a compact OpenLineage-shaped event beside the JSON report. It records the run id, current index, candidate index, recall score, metadata coverage, snapshot checksum, and decision.
run = new
{
runId = report.RunId,
facets = new
{
embeddingIndexLifecycleGate = new
{
decision = report.Recommendation.Decision.ToString(),
currentIndex = report.CurrentManifest.IndexName,
currentVersion = report.CurrentManifest.Version,
candidateIndex = report.CandidateManifest.IndexName,
candidateVersion = report.CandidateManifest.Version,
candidateAverageRecall = report.CandidateRecall.AverageRecall,
candidateMetadataCoverage = report.CandidateMetadata.MetadataCoverage,
snapshotChecksumSha256 = report.SnapshotDrill.SnapshotChecksumSha256
}
}
}That is enough for the local example. In production, I would send formal lineage events to the same place the rest of the data platform records job runs. The important habit is this: an index promotion should leave behind a trail that an engineer can inspect during a later incident.
A Local Run Tells The Story
Run the companion repo:
dotnet run --project EmbeddingIndexLifecycleGatesThe default output is deliberately plain:
Embedding Index Lifecycle Gates for RAG Systems
Embedding provider: deterministic
Embedding base URL: http://localhost:11434/v1
Embedding route: http://localhost:11434/v1/embeddings
Embedding model: text-embedding-3-small
Credential header: Authorization
Credential prefix: Bearer
Credential length: 6
Run: index_gate_20260919000000
Current index: support-rag-current 2026.09.12
Candidate index: support-rag-candidate 2026.09.19
Documents: current=6 candidate=7
Eval cases: 4
Current recall@3: 0.75
Candidate recall@3: 1.00
Metadata: 7/7 records valid
Snapshot: restored=True records=7
Decision: Promote
- Candidate cleared metadata, recall, snapshot, and restore gates.
- Average recall moved from 0.750 to 1.000.
- Snapshot restore verified 7 records.
Report: data\reports\embedding-index-lifecycle-report.json
Lineage: data\reports\openlineage-events.jsonl
Snapshot: data\snapshots\support-rag-candidate_2026.09.19.snapshot.jsonThis is exactly the kind of output I want from a retrieval release. You can see the candidate version, the number of documents, the recall comparison, the metadata result, the restore result, and the decision. It is not a dashboard screenshot. It is release evidence.
The Deterministic Boundary
The probabilistic layer may answer with retrieved evidence, generate a summary, or explain why a document matters. It does not own index promotion.
In this implementation, deterministic code owns:
- which index manifest is current
- which candidate manifest is under review
- which metadata fields are required
- which sensitivities and audiences are allowed
- which recall cases must pass
- how much recall regression is allowed
- whether a snapshot restore drill is required
- which promotion evidence is written
The LLM can use the active index after promotion. It cannot vote the index into production.
Why This Architecture Works
This architecture works because it treats retrieval as release-managed runtime behavior.
- The candidate index is named, versioned, and inspectable.
- Metadata failures are caught before recall evaluation becomes a distraction.
- Recall is measured against frozen product-relevant queries.
- The current index remains the baseline until the candidate clears the gate.
- Snapshot and restore are tested before the artifact is trusted.
- Lineage ties the index, corpus, eval set, snapshot, and report together.
- The promotion decision is deterministic and reviewable.
That is the practical difference between rebuilding an index and operating a RAG system. Rebuilds are jobs. Promotions are decisions.
Potential Enhancements
The next version should add a real vector store adapter. Qdrant would be a natural first target because collection snapshots map cleanly to this issue. Pinecone would make backup and restore behavior concrete for a managed serverless index. pgvector would be useful when the team wants the index lifecycle close to existing relational release controls.
I would also add chunk-level diff reports, query latency checks, embedding cost reports, sparse+dense hybrid recall, per-tenant corpus gates, alias promotion and rollback scripts, and an approval record for high-risk corpus changes.
The larger lesson is that RAG quality is not only prompt quality. It is also data admission, index construction, retrieval evaluation, backup and restore discipline, and release evidence.
Final Notes
Embedding indexes deserve the same seriousness we give models, prompts, tools, and runtime flags. They change system behavior. They can regress silently. They can make a good answer impossible before the model sees the prompt.
The practical lesson is simple: build a candidate index, compare it to the current one, validate metadata, replay recall, restore from snapshot, and write promotion evidence before moving the runtime alias. Then your RAG system has a release boundary instead of a background indexing habit.
Explore the companion repository at the GitHub repository.
See you in the next issue.
Stay curious.
Join the Newsletter
Subscribe for AI engineering insights, system design strategies, and workflow tips.