Why a 0.86 ms Qdrant Query Timed Out at 60 Seconds in Our Script
A Qdrant filter timeout at 60 seconds, yet one query took 0.86 ms. How an integer match on a keyword payload index forced a full scan, and the simple fix.
TL;DR
- A slow request doesn't always mean a busy server. Time one request on its own before you blame load.
- An index only helps when your question matches what the index stores. Ask a text index for a number and the database can't use it.
- In an "either this or that" search, one half that can't use the index can make the whole search read everything.
- Check how a field is really indexed by asking the database, not by trusting memory or a guess.
- A script that prints "updated" for every document tells you nothing. Log what actually changed.
A one-off script kept timing out. Every lookup waited the full 60 seconds and gave up. Yet when I timed one lookup on the same field by hand, the server answered in 0.86 ms.
You've probably met this at a library desk. Ask for a book by its title and the answer comes back in seconds, because the catalogue is sorted by title. Ask for "this title, or maybe this shelf number" and someone has to walk every aisle, because the catalogue can't answer the second half.
Multibagg AI is an AI-powered stock research platform for Indian investors. Its AI assistant answers questions from documents it finds through vector search, so the search index has to stay in step with the documents behind it.
This is the story of three confident wrong guesses, one timed query, and a filter branch that looked harmless.
The real problem: a backfill that never finished
We store chunks of documents as points in Qdrant, an open-source vector database. Each point has a vector for search plus a payload, a small JSON object of fields. One payload field holds the ID of the document the chunk came from, and that field has a payload index, so filters on it don't need to read every point.
The old IDs were plain numbers stored as text, like "1001", and we needed to rewrite them into a prefixed form across a very large collection. So I wrote a one-off backfill script, a script that rewrites existing records in bulk. For each document, it found the document's points by that ID field, then wrote the new ID onto them. It ran many documents at once.
Every lookup hit the 60-second timeout. Worse, the script printed "updated" for every document whether the write happened or not, so its own log couldn't tell me whether anything had been written.
How did the old script find a document's points?
Here is the lookup, rewritten with generic names. The shape matches the script:
import type { Schemas } from "@qdrant/js-client-rest";
type Filter = Schemas["Filter"];
export const oldLookup = (recordId: string): Filter => ({
should: [
{ key: "record_id", match: { value: recordId } },
{ key: "record_id", match: { value: Number(recordId) } },
],
});
In a Qdrant filter, should means OR: a point matches if any branch matches. So this asked for points whose ID equals the string "1001", or equals the integer 1001. The payload index on that field was a keyword index, the kind built for string values.
flowchart LR
A["Next document"] --> B["Lookup: ID as string OR ID as integer"]
B --> C["Integer branch: no index can answer it"]
C --> D["Check the payload of every point"]
D --> E["60-second timeout"]
B --> F["Write the new ID"]
F --> G["Log updated, whatever happened"]
Three confident diagnoses, and why each was wrong
I debugged this with an AI coding assistant. It gave three explanations, one after another, each stated with the same confidence:
| Diagnosis | What it predicted | What we checked | Verdict |
|---|---|---|---|
| The field has no payload index | Every filter on it is a full scan | I pasted the collection's schema: the field was indexed | Wrong |
| The optimizer is busy (status yellow) | The server is slow for every request | One timed query on the field: 0.86 ms | Ruled out |
| Too many requests at once | Requests queue and time out | The same timed query: one request answered fast | Server load ruled out, not queueing |
A yellow status in Qdrant means the optimizer is still working on the collection in the background. That sounded plausible. But each theory explained the slowness without anyone having timed a single request.
The probe: time one query before you theorise
So I asked for probes instead of more theories. The assistant designed them, I ran them, and I pasted back the results and the collection's schema.
Checking the index takes one call. Qdrant reports every payload index and its type in the collection info:
import { QdrantClient } from "@qdrant/js-client-rest";
export async function indexType(
client: QdrantClient,
collection: string,
field: string,
): Promise<string | undefined> {
const info = await client.getCollection(collection);
return info.payload_schema[field]?.data_type;
}
For our field it said keyword. So "missing index" was wrong.
Then the timing. One filtered scroll on that field, on its own:
import { QdrantClient } from "@qdrant/js-client-rest";
import type { Schemas } from "@qdrant/js-client-rest";
export async function timeOne(
client: QdrantClient,
collection: string,
filter: Schemas["Filter"],
): Promise<number> {
const started = performance.now();
await client.scroll(collection, {
filter,
limit: 1,
with_payload: false,
with_vector: false,
});
return performance.now() - started;
}
It came back in 0.86 ms. A server that answers a filtered request in under a millisecond is not too busy to answer. That ruled out server load, and only that. It didn't say what was wrong. It said where not to look: the problem was in the shape of the script's query, not in the server.
Why did an integer match skip a keyword index?
This is the part the coding assistant spotted, and it's in the integer branch.
Qdrant has a separate index type per kind of value. Its indexing docs list a keyword index for keyword (string) payloads and an integer index for integer payloads. Each one serves match conditions for its own type.
Qdrant's source is open, so you can see what happens on a mismatch. When the keyword index is asked to filter by a match value, it handles a string and returns nothing usable for an integer. So the integer branch has no index to use.
On its own that branch would just be slow. Inside should, it slows down the whole filter. When Qdrant plans a should clause, a comment in its query estimator says it plainly: if some branch is un-indexed, it can't make any assumptions about the whole should clause. With no index plan for the OR, it checks the payload of every point.
flowchart TD
A["Filter on the ID field"] --> B{"Which clause?"}
B -->|"should: string OR integer"| C["Integer branch has no index"]
C --> D["No index plan for the whole OR"]
D --> E["Check every point (full scan)"]
B -->|"must: string only"| F["Keyword index lookup"]
F --> G["Only this document's points"]
On a very large collection, a full scan per document, with many documents in flight, most likely explains why every lookup ran into the 60-second limit. I didn't profile the server during the slow run, so that's the explanation the docs, the source and the fix support, not a measurement.
The fix: one must clause, one type
The fix was small. Keep only the string branch, and use must:
import type { Schemas } from "@qdrant/js-client-rest";
export const lookup = (recordId: string): Schemas["Filter"] => ({
must: [{ key: "record_id", match: { value: recordId } }],
});
With a single must condition that the keyword index can answer, Qdrant pulls the matching points straight from the index. The value type matches the index type, and there's no OR branch left to drag the plan down to a full scan.
The re-run finished in about 2 minutes with 0 failures.
How the work split: I directed the probes and supplied the schema; the coding assistant spotted the integer branch and explained the full scan.
Trade-offs and limitations
The fix assumes the field only holds strings. If any point had stored the ID as a number, the new filter would skip it. If you really need both types, give the field a second index of the integer type, or normalise the values first, instead of OR-ing two types onto one keyword index.
There's no server-side proof. I didn't capture a query plan or a profile of the slow requests. The case for the cause is: the field was indexed, a single filtered query on it was fast, the documented and coded behaviour of should with an unindexed branch, and the re-run finishing once that branch was gone.
The script was a one-off. It was never committed, so nothing in the codebase stops the same filter shape from coming back.
Qdrant can enforce this for you. Its strict mode has an unindexed_filtering_retrieve setting. Set to false, it rejects reads that filter on a payload key with no suitable index, which the docs warn "can be very slow". From the source, an integer match asks for an integer index, so this filter should have been rejected with an error instead of timing out. We didn't turn it on:
import { QdrantClient } from "@qdrant/js-client-rest";
export async function refuseUnindexedFilters(
client: QdrantClient,
collection: string,
): Promise<void> {
await client.updateCollection(collection, {
strict_mode_config: { enabled: true, unindexed_filtering_retrieve: false },
});
}
That's a trade-off too. Strict mode can reject ad-hoc queries that are fine on small collections.
Results and observations
What I measured:
- Before the fix, the script's lookups hit the 60-second timeout.
- One filtered scroll on the same field took 0.86 ms.
- After the fix, the whole re-run took about 2 minutes, with 0 failures.
What I didn't measure: how long the old OR filter took on its own when nothing else was running, or what the server was doing during the slow run. If you're chasing a Qdrant filter timeout and the payload index is clearly there, these are the two numbers worth collecting before you change anything.
What other engineers can learn
Time one query before you theorise. Three server-side explanations came before any measurement. One timed request split "the server is slow" from "this query is slow". I think timing it first would have skipped two of the three wrong turns.
Match the value type to the index type. An index is built for one kind of value. A filter value of a different type doesn't fail, it just can't use the index. So the query still returns, slowly, and nothing tells you why.
Treat an OR as only as fast as its slowest branch. A must clause can lean on its best indexed condition. A should clause can't, because any branch might add matches the index doesn't know about. One defensive "just in case" branch can cost you the whole index.
Ask the database, not your memory. "Is this field indexed?" is a one-call question. So is "with what type?". The answer ended the first theory, and the type in it pointed straight at the second branch.
Log outcomes, not intentions. Our old script logged "updated" for every document, whether the write happened or not. Count what the database reports as matched or changed, and a timeout shows up as a failure in the log instead of a success.
Conclusion
The server was never the problem. It answered in under a millisecond the moment we asked it a question its index could answer. The slow part was one extra branch in a filter, asking a text index for a number, inside an OR that let that branch decide for the whole query.
The lesson I keep: when something is slow, time one request first. Then look at the shape of the question before you look at the machine answering it.
What's the most expensive "just in case" branch you've found in a query?


