Symptoms
- "Upload a file and ask about it" works most of the time, but sometimes the first question returns nothing relevant.
- Integration tests that upsert and immediately query are flaky.
describe_index_stats()shows fewer records than were just written, then catches up.fetch(ids=[...])finds a record thatquerydoesn't return yet.
Why it happens
Pinecone serverless indexes are eventually consistent. An upsert returns success once the write is accepted durably, but the record becomes visible to queries a little later, after it has been indexed. Under normal conditions that delay is short (usually seconds), but it isn't zero and can grow during heavy write load. Stats counts are also computed asynchronously.
This isn't a bug to fix in Pinecone; it's a property to design for.
Step 1: separate lag from failure
Before blaming freshness, make sure the write actually happened:
| Check | How | If it fails |
|---|---|---|
| Upsert call succeeded | Log the response; exceptions caught? | Fix error handling; see 429/size errors |
| Record exists | index.fetch(ids=["doc42#0"], namespace=ns) | Wrong namespace, wrong ID, or write never sent |
| Same namespace for write and read | Log the namespace on both paths | Fix routing; check for default "" namespace |
| Same index host | Log host on both paths | Writer and reader point at different indexes (e.g. staging vs prod) |
| Record matches the filter | Check metadata in the fetch result | Filter excludes it (type mismatch, case) |
| Record appears after 30–60 s | Query again later | If never, it's not freshness |
Many "freshness" incidents turn out to be a namespace mismatch or a filter that excludes the new record, for example year stored as the string "2026" while the filter uses a number.
Step 2: measure the actual lag
Run a small probe that writes a marker record and polls until a query returns it:
import time, uuid
def freshness_probe(index, ns, vec):
rid = f"probe-{uuid.uuid4()}"
index.upsert(vectors=[{"id": rid, "values": vec, "metadata": {"probe": True}}], namespace=ns)
start = time.time()
while time.time() - start < 120:
res = index.query(vector=vec, top_k=1, namespace=ns, filter={"probe": {"$eq": True}})
if res.matches and res.matches[0].id == rid:
lag = time.time() - start
index.delete(ids=[rid], namespace=ns)
return lag
time.sleep(0.5)
return None
Run it every few minutes into a dedicated probe namespace and chart the result. It gives you a real number to put in the design, and an alert when lag grows during bulk loads.
Step 3: design the user experience
| Pattern | How |
|---|---|
| Show ingestion status | "Processing… ready in a few seconds"; enable "ask about this file" when ready |
| Wait for visibility before marking ready | After upsert, poll with fetch + a filtered query on the new doc_id until it returns, with a timeout |
| Answer from the uploaded file directly | For the first question on a just-uploaded file, pass its chunks straight to the LLM without retrieval |
| Read-your-writes cache | Keep the last N uploaded chunks per session in the app and merge them into retrieval results |
The direct-context pattern is simple and reliable: the user just uploaded the file, you already have its text, and you don't need search to find it.
Step 4: fix flaky tests
Don't add sleep(5) and hope. Poll with a deadline:
def wait_until_queryable(index, ns, doc_id, vec, timeout=60):
deadline = time.time() + timeout
while time.time() < deadline:
res = index.query(vector=vec, top_k=1, namespace=ns, filter={"doc_id": {"$eq": doc_id}})
if res.matches:
return
time.sleep(0.5)
raise TimeoutError(f"{doc_id} not queryable after {timeout}s")
Also give each test run its own namespace (test-{run_id}) and delete it at the end, so runs don't see each other's data.
Step 5: updates and deletes too
The same delay applies to:
- Updates: a query may briefly return the old metadata, which matters for permission changes. If a user loses access to a document, enforce the change in your application (check permissions on returned
doc_ids against your source of truth) instead of relying only on the metadata filter. - Deletes: a deleted document can show up in results for a short time. Post-filter results against a "deleted documents" set in the application during that window if that's unacceptable.
Important
For security-relevant changes (access revoked, legal deletion), never rely on vector index freshness alone. Check returned documents against the authoritative permission store before showing them.
Step 6: watch stats correctly
describe_index_stats() counts trail writes. Don't use them as an exact "ingestion finished" signal right after a load. Use your loader's own count of successful upserts, then compare with stats after a pause, and spot-check with fetch.
Decision table
| Finding | Action |
|---|---|
| Record never appears | Not freshness: check namespace, host, ID, filter, errors |
| Appears within seconds | Design UX/test around it |
| Lag grows to minutes during bulk loads | Throttle loader, load into a new index, or schedule loads off-peak |
| Permission change visible late | Enforce permissions in the application too |
Prevent it next time
- Treat "written" and "searchable" as two states in your ingestion pipeline.
- Run a freshness probe and alert on lag.
- Use poll-with-deadline in tests, never fixed sleeps.
- Check permissions on results against the source of truth.