Re-indexing when the source changes, without leaving stale chunks
Deterministic ids handle updates; deletions need a record of what you wrote. The three cases a naive re-index gets wrong, and the id scheme that fixes all of them.
A document changes and you re-index it. POST /v1/vector/upsert on Infrai overwrites any vector whose id already exists, so an edit that produces the same number of chunks is handled for free. The problems start when it produces fewer chunks, or when the document is deleted outright — and DELETE /v1/vector/delete takes explicit ids, not a filter, so you need to know what you wrote.
Three cases, one id scheme that handles all of them.
Deterministic ids make updates free
curl -sS -X POST "https://api.infrai.cc/v1/vector/upsert" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"collection": "docs",
"vectors": [
{"id": "https://docs.example.com/gateway#0", "embedding": [0.0123, -0.0456, 0.0789],
"metadata": {"url": "https://docs.example.com/gateway", "chunk": 0,
"text": "An API gateway routes requests to services."}},
{"id": "https://docs.example.com/gateway#1", "embedding": [0.0211, -0.0388, 0.0654],
"metadata": {"url": "https://docs.example.com/gateway", "chunk": 1,
"text": "It can also apply rate limits and authentication."}}
]
}'
{
"ok": true,
"data": { "ok": true, "id": "docs" }
}
<source>#<chunk index> as the id means chunk 0 of that page is always the same vector, so re-indexing overwrites in place. No duplicates, no drift, no “why does search return three versions of this paragraph”.
Random ids are the opposite.
A UUID per chunk produces a brand-new vector on every run, so a nightly re-index of a thousand documents multiplies your index by the number of nights it has run — and because every copy is a legitimate vector with a legitimate score, retrieval keeps working while quietly returning the same passage three, then thirty, then three hundred times over.
The three cases a naive re-index gets wrong
| Change | With deterministic ids | What’s still needed |
|---|---|---|
| Edit, same chunk count | overwritten correctly | nothing |
| Edit, fewer chunks now | new chunks overwritten, old tail orphaned | delete the surplus ids |
| Document deleted | nothing happens | delete every id for that source |
| Chunker changed (different boundaries) | ids shift meaning | delete all, re-index |
Row two is the subtle one. A page that used to produce eight chunks and now produces five leaves chunks 5, 6 and 7 in the index, still matching queries, still citing a passage that no longer exists in the source. Nobody notices until an answer quotes text a reader can’t find on the page.
Track the count, delete the tail
import os
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
COLLECTION = os.environ.get("RAG_COLLECTION", "docs")
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
# source url -> how many chunks we last wrote. In production this is a table, not a
# dict: without it you cannot know which ids to delete, because delete takes ids
# rather than a filter.
WRITTEN: dict[str, int] = {}
def upsert(source: str, embeddings: list[list[float]], texts: list[str]) -> int:
vectors = [
{"id": f"{source}#{index}", "embedding": embedding,
"metadata": {"url": source, "chunk": index, "text": text}}
for index, (embedding, text) in enumerate(zip(embeddings, texts))
]
resp = SESSION.post(f"{API}/v1/vector/upsert",
json={"collection": COLLECTION, "vectors": vectors}, timeout=180)
resp.raise_for_status()
return len(vectors)
def delete_ids(ids: list[str]) -> bool:
if not ids:
return True
resp = SESSION.delete(f"{API}/v1/vector/delete",
json={"collection": COLLECTION, "ids": ids}, timeout=60)
return bool(resp.ok)
def reindex(source: str, embeddings: list[list[float]], texts: list[str]) -> dict:
"""Write the new chunks, then remove the tail the document no longer has. Skip
the second half and search keeps returning passages that were deleted from the
page months ago — which reads as the model inventing text."""
previous = WRITTEN.get(source, 0)
written = upsert(source, embeddings, texts)
orphans = [f"{source}#{index}" for index in range(written, previous)]
delete_ids(orphans)
WRITTEN[source] = written
return {"source": source, "written": written, "orphans_removed": len(orphans)}
def remove_source(source: str) -> dict:
"""A deleted document needs every one of its chunks gone. The recorded count is
the only thing that tells you how many there were."""
previous = WRITTEN.pop(source, 0)
ids = [f"{source}#{index}" for index in range(previous)]
delete_ids(ids)
return {"source": source, "removed": len(ids)}
if __name__ == "__main__":
print(reindex("https://docs.example.com/gateway",
[[0.01, -0.02, 0.03], [0.02, -0.03, 0.04]],
["An API gateway routes requests.", "It applies rate limits."]))
print(remove_source("https://docs.example.com/retired-page"))
The WRITTEN table is the part that isn’t optional. Delete needs ids, so the only way to remove a document’s chunks later is to know how many you wrote — and reconstructing that from the index isn’t possible without a filtered delete, which isn’t available.
Re-index only what changed
Re-embedding a corpus nightly is a bill for work that mostly produced identical vectors. Keep a content hash per source and skip the sources whose hash hasn’t moved.
That single check is usually a 90%+ reduction on a documentation corpus, where a handful of pages change per week. Schedule it with POST /v1/cron/create and record the run with POST /v1/logs/ingest so you can see which sources actually moved.
When the chunker changes, start over
Changing chunk size or boundary logic invalidates the id scheme: #3 now means a different passage. Overwriting in place leaves an index where some vectors came from the old chunker and some from the new, with ids that no longer correspond to anything consistent.
The clean move is a new collection. Index into docs_v2 with the new chunker, switch reads over when it’s complete, then DELETE /v1/vector/collection/delete on the old one — which also stops paying rent for it. A rebuild in place, with the reads still pointing at a half-rebuilt index, is the version that produces a bad week of answers.
Limitations
Delete takes ids only, so there’s no “remove everything from this source” or “remove everything older than August” — the id bookkeeping above exists because of that, and it’s real work the API doesn’t do for you. There’s also no soft delete or versioning: a removed vector is gone, and an index has one current state.
Qdrant and Weaviate both support filtered deletion, which removes the need for the tracking table entirely, and Pinecone’s namespaces make a full rebuild cheaper to isolate. If continuous re-indexing of a large, churning corpus is your core workload, those are fair reasons to look at them.
What one credential gives you is the rest of the loop: the scrape that fetched the source with POST /v1/web/scrape, the embeddings, the index, the schedule and the log line are one key and one GET /v1/account/usage. Upsert and query bill per call at rates live in GET /v1/discovery/vector.upsert (verified 2026-09-21), collections accrue standing rent while they exist, and platform rates drift downward as vendor contracts improve.