Reindex an Azure AI Search index without downtime¶
Draft: not validated
Not yet dry-run. Do not use on a live system until the validation record is complete and signed off.
1. Summary¶
This runbook rebuilds or refreshes an Azure AI Search index while users keep getting answers. There are two paths:
- Path A, in place: reset the indexer and run it again on the same index. Use it when the schema does not change.
- Path B, rebuild and switch: build a new index next to the live one, check it, then repoint an index alias in one call. Use it for any change that forces a rebuild, and for any change you might need to undo quickly.
Path B is the default for production. It is the only path with an instant rollback.
Facts checked against Microsoft Learn on 2 November 2026. Latest stable data-plane REST API: 2026-04-01. Index aliases are generally available from 2026-04-01. Reset docs, reset skills, resync and the enrichment cache are preview (latest preview REST API: 2026-08-01-preview).
2. When to use and when not to¶
Use this runbook when:
- You change a field's type, name or attributes (
searchable,filterable,sortable,facetable). - You change or assign an analyser on an existing field, or change an analyser definition.
- You change chunking, the embedding model, vector dimensions or vector compression.
- The index holds orphans or deleted documents because deletion detection was missing from the first run.
- Source content changed in bulk (re-OCR, re-extraction, skill change) and must be reprocessed.
Do not use it when:
- You only add a field, a scoring profile, a synonym map, a semantic configuration or CORS settings. These are in-place updates. See the decision table in section 5.
- You only need a handful of documents refreshed. Use reset docs (preview) or push those documents directly.
- You are moving to a new search service or region. Aliases work within one service only, so switch at the application configuration level instead. The steps for building and checking the new index still apply.
- The change is an embedding-model upgrade across many indexes. Use PB-RAG-09-02 first, then this runbook for each index.
3. What you must know first¶
Master these before you start. The reviewer will ask about each one.
- Rebuild triggers. Changing a field's name, data type or the attributes searchable, filterable, sortable or facetable needs a full rebuild. Analysers can only be assigned when the field is created (
searchAnalyzeris the exception). Analyser definitions cannot be changed or deleted without a rebuild. Removing a field completely needs a rebuild. - Aliases. An alias maps a name to exactly one index. Queries and document operations can use the alias. You cannot delete an index through an alias, and an indexer's
targetIndexNamecannot be an alias. An alias update can take up to 10 seconds to propagate. You cannot delete an index while an alias points to it (400 Bad Request). The Serverless Developer tier (preview) does not support index aliases, so Path B needs a Dedicated tier. - Reset semantics. Reset clears the indexer's internal high-water mark. It is passive: you must call Run afterwards. You cannot undo a reset. Reset does not delete orphaned documents from the index. With a skillset and enrichment cache, a reset also refreshes the cache.
- Indexer limits. Plan for a 2-hour maximum per indexer run. That is the limit in the default multitenant execution environment, where most skillsets run. The 24-hour limit applies only when the indexer runs in the private execution environment (
executionEnvironmentset to private, Standard2 or higher) or is an older indexer that cannot use the multitenant environment. On S3 HD and Serverless services, all indexers also share 24 hours of cumulative runtime per service in each 24-hour UTC window. A running indexer cannot be paused or stopped. For long jobs, schedule the indexer every 5 minutes so each run resumes where the last one stopped. This only works with change detection. - Change and deletion detection. Blob indexers detect changes automatically from
LastModified. Deletions are not detected unless you set a deletion policy (native blob soft delete or a soft-delete metadata column). The deletion policy must exist from the very first indexer run. If it did not, Microsoft's advice is a new index and a new indexer with the policy in place. Neither soft-delete policy applies to one-to-many parsing modes (jsonArray,jsonLines,delimitedText); delete those documents with the Documents API instead. With index projections, chunks of a changed document are added, updated or deleted on the next run, but chunks of a deleted parent are only removed if a deletion policy detects the deletion. Azure SQL and Cosmos DB sources need change detection configured. - Capacity. During Path B you hold two copies of the data. Storage and vector index quota are per partition, and the vector quota is a hard limit: indexing fails once it is exceeded. Indexing load can throttle queries.
- Vectors with
stored: false. If a vector field hasstoredset to false, partial updates must resend the vector. Otherwise the vector is dropped on reindexing.
4. Prerequisites and access¶
| Item | Detail |
|---|---|
| Roles | Search Service Contributor (create indexes, indexers, aliases). Search Index Data Contributor for push writes and test queries. Use Microsoft Entra ID, not admin keys, wherever possible. Indexers have service-level permissions: an indexer can write to any index on the service, whatever index-scoped roles you assign. |
| Service authentication | Role-based access must be enabled on the search service (Settings > Keys > Role-based access control, or Both while clients move off keys). With API keys only, the Entra ID token in the commands below is not accepted. |
| Enrichment cache (only if used) | The search service identity needs Storage Blob Data Contributor and Storage Table Data Contributor on a general-purpose v2 storage account. |
| Tools | Azure CLI (az), curl, jq, Python 3.10+ with azure-search-documents 12.x and azure-identity. |
| Definitions in Git | Current index, indexer, data source and skillset JSON, exported in step 1. The new definitions are reviewed in a pull request. |
| Golden query set | 30-100 representative queries with expected top results or relevance labels (see PB-RAG-08-02). |
| Capacity check | Free storage and vector quota for a second full copy (step 2). If not available, add a partition first and agree the cost. |
| Change approval | Change ticket with the cut-over window, the rollback owner and the sign-off owner. |
| Sandbox evidence | A dry-run on a sandbox service with a representative corpus slice, with timings recorded (section 13). |
| Application readiness | The application queries the alias, not the index name. If it does not yet, do step 3 as a separate, earlier change. |
Get a data-plane token (Microsoft Entra ID):
export SEARCH_ENDPOINT="https://${SEARCH_SERVICE}.search.windows.net"
export API_VERSION="2026-04-01"
export TOKEN="$(az account get-access-token --scope https://search.azure.com/.default --query accessToken --output tsv)"
5. Decide your approach¶
5.1 Decision table¶
| Change you need | Rebuild needed? | Path | Notes |
|---|---|---|---|
| Add a new field | No | In-place schema update, then backfill | Existing documents get null until the next indexing run. Backfill with reset and run (A) or mergeOrUpload. |
| Add, change or delete scoring profiles, synonym maps, semantic configurations, CORS, index description | No | In-place update | No reindex needed. Re-run the golden queries afterwards. |
Set retrievable on an existing field |
No | In-place update | |
Change searchAnalyzer on a field that already has indexAnalyzer |
No | In-place update | |
| Add a new analyser definition (for new fields) | No rebuild, but brief downtime | B for zero downtime | The Create or Update Index API needs allowIndexDowntime=true to add analysers. That takes the index offline for at least a few seconds and can impair writes for minutes. |
| Change a field's name, type, or searchable, filterable, sortable or facetable attributes | Yes | B | |
| Assign an analyser to an existing field, or change or delete an analyser, tokenizer, token filter or char filter | Yes | B | |
| Remove a field completely | Yes | B | Until rebuilt, the field definition and content stay in the index. |
| Add an existing field to a suggester | Yes | B | |
| Change embedding model or vector dimensions | Yes (new vector field or new index) | B | Queries and documents must use the same model. Switching index and vectorizer together through the alias keeps them consistent. |
| Add scalar or binary quantisation | New vector profile and new vector field | B | Microsoft's steps create a new field and reload data. |
| Change chunk size or overlap (index projections) | Not by schema, but every chunk is reprocessed and re-embedded | B | Index projections delete chunks that no longer exist on the next run and give the remaining chunks new keys, so A would work. While A runs, users see a mix of old and new chunks, and A has no rollback. Chunks of parents deleted without a deletion policy stay in the index. |
| Change a skill, extraction settings or field mappings only | No | A, or B if you need rollback | Path A reprocesses everything and re-bills every skill call. Reset skills (preview) can limit reprocessing to named skills. |
| Source content changed for a few known documents | No | Reset docs (preview) or push | resetdocs with documentKeys, then run. |
| Deletion detection was missing from the first run | Yes | B with a new indexer | Add the deletion policy before the first run of the new indexer. |
| Move to another service, tier or region | Yes | Rebuild in the new service, switch in app configuration | Aliases do not span services. |
5.2 Path comparison¶
| Path A: reset and rerun in place | Path B: rebuild and switch with an alias | |
|---|---|---|
| Downtime | None for queries, but results are mixed old and new while it runs | None. Users see the old index until the switch |
| Rollback | None. Reset cannot be undone | Repoint the alias (seconds) |
| Capacity | One copy | Two copies until decommission |
| Orphan clean-up | No | Yes, by construction |
| Cost | One full re-enrichment | One full re-enrichment, plus temporary extra storage or partitions |
| Use for | Content refresh, skill changes with low risk | Schema, analyser, chunking, embedding and deletion-policy changes, and anything high risk |
Rule of thumb: if you would be unhappy explaining a bad result to the client for a day, use Path B.
6. Steps¶
Placeholders: ${SEARCH_SERVICE}, ${RG}, ${ALIAS} (for example kb), ${OLD_INDEX} (kb-v1), ${NEW_INDEX} (kb-v2), ${OLD_INDEXER}, ${NEW_INDEXER}, ${NEW_SKILLSET}, ${DATA_SOURCE}. Never paste keys into tickets or chat.
Snippets used (in the pack's snippets/ folder):
ops/reindex/reindex.sh: creates the new index and indexer, enables the indexer, runs it and waits until it has caught up (steps 5 and 6). Refuses to run against the index the alias points to. Exit 0 caught up, 1 failed, 2 bad usage.ops/reindex/switch_alias.py: points the alias at one index, waits 15 seconds and confirms (step 10). The same script rolls back (section 8). Exit 0 confirmed, 1 the alias did not move.defs/after/indexer-kb-v2.json: the new indexer definition used byreindex.sh(step 5). Copy it todefs/after/and change the names to yours.
Stage 0: Path A only (reset and rerun in place)¶
Use this short path only when section 5 says A.
- Export the current definitions (step 1 below).
Irreversible: reset cannot be undone
A reset makes the indexer reprocess every document, re-bills every skill call, and users see a mix of old and new results until it finishes. It does not remove orphaned documents. There is no quick rollback: see the Path A row in section 8.
- Reset the indexer, then run it:
curl -sS -X POST "${SEARCH_ENDPOINT}/indexers('${OLD_INDEXER}')/search.reset?api-version=${API_VERSION}" \
-H "Authorization: Bearer ${TOKEN}" -o /dev/null -w "reset HTTP %{http_code}\n" # expect 204
curl -sS -X POST "${SEARCH_ENDPOINT}/indexers('${OLD_INDEXER}')/search.run?api-version=${API_VERSION}" \
-H "Authorization: Bearer ${TOKEN}" -o /dev/null -w "run HTTP %{http_code}\n" # expect 202
- For a few known documents, use reset docs (preview) instead of a full reset:
curl -sS -X POST "${SEARCH_ENDPOINT}/indexers/${OLD_INDEXER}/resetdocs?api-version=2026-08-01-preview" \
-H "Authorization: Bearer ${TOKEN}" -H "Content-Type: application/json" \
-d '{"documentKeys": ["1001", "4452"]}'
Then call Run. Microsoft's guidance is to call Run a second time to continue from the last high-water mark. Status currentState.mode shows indexingResetDocs until all keys are processed.
- Monitor with the status call in step 6 below.
Verify: reset returned 204 and run returned 202. When the run ends, lastResult.status is success, itemsFailed is 0 or within the agreed tolerance, and the golden queries pass.
Stage 1: Prepare (Path B)¶
Step 1. Export and commit the current definitions.
mkdir -p defs/before
for kind in indexes indexers datasources skillsets; do
curl -sS "${SEARCH_ENDPOINT}/${kind}?api-version=${API_VERSION}" \
-H "Authorization: Bearer ${TOKEN}" | jq '.' > "defs/before/${kind}.json"
done
git add defs/before && git commit -m "Snapshot search definitions before ${NEW_INDEX} rebuild"
Verify: each file has a value array, and indexes.json contains ${OLD_INDEX}.
Costs money: extra partitions
If this step leads you to add a partition, you pay for the extra search units until you remove them in step 13. Get the cost approved in the change ticket first. To undo, scale the partition count back down.
Step 2. Check capacity.
curl -sS "${SEARCH_ENDPOINT}/servicestats?api-version=${API_VERSION}" \
-H "Authorization: Bearer ${TOKEN}" | jq '.counters'
curl -sS "${SEARCH_ENDPOINT}/indexes('${OLD_INDEX}')/search.stats?api-version=${API_VERSION}" \
-H "Authorization: Bearer ${TOKEN}" | jq '{documentCount, storageSize, vectorIndexSize}'
Verify: used storage plus storageSize, and used vector quota plus vectorIndexSize, stay below 80% of the service quota (Orion default margin). The new index can be larger than the old one if you add fields, vectors or smaller chunks. If the margin is too small, add a partition (az search service update --partition-count) and wait for it to finish before you continue. Index statistics are collected periodically, so treat them as approximate.
Step 3. Make sure the application queries an alias (one-time).
If no alias exists, create one that points at the current index, then deploy the application change to use it:
curl -sS -X POST "${SEARCH_ENDPOINT}/aliases?api-version=${API_VERSION}" \
-H "Authorization: Bearer ${TOKEN}" -H "Content-Type: application/json" \
-d "{\"name\": \"${ALIAS}\", \"indexes\": [\"${OLD_INDEX}\"]}"
Verify: GET /aliases/${ALIAS} returns "indexes": ["${OLD_INDEX}"], and the application's query logs show traffic through the alias. Do this as its own change, days before the rebuild.
Step 4. Capture the baseline. Run the golden query set against the alias and save the results (top 10 keys and scores per query) to eval/baseline.json. Record the current p50 and p95 SearchLatency and the average ThrottledSearchQueriesPercentage over the last 7 days.
Verify: eval/baseline.json has one entry per golden query, and the latency and throttling baselines are written in the change ticket.
Stage 2: Build the new index (Path B)¶
Step 5. Prepare the new definitions, data source and skillset.
- Put the reviewed index definition in Git as
defs/after/index-${NEW_INDEX}.json.reindex.shcreates the index in step 6. - If you use index projections, create a new skillset whose projections target
${NEW_INDEX}. Name the concrete index, not the alias. The parent key field must beEdm.String, filterable, and not the document key. Do not create a field mapping for the parent key field: it breaks change tracking. - Prepare a new indexer definition from
snippets/defs/after/indexer-kb-v2.jsonand save it asdefs/after/indexer-${NEW_INDEX}.json. It must target the concrete index, never the alias. Point it at the same data source unless the data source itself changes (for example to add a deletion policy). It has a 5-minute schedule so each run resumes where the last one stopped. - If the data source must gain a deletion policy, set it now, before the first run. Native blob soft delete is in the stable API:
{
"name": "kb-blob-ds-v2",
"type": "azureblob",
"credentials": { "connectionString": "ResourceId=/subscriptions/${SUB_ID}/resourceGroups/${RG}/providers/Microsoft.Storage/storageAccounts/${STORAGE};" },
"container": { "name": "kb-docs" },
"dataDeletionDetectionPolicy": {
"@odata.type": "#Microsoft.Azure.Search.NativeBlobSoftDeleteDeletionDetectionPolicy"
}
}
Native soft delete needs blob soft delete enabled with a retention period much longer than the indexer interval, and blob versioning must be off. Document keys must map to a blob property such as metadata_storage_path.
Verify: both definition files are reviewed and merged in a pull request. GET on the data source and the new skillset (if any) returns 200. GET /aliases/${ALIAS} still points at ${OLD_INDEX}.
Load and cost: full re-enrichment
Building the new index calls every skill again (re-billed) and adds indexing load that can throttle production queries. Watch ThrottledSearchQueriesPercentage (section 7). To stop new runs, set "disabled": true on ${NEW_INDEXER}. Users are not affected by the new index until step 10.
Step 6. Build the new index and wait until it has caught up. In a terminal that can stay open for hours:
bash snippets/ops/reindex/reindex.sh \
"defs/after/index-${NEW_INDEX}.json" "defs/after/indexer-${NEW_INDEX}.json"
The script stops with exit 1 before changing anything if the alias already points at the new index, the index already exists, or the data source or skillset is missing. To check progress by hand:
curl -sS "${SEARCH_ENDPOINT}/indexers('${NEW_INDEXER}')/search.status?api-version=${API_VERSION}" \
-H "Authorization: Bearer ${TOKEN}" \
| jq '{status, last: .lastResult | {status, itemsProcessed, itemsFailed, errorMessage}}'
statusis the overall state:running,errororunknown.lastResult.statusisinProgress,success,transientFailureorreset.- In the multitenant environment each run stops at 2 hours. The 5-minute schedule restarts it from where it stopped.
- On a large blob container, the first run can show no documents processed for hours while the indexer lists the blobs. This is expected unless the status shows an error.
- On S3 HD or Serverless services, check the remaining cumulative runtime with
servicestats(preview,2026-08-01-preview,indexersRuntimeproperties) before a long build. - If production queries start throttling (section 7), set
"disabled": trueon the new indexer. The current run finishes, but no new run starts. Resume off-peak.
Verify: reindex.sh exits 0 with "has caught up", meaning two runs in a row succeeded with 0 items processed. itemsFailed is 0, or each failure is explained in the ticket.
Step 7. Keep push writers in step (only if the application also pushes documents). From step 5 until decommission, every push write must go to both indexes, or you must replay writes made during the build. Missing this is the most common cause of "the new index is missing yesterday's documents".
Verify: look up 5 documents pushed during the build by key in ${NEW_INDEX}. Each one is present with the latest values.
Stage 3: Verify before the switch (Path B)¶
Step 8. Check parity and quality by querying ${NEW_INDEX} directly, not the alias.
"""Compare document counts and golden-query overlap between two indexes."""
import json
import os
from azure.identity import DefaultAzureCredential
from azure.search.documents import SearchClient
endpoint = os.environ["SEARCH_ENDPOINT"]
credential = DefaultAzureCredential()
old = SearchClient(endpoint, os.environ["OLD_INDEX"], credential)
new = SearchClient(endpoint, os.environ["NEW_INDEX"], credential)
print("documents old/new:", old.get_document_count(), new.get_document_count())
with open("eval/golden_queries.json", encoding="utf-8") as handle:
queries = json.load(handle)
overlaps = []
for item in queries:
old_keys = [d["id"] for d in old.search(search_text=item["query"], top=10, select=["id"])]
new_keys = [d["id"] for d in new.search(search_text=item["query"], top=10, select=["id"])]
overlap = len(set(old_keys) & set(new_keys)) / max(len(old_keys), 1)
overlaps.append(overlap)
if overlap < 0.5:
print(f"LOW OVERLAP {overlap:.2f}: {item['query']}")
print(f"mean top-10 overlap: {sum(overlaps) / len(overlaps):.2f}")
Adapt the query to your real query shape (hybrid, semantic, filters), using PB-RAG-08-02. Low overlap is not always bad: a schema or chunking change is meant to change results. Judge by relevance labels or evaluator scores, not overlap alone.
Pass criteria (agree them in the ticket before you start):
- Document count matches expectations. Equal for a like-for-like rebuild. Explained difference for chunking changes or orphan removal.
- Spot lookups of 10 known keys return the expected new fields.
- Golden-query quality is equal or better than the baseline on the agreed metric.
vectorIndexSizeandstorageSizeof the new index are within the capacity plan.- p95 query latency on the new index is within 20% of baseline (Orion default).
Verify: every pass criterion is met and recorded in the ticket. If any fails, do not switch: fix the definitions and repeat from step 5 with a new index name.
Stage 4: Cut over (Path B)¶
Step 9. Start a probe. In a second terminal, run a probe that queries the alias every second and logs the HTTP status, latency and document count (see section 13 for the script). Leave it running until step 11.
Verify: the probe prints one line a second with status 200 and the current document count.
Affects every user of the index
From this step, all queries through ${ALIAS} go to ${NEW_INDEX}. Make sure the rollback owner is present and ${OLD_INDEX} is untouched. To roll back, run the same script with NEW_INDEX set to ${OLD_INDEX} (section 8).
Step 10. Repoint the alias. One call, one index in the array:
# Needs SEARCH_ENDPOINT, ALIAS and NEW_INDEX exported in this shell.
python snippets/ops/reindex/switch_alias.py
REST equivalent:
curl -sS -X PUT "${SEARCH_ENDPOINT}/aliases/${ALIAS}?api-version=${API_VERSION}" \
-H "Authorization: Bearer ${TOKEN}" -H "Content-Type: application/json" \
-d "{\"name\": \"${ALIAS}\", \"indexes\": [\"${NEW_INDEX}\"]}"
Verify: the script prints OK: alias ... points to ${NEW_INDEX} and exits 0. With REST, wait 15 seconds, then GET /aliases/${ALIAS} returns "indexes": ["${NEW_INDEX}"].
Step 11. Verify through the alias. Within 5 minutes:
- The probe shows no non-2xx responses across the switch.
- A query through the alias that selects a field that only exists in
${NEW_INDEX}(or a known sentinel document) returns it. - The golden queries through the alias match the step 8 results.
- Application dashboards show normal answer rates and no rise in "no results" or refusal rates.
Record the switch time in the ticket. Stop the probe.
Verify: all four checks pass. If any fails, roll back now (section 8).
Stage 5: Watch and decommission (Path B)¶
Step 12. Watch window (24-72 hours). Keep ${OLD_INDEX} and, if capacity allows, its indexer running, so a rollback serves fresh data. Watch the alerts in section 7.
Verify: no rollback trigger in section 8 fired during the window, and the sign-off owner records the sign-off in the ticket.
Irreversible: deletes the old index and its data
Deleting ${OLD_INDEX} permanently removes its documents and vectors, and ends the quick rollback. Only the senior who signed off may approve it. After this point, rollback means a rebuild from defs/before (section 8).
Step 13. Decommission after sign-off.
- Set
"disabled": trueon${OLD_INDEXER}. - Confirm no alias points at
${OLD_INDEX}:GET /aliases. - Take a final export of the old definitions (step 1) for the record.
- Delete the old indexer, the old skillset (if separate) and
${OLD_INDEX}. - If you added a partition only for the rebuild, scale back down and confirm the billing change.
Verify: servicestats shows storage and vector usage back to the planned level.
7. Monitoring and alerts¶
Thresholds are Orion starting defaults. Microsoft leaves thresholds to you, so tune them against your own baseline from step 4.
| Signal | Source | Alert when | Action |
|---|---|---|---|
ThrottledSearchQueriesPercentage |
Platform metric (average) | Above 1% for 5 minutes during the build, or above the 7-day baseline | Disable the new indexer's schedule. Resume off-peak or add replicas. |
SearchLatency |
Platform metric (average, seconds) | Above 1.5 times the baseline for 10 minutes | Check indexing load first. If it persists after the switch, roll back. |
DocumentsProcessedCount with dimension Failed = true |
Platform metric (total), filter by IndexerName |
More than 0 in 15 minutes for the new indexer | Read lastResult.errors from the status API. Fix and rerun. |
| Indexer status | Status API polled by a pipeline or Logic App | status = error, or lastResult.status = transientFailure twice in a row |
Page the on-call engineer. See known issues. |
IndexVectorUsage and IndexStorageUsage |
Platform metrics (maximum, bytes), by IndexName |
Total above 80% of quota | Stop indexing. Add a partition or compress vectors before you continue. The vector quota is a hard limit. |
| Non-2xx query responses via the alias | Resource logs (AzureDiagnostics) |
Any rise after the switch | Roll back (section 8). |
| Remaining cumulative indexer runtime (S3 HD and Serverless only) | indexersRuntime in servicestats (preview, 2026-08-01-preview), polled by a pipeline |
Less than the time the build still needs in the current 24-hour UTC window | Pause other indexers on the service, or plan the rest of the build for the next window. |
Create a metric alert with the Azure CLI:
SEARCH_ID="$(az search service show --name "${SEARCH_SERVICE}" --resource-group "${RG}" --query id --output tsv)"
az monitor metrics alert create \
--name "search-throttling-during-reindex" \
--resource-group "${RG}" \
--scopes "${SEARCH_ID}" \
--condition "avg ThrottledSearchQueriesPercentage > 1" \
--window-size 5m \
--evaluation-frequency 1m \
--severity 2 \
--action "${ACTION_GROUP_ID}" \
--description "Queries are being throttled while the index is rebuilt"
Resource logs must be sent to a Log Analytics workspace through a diagnostic setting (category OperationLogs). They land in AzureDiagnostics. Column names follow the Microsoft examples. Check them in your workspace before you rely on them.
Latency and throttling trend from platform metrics:
AzureMetrics
| where ResourceProvider == "MICROSOFT.SEARCH"
| where MetricName in ("SearchLatency", "ThrottledSearchQueriesPercentage", "SearchQueriesPerSecond")
| summarize avg(Average), max(Maximum) by MetricName, bin(TimeGenerated, 5m)
| order by TimeGenerated asc
Query results by status code around the switch:
AzureDiagnostics
| where ResourceProvider == "MICROSOFT.SEARCH" and OperationName == "Query.Search"
| where TimeGenerated > ago(2h)
| summarize queries = count(), p95_ms = percentile(DurationMs, 95) by bin(TimeGenerated, 1m), tostring(resultSignature_d), IndexName_s
| order by TimeGenerated asc
Push-indexing failures (for push writers in step 7):
AzureDiagnostics
| where ResourceProvider == "MICROSOFT.SEARCH" and OperationName == "Indexing.Index"
| where TimeGenerated > ago(24h)
| summarize calls = count(), docs = sum(Documents_d) by IndexName_s, tostring(resultSignature_d), bin(TimeGenerated, 15m)
| order by TimeGenerated desc
Check in the sandbox whether IndexName_s records the alias name or the index name for queries sent through an alias. Write the answer in the validation record.
8. Rollback¶
| Situation | Rollback | Time |
|---|---|---|
| Path B, after the switch | Run NEW_INDEX="${OLD_INDEX}" python snippets/ops/reindex/switch_alias.py. It waits 15 seconds and confirms the alias. Verify with the probe. |
Under 5 minutes |
| Path B, before the switch | Nothing to roll back for users. Disable the new indexer, then delete the new index, indexer and skillset when convenient. | Minutes |
| Path B, after decommission | No quick rollback. Rebuild the old version from defs/before using this runbook. This is why the watch window exists. |
Hours to days |
| Path A, in progress or done | A reset cannot be undone. Restore the previous indexer and skillset definitions from Git, then reset and run again. Results stay mixed until it finishes. | Same as the original run |
| In-place schema change gone wrong | Fields cannot be removed in place. Use Path B to build a corrected index. | Hours |
Rollback trigger (any one): non-2xx query rate above baseline, a golden-query regression that the business owner rejects, a p95 latency rise of more than 50% for 15 minutes, or a missing-content report confirmed by a lookup.
9. Known issues and fixes¶
| Symptom | Cause | Fix |
|---|---|---|
| Alias create or update is rejected | More than one index in the indexes array |
Put exactly one index name in the array. |
| Deleting the old index returns 400 Bad Request | An alias still maps to it | Repoint or delete the alias first. Wait at least 10 seconds after the alias update. |
| Indexer creation fails or writes to the wrong place | targetIndexName set to the alias |
Indexers and index projections must name the concrete index. |
| Indexer stops after about 2 hours with work left | Indexers in the multitenant environment run with a 2-hour maximum | Keep a 5-minute schedule and change detection on. Each run resumes from the high-water mark. |
| First run shows 0 documents processed for hours on a large blob container | The indexer is still listing the blobs | Wait unless the status shows an error. To go faster, split the container into folders with parallel indexers into the same index. |
| Indexers on an S3 HD or Serverless service stop starting runs | All indexers share 24 hours of cumulative runtime per service in each 24-hour UTC window | Check indexersRuntime in servicestats (preview). Plan the build across windows. |
| Commands return 401 or 403 with a valid Entra ID token | Role-based access is not enabled on the service, or the role assignment has not taken effect yet | Enable Role-based access control or Both under Settings > Keys. Role assignments can take 5-10 minutes to apply. |
| "Another indexer invocation is currently in progress" | Indexers are single-instance | Wait for the current run. Do not call Run in a loop. |
| Chunks of deleted source files still appear | The parent was deleted with no deletion policy; index projections only remove chunks of deleted parents when a dataDeletionDetectionPolicy detects the deletion |
Use Path B with the deletion policy set from the new indexer's first run, or delete the chunks with the Documents API. |
| Deleted source files still searchable | No deletion policy at the first run, or a one-to-many parsing mode, which soft-delete policies do not cover | Build a new index with a new indexer and the deletion policy set from the first run. For one-to-many parsing modes, delete the documents with the Documents API. |
| Chunk keys change after a source document changes | Index projections give the remaining chunks a new hash in their key when the parent changes | Expected. Do not store chunk keys outside the index; look chunks up by the parent key field. |
| Restored soft-deleted blob is not reindexed | LastModified did not change on restore |
Re-save the blob's metadata to update LastModified, then run. |
| Vectors vanish after a partial update | Vector field has stored: false and the update omitted it |
Always send the vector in partial updates for such fields. |
| 207 or 503 responses on push, or throttled queries during the build | Service under indexing load | Retry with exponential back-off, use smaller batches (up to 1,000 documents or about 16 MB), index off-peak, or add replicas. |
| "The cognitive service for skill ... has been throttled", or a 429 from a skill or vectorizer | Quota of the Azure OpenAI or Foundry resource the skill calls, not the search service | Lower batchSize, raise the quota of the Foundry or Azure OpenAI resource, then run again. |
| Indexing fails once vectors reach the limit | Vector index quota is a hard limit per partition | Add partitions, use quantisation or narrower types, or free space. Plan this in step 2. |
| 422 "index is temporarily unavailable ... allowIndexDowntime" | Someone updated the index with allowIndexDowntime=true |
Retry after a short wait. Use Path B for analyser additions in production. |
| Documents missing although the indexer reports success | Change-tracking high-water mark is wrong (for example a future date), or a field mapping changed the document shape | Compare initialTrackingState and finalTrackingState in the status response. Check mappings. Look up the key directly. |
| Processed counts and skill bills higher than the number of changed documents | Indexers use a 30-second look-back buffer, so rapid on-demand runs reprocess documents | Space on-demand runs at least 5 minutes apart, or rely on the schedule. |
| Document count lower than expected right after indexing | Statistics are collected periodically, and deletes are physically removed by a background process | Wait a few minutes, then use $count queries and lookups rather than portal statistics. |
10. Cost and capacity notes¶
- Second copy of the data. Path B needs storage and vector quota for two indexes until decommission. If that forces an extra partition, you pay for the extra search units for the build and watch window. Check the current price before approval.
- Re-enrichment is re-billed. A full run calls every skill again: Document Intelligence or Content Understanding extraction, and embedding calls to Azure OpenAI for every chunk. Estimate it as corpus tokens multiplied by the embedding price, plus extraction pages multiplied by the extraction price. The Document Layout skill is billable above 20 documents per indexer per day. The Content Understanding skill has no free allotment.
- Enrichment cache (preview) can avoid repeat skill calls for unchanged documents on Path A. It needs a general-purpose v2 storage account, and the search service identity needs Storage Blob Data Contributor and Storage Table Data Contributor on it. It is not a backup, and Microsoft does not recommend preview features for production. For large data sources with long-running skills or repeated interruptions, a cache can increase total reprocessing. Do not expect a new indexer on Path B to reuse the old indexer's cache.
- Throughput. One indexer job runs per search unit. More partitions or replicas raise parallelism for multiple indexers, not for one indexer. Splitting the source into folders with parallel indexers into the same index is the documented way to go faster.
- Semantic ranker is billed per query when
queryType=semantichas a non-empty search string. Golden-query runs count. - Time. Measure documents per hour in the sandbox and scale linearly as a first estimate. Do not quote durations to the client from this runbook.
11. AWS and open-source equivalents¶
| Platform | Equivalent | Key differences |
|---|---|---|
| Amazon OpenSearch Service and OpenSearch | Index aliases. POST _aliases with remove and add actions in one request. Creating and updating aliases is atomic. _reindex copies documents between indexes. |
Mapping changes to existing fields also need a new index. If embeddings change, re-ingest from source rather than _reindex, because _reindex copies the old vectors. An alias can point to several indexes, so be explicit. |
| Amazon Bedrock Knowledge Bases | Data source sync (StartIngestionJob) is incremental: it processes added, modified and deleted files since the last sync. |
There is no alias. The embedding model is configured when the knowledge base is created. For a model or chunking change, plan a new knowledge base and switch the application configuration, and check the current docs. |
| PostgreSQL with pgvector | Build a new table or column, then CREATE INDEX CONCURRENTLY to avoid blocking writes. Switch with a view or a rename in one transaction. |
You manage the switch yourself. Watch pg_stat_progress_create_index. HNSW on the vector type supports up to 2,000 dimensions. Size maintenance_work_mem to fit the graph without exhausting server memory. |
OpenSearch switch:
{
"actions": [
{ "remove": { "index": "kb-v1", "alias": "kb" } },
{ "add": { "index": "kb-v2", "alias": "kb" } }
]
}
Send it as POST _aliases.
pgvector switch:
CREATE TABLE kb_chunks_v2 (LIKE kb_chunks_v1 INCLUDING DEFAULTS);
-- load and embed into kb_chunks_v2, then:
CREATE INDEX CONCURRENTLY kb_chunks_v2_embedding_hnsw
ON kb_chunks_v2 USING hnsw (embedding vector_cosine_ops);
BEGIN;
CREATE OR REPLACE VIEW kb_chunks AS SELECT * FROM kb_chunks_v2;
COMMIT;
CREATE OR REPLACE VIEW only works if the new table has the same columns in the same order. If the columns change, drop and recreate the view in the same transaction.
12. Checklist¶
Pre-flight¶
- Change ticket approved, with cut-over window, rollback owner and sign-off owner named.
- Path chosen using section 5 and written in the ticket with the reason.
- Current definitions exported and committed (step 1).
- New index, skillset, indexer and data source JSON reviewed in a pull request.
- Capacity check passed with at least 20% headroom (step 2).
- Application queries the alias (step 3), confirmed in logs.
- Baseline golden-query results and latency recorded (step 4).
- Deletion policy present on the data source before the new indexer's first run, if needed.
- Push writers dual-write or have a replay plan (step 7).
- Alerts in section 7 created and tested with a test notification.
- Sandbox dry-run done, with timings in the validation record.
- Preview features (reset docs, reset skills, resync, enrichment cache) noted in the ticket if used.
Post-cut-over¶
- Alias shows exactly
[${NEW_INDEX}]. - Probe showed no non-2xx responses across the switch.
- Sentinel or new-field query through the alias succeeded.
- Golden queries through the alias match the pre-switch run.
- Latency and throttling within thresholds for the first hour.
- Old index and indexer kept for the watch window.
- Watch window ended without a rollback trigger, and sign-off recorded.
- Old objects deleted, temporary partitions removed, cost change confirmed.
- Runbook updated with anything that surprised you.
13. Validation record¶
Pending. Filled in by the dry-runner (see docs/validation-protocol.md).
Sandbox scenario:
- A Standard-tier sandbox service with a blob container of at least 2,000 mixed documents (PDF, DOCX, HTML), an indexer, a skillset with embeddings, and alias
kbpointing atkb-v1. - A load script sends 2 queries per second through the alias for the whole exercise.
- Change requested: switch a
filterableattribute on an existing field, change chunk size, and add a native blob soft delete policy. All three force Path B. - Delete 20 blobs during the build. Restore one soft-deleted blob.
- Run steps 1-13, including a deliberate rollback after step 11 and a second switch forward.
Probe used in steps 9-11:
"""Query an alias once a second and log status and timing."""
import datetime as dt
import os
import time
import requests
from azure.identity import DefaultAzureCredential
endpoint = os.environ["SEARCH_ENDPOINT"]
alias = os.environ["ALIAS"]
url = f"{endpoint}/indexes/{alias}/docs/search?api-version=2026-04-01"
credential = DefaultAzureCredential()
while True:
token = credential.get_token("https://search.azure.com/.default").token
started = time.perf_counter()
response = requests.post(
url,
json={"search": "*", "top": 1, "count": True},
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
elapsed_ms = (time.perf_counter() - started) * 1000
count = response.json().get("@odata.count") if response.ok else None
print(f"{dt.datetime.now(dt.timezone.utc).isoformat()} {response.status_code} {elapsed_ms:.0f}ms count={count}")
time.sleep(1)
Pass criteria:
- Zero non-2xx responses from the probe across both switches and the rollback.
- After the switch, the 20 deleted documents are absent from
kb-v2, and the restored blob is present after its metadata is re-saved. - No orphaned chunks: the count of chunk documents matches the expected count for the new chunk size within 1%.
- Rollback completes in under 5 minutes from decision to verified.
- Every command in this runbook ran unchanged, or the runbook was corrected in the same pull request.
Record¶
| Item | Detail |
|---|---|
| Playbook | PB-RAG-09-01 at commit [hash] |
| Dry-run by | [Name] (must not be author or reviewer) |
| Date | YYYY-MM-DD |
| Sandbox | [Resource group or tag, tier and region] |
| Scenario | [Scenario followed] |
| Time taken | [Hands-on time] hands-on, [waiting time] waiting |
| Result | [PASS / PASS WITH NOTES / FAIL] |
| Notes | 1. [Step number: defect or explanation; blocking/non-blocking; fixed/logged] |
| Signed off by | [Reviewer name and date] |
| Corpus size | [Documents, chunks, GB] |
| API versions used | [API versions used during dry-run] |
| Path tested | B (plus A for reset docs) |
| Build timings | [Build duration and documents per hour] |
| Switch and rollback timings | [Switch and rollback timings] |
| Probe result | [Non-2xx count across switch and rollback] |
IndexName_s shows alias or index? |
[Answer from section 7: records alias or index name] |
14. Sources¶
Checked on 2 November 2026.
- Microsoft Learn, Update or rebuild an index in Azure AI Search: changes that need a rebuild, alias tip, update actions, vector
storedcaveat. - Microsoft Learn, Create an index alias in Azure AI Search: alias REST calls, one index per alias, restrictions, 10-second propagation.
- Microsoft Learn, What's new in Azure AI Search:
2026-04-01stable API, index aliases GA (April 2026), latest preview2026-08-01-preview. - Microsoft Learn, Run or reset indexers, skills, or documents: reset behaviour, execution environments and run limits, service-level indexer permissions, reset docs, reset skills and resync (preview,
2026-08-01-preview), runtime quota on S3 HD and Serverless. - Microsoft Learn, Service limits for Azure AI Search: alias limits and no aliases on Serverless Developer, indexer run time (footnote 5: plan for 2 hours), cumulative runtime on S3 HD and Serverless, vector and storage quotas, batch limits, 429s from Azure OpenAI quota.
- Microsoft Learn, Index large data sets: 2-hour window, batch sizes, scheduling to resume, blob listing time, parallel indexers.
- Microsoft Learn, Define index projections: parent key field rules, no field mapping for the parent key, chunk updates and deletions.
- Microsoft Learn, Enable or disable role-based access control: enabling roles on the service, role assignment delay.
- Microsoft Learn, Document Layout skill and Azure Content Understanding skill: 20 free documents per indexer per day for Document Layout, no free allotment for Content Understanding.
- Microsoft Learn, Indexers - Run:
202 Accepted. - Microsoft Learn, Indexer troubleshooting guidance: tracking state checks, 30-second look-back, spacing on-demand runs.
- Microsoft Learn, Configure an enrichment cache (preview): storage and role requirements, not a backup, large-source caution.
- Microsoft Learn, Change and delete detection for Azure Storage: deletion policy from first run, native soft delete requirements, restored blobs, no soft delete for one-to-many parsing.
- Microsoft Learn, Data Sources - Create Or Update:
NativeBlobSoftDeleteDeletionDetectionPolicyin2026-04-01. - Microsoft Learn, Indexes - Create Or Update:
allowIndexDowntime. - Microsoft Learn, Indexers - Get Status: status fields and values.
- Microsoft Learn, Azure AI Search monitoring data reference and Monitor query requests: metric names, log columns, KQL patterns.
- Microsoft Learn, SearchIndexClient (Python): alias methods.
- Microsoft Learn, Connect to a search service using roles and az monitor metrics alert.
- OpenSearch, Index aliases and Reindex API.
- pgvector, README.
- AWS, Sync a data source with an Amazon Bedrock knowledge base.
Related playbooks¶
- PB-RAG-09-02: upgrade the embedding model first, then use this runbook for each index.
- PB-RAG-08-02: build the golden query set and relevance labels used in steps 4 and 8.