Skip to content

Convert REST JSON to QQL

Existing Qdrant applications speak REST JSON. QQL can read that JSON and produce the equivalent QQL statements, so a migration or an onboarding step does not start with a rewrite by hand.

Two commands do the work:

  • qql record captures live traffic from any SDK, dashboard, or script by proxying it — the application keeps sending the same requests.
  • qql convert turns one captured request, or a bare Qdrant REST body, into canonical QQL.

qql record is a transparent proxy. Point an application at its listen address instead of Qdrant and change nothing else; every request is forwarded to the real server unchanged, and each bodied /collections/ request is appended to a JSONL capture.

qql record is opt-in because it pulls in the proxy runtime:

Terminal window
cargo install qql-cli --locked --features record
# ... or from a local checkout:
# cargo build --release -p qql-cli --features record
Record once, convert after
# Qdrant stays where it is (default :6333); the app moves to :6334.
qql record --listen 127.0.0.1:6334 --target http://127.0.0.1:6333 \
--out capture.jsonl --qql-out capture.qql
# Then, with no recorder running:
qql convert --collection docs capture.jsonl

With --qql-out, each captured request is converted as it arrives, so capture.qql is ready when the recording stops. Requests that cannot be converted are written as # ERROR annotations and never interrupt forwarding.

Defaults are chosen so a bare qql record works with a local Qdrant: Qdrant keeps serving on 127.0.0.1:6333, and the recorder listens on 127.0.0.1:6334 so the application only needs its base URL changed:

Terminal window
qql record

Query strings are forwarded and captured as a "query" object so WAIT, timeout, and consistency survive conversion. Bodies are buffered in memory per request, which is fine for interactive captures and large batched upserts, but this is a development tool, not a production proxy. Bodyless collection and quota routes (SHOW, DROP COLLECTION, DROP INDEX) are recorded too.

qql convert reads a wrapped request, or a bare Qdrant body, from a file or stdin and prints canonical QQL.

Convert a search request
qql convert search.json cat search.json | qql convert

A wrapped request carries the endpoint, so the collection is derived from the path:

{
"method": "POST",
"path": "/collections/docs/points/query",
"body": {
"query": { "nearest": [0.1, 0.2] },
"using": "dense",
"limit": 5,
"filter": { "must": [{ "key": "status", "match": { "value": "active" } }] }
}
}
QQLConverted nearest queryTry in playground
QUERY [0.1, 0.2]
FROM docs
USING dense
WHERE status = 'active'
LIMIT 5;

A bare body has no path to derive the collection from. Pass it explicitly with --collection:

Bare body with an explicit collection
echo '{"ids": [1, "point-2"]}' | qql convert --collection docs
# QUERY POINTS (1, 'point-2') FROM docs;

Without --collection, a bare body fails with MissingCollection rather than inventing a collection name.

Conversion is part of the same contract as execution: Qdrant's OpenAPI schema describes each request, QQL's typed AST is the shared request model, and QQL's own formatter renders the output. The converter therefore emits the same statements the runtime plans, including every clause QQL supports.

RequestQQL
POST .../points/query, .../query/groupsQUERY (including formula, fusion, prefetch, and grouped forms)
POST .../points/scroll, .../points/count, .../facetSCROLL, COUNT, FACET
POST .../points, .../points/deleteQUERY POINTS, DELETE
POST .../points/payload, payload/clear, payload/deleteUPDATE … SET PAYLOAD, CLEAR PAYLOAD, DELETE PAYLOAD
PUT .../points/vectors, POST .../points/vectors/deleteUPDATE … SET VECTOR, DELETE VECTOR
PUT .../pointsUPSERT
`PUTPATCH
PUT .../index, DELETE .../index/{field}CREATE INDEX, DROP INDEX
shard keys, quotas, SHOW metadataCREATE / DROP SHARD KEY, SET QUOTA, SHOW …

Filter conversion is structural: mustAND, shouldOR, must_notNOT, and match, range, geo_bounding_box, geo_radius, geo_polygon, has_id, is_empty, and is_null lower onto their typed QQL predicates.

  • One emitter. The converter decodes JSON into the typed AST and prints it with qql fmt's formatter. It never builds QQL by string concatenation, so canonical output always re-parses.
  • Contract tests. The covered routes are asserted against crates/qql-runtime/openapi.json at test time, and every converted statement is re-planned and compared against the REST route the runtime would have produced from the original QQL.
  • Typed failures. Inputs QQL cannot represent fail with a specific error and a field path (UnsupportedEndpoint, UndecodableBody, InvalidField, InvalidJson) instead of dropping a clause or emitting placeholder text.
  1. Capture the requests the application actually sends (qql record --out capture.jsonl).
  2. Convert them (qql convert --collection <name> capture.jsonl) and review the file; it is plain QQL.
  3. Lint the converted script offline (qql lint capture.qql).
  4. Triage representative statements if needed (qql doctor "<statement>").
  5. Move data with Cluster migration when the target is a new cluster, or replay the script with qql run capture.qql.

Related references: CLI, QQL vs raw Qdrant JSON API.