Loams v0.1.0 is out: run the whole stack yourself with Docker Compose. See the roadmap

Blog/The protocols Loams speaks

The Qdrant API: REST, gRPC and the universal query

How Qdrant's REST and gRPC APIs are shaped, why Loams serves them unchanged so LangChain and LlamaIndex vector stores work without a code change, how loams-qdrant maps points onto Loams collections, and every place we deliberately differ.

The Qdrant API: REST, gRPC and the universal query
On this page
  1. What the protocol is
  2. Why Loams speaks it
  3. How Loams implements it
  4. How it is tested
  5. What is not supported
  6. Gotchas

Qdrant is an open-source vector database, and its API is what most AI frameworks reach for when they want a vector store. Loams serves that API, REST and gRPC, so a program written against qdrant-client can point at Loams and keep working. Behind it there is no Qdrant server: every request becomes a call on Loams' own collection service, over data that lives in your bucket.

SpecQdrant API reference (REST, OpenAPI); the protobuf files in qdrant/qdrant lib/api/src/grpc/proto (gRPC)
Version servedQdrant 1.19.1, reported by GET / and the gRPC HealthCheck
Clients tested in CIPython qdrant-client 1.15.1 (REST, legacy methods) and 1.19.1 (query API, REST and gRPC)
Crateloams-qdrant (cargo feature qdrant, in the default build)
PortsREST 6333, gRPC 6334, both on 127.0.0.1 by default
StatusAvailable in Loams 0.1.0

What the protocol is

Qdrant has two transports over one data model, and they are close to isomorphic.

REST is JSON over HTTP/1.1. Every answer comes in one envelope: {"result": …, "status": "ok", "time": 0.0012}, where time is the server's handling time in seconds and an error carries {"status": {"error": "…"}} with an HTTP status. The routes are resource paths under /collections/{name}: PUT to create a collection, PUT …/points to upsert, POST …/points/scroll, …/points/count, …/points/delete, the payload routes, and the search routes. The REST body cap is 32 MiB by default (max_request_size_mb).

gRPC is the same model as protobuf services: qdrant.Collections, qdrant.Points, qdrant.Snapshots and qdrant.Qdrant (health), on their own port. The clients use it for throughput, and qdrant-client switches to it with prefer_grpc=True. Messages may be gzip-compressed.

Auth is a static key. A server configured with service.api_key expects it in the api-key header (REST) or metadata key (gRPC), or as Authorization: Bearer <key> (security guide). There is no handshake beyond that and no session: every request stands alone.

Points. A collection holds points. A point has an id (an unsigned 64-bit integer or a UUID), one or more vectors, and a JSON payload. Vectors can be unnamed (one per point) or named ({"text": [...], "image": [...]}), dense with a distance (Cosine, Dot, Euclid, Manhattan), or sparse: a list of indices and values, optionally with modifier: idf, which makes Qdrant weight each dimension by inverse document frequency at query time, the way BM25 does for terms.

Writes and wait. An upsert returns once the operation is accepted. With wait=true it returns when the update is applied; without it the answer is acknowledged and the point may not yet be searchable. ordering (weak, medium, strong) chooses how writes are ordered across replicas, and reads take a consistency parameter that picks how many replicas must agree (points concepts).

The universal query. Since Qdrant 1.10, POST …/points/query replaces the older search, recommend and discover routes with one request shape (hybrid queries). A query can carry prefetch, a list of sub-queries whose results become the candidates of the outer query, nested to any depth. The outer query then either scores those candidates again (rescoring, often with a larger or multi-vector representation) or fuses them with {"fusion": "rrf"} or {"fusion": "dbsf"}. A dense prefetch plus a sparse prefetch fused with RRF is the standard hybrid search recipe, and it is what LangChain's hybrid mode sends.

POST /collections/docs/points/query
{
  "prefetch": [
    { "query": [0.12, 0.08, …], "using": "dense", "limit": 50 },
    { "query": { "indices": [17, 912], "values": [0.6, 0.4] }, "using": "sparse", "limit": 50 }
  ],
  "query": { "fusion": "rrf" },
  "filter": { "must": [{ "key": "lang", "match": { "value": "en" } }] },
  "limit": 10,
  "with_payload": true
}

Filters are a small boolean language over payload keys: must, should, must_not and min_should, with field conditions such as match (value, any, except, text, phrase), range, values_count, is_empty, is_null, has_id, geo conditions and nested. Qdrant can filter any key without an index; a payload index only makes it faster.

Why Loams speaks it

Our own API is the native one, a Connect/gRPC service (see Connect RPC). But the people who would try Loams first already have code: a LangChain or LlamaIndex vector store, a RAG pipeline, a notebook. Asking them to rewrite it is the fastest way to lose them. The Qdrant API is the vector-store contract with the most framework adoption, so we serve it.

We did not try to serve everyone's API. The decision log narrowed the protocol footprint early (D42): Loams speaks its native API, Arrow Flight SQL, the Qdrant REST and gRPC API, and a targeted Elasticsearch subset (part 5). The reason in the log is blunt: emulating five products' protocols is a five-product trap. Qdrant and Elasticsearch carry the AI-framework adoption; the rest did not earn a gateway. D603 then made the promise explicit: compatibility surfaces exist so unmodified third-party clients work, and they are adapters over the same internal service traits. A compatibility surface never reaches storage directly.

The version we serve is fixed by the clients we have to satisfy, not by what is newest (D132). LangChain's lockfile pinned qdrant-client 1.15.1, whose Qdrant class still calls the legacy search route; LlamaIndex runs on 1.19. So the gateway serves Qdrant 1.19.1's surface including the legacy search, recommend and discover routes, their batch and group variants, and the deprecated gRPC methods that the 1.19.1 server still answers.

How Loams implements it

loams-qdrant is one crate with two listeners: an axum REST server and a tonic gRPC server, gzip both ways, inside the same hot-tier layer as the rest of the engine. It calls CollectionService, the same service the native API and the Elasticsearch gateway call, so a collection written through Qdrant can be read through SQL or the native API, and the other way round. How a collection is stored (Lance for vectors and columns, Tantivy splits for text, one manifest for both) is covered in the Lance post and One manifest, two formats; how a query plans and fuses is in the engine post.

Protos and model (D133). Qdrant's protobuf definitions are Apache-2.0, so we vendor nine of them from qdrant/qdrant v1.19.1 and compile them with tonic-prost-build. We did not use Qdrant's Rust client crate for the types: it lags the server's protos and would pull client builders and TLS stacks into a server. The REST types are a hand-written serde model of the subset we serve, because the 1.19 OpenAPI document omits the legacy routes and code generators get its anyOf precedence wrong. gRPC messages convert into the REST model, so each operation has exactly one executor.

Points become documents. A point id becomes the document's primary key, ordered the way Qdrant orders ExtendedPointId (integers before UUIDs). Dense vectors become vector fields; sparse vectors become a Lance column plus postings and weights inside the text splits (D31).

Every filter reads the catch-all payload field. Payload indexes add typed copies that filters never read (D134), so a result never depends on when an index was created.

Filters read one field (D134). Every collection created through the gateway gets a catch-all payload JSON field, and every Qdrant filter is compiled against it, text and datetime conditions included. Payload indexes still exist: creating one adds a typed payload_index.<key> field, used for payload_schema and the hot tier, but filters never read it. The reason is that Qdrant filters any key without an index, and a field added later would not be backfilled: if filters read the typed field, the same query would answer differently depending on when you created the index.

Writes are atomic per request. Each write route is planned into document operations and written with one atomic write call. Writes by filter (delete with a filter, the payload operations, delete_vectors) run as the collection service's native delete_by_filter and patch_by_filter (D87), which evaluate the filter at one pinned version.

The universal query becomes one search. query compiles to one search in Loams' IR: nearest and sparse-nearest retrievers, nested prefetches, RRF or DBSF fusion, rescoring, filters, lookup_from. Sparse vectors are served with modifier: idf and exact scoring (D136, D84). DBSF follows Qdrant's normalization exactly, unclamped (D80). The legacy search, recommend and discover routes are converted into universal queries before they run.

Queries that are not "nearest to a vector" (recommend with best_score or sum_scores, discover, context, MMR) cannot be expressed in the IR, which has no custom-scorer retriever. The gateway scores them itself with Qdrant's formulas over the union of candidate searches, one per example (D135). Groups (query/groups, with_lookup) follow Qdrant's collect-then-fill driver over the compiled query.

Auth. The engine has one authentication layer for every HTTP surface, Qdrant's listeners included. In apikey mode it accepts a Loams API key as Authorization: Bearer, and also in Qdrant's api-key header, so QdrantClient(url=…, api_key="lk_…") works unchanged. In oidc mode it takes a JWT the same way. The release stack, however, serves only the engine's main port on your tailnet; the Qdrant listeners stay inside the compose network, and the release notes say not to publish them in 0.1.0. Locally, loams dev binds them on 127.0.0.1 (D112), which is also where qdrant-client looks by default.

Namespaces. Qdrant has no tenancy above collections. A Loams-Namespace header (or gRPC metadata key) picks the namespace; without it the gateway uses the one it was started with (--qdrant-namespace, default default).

How it is tested

CI runs the official Python qdrant-client against loams dev twice: 1.15.1 over REST with the legacy methods, and 1.19.1 with the query API over REST and gRPC. Each result is checked against a brute-force computation with Qdrant's own formulas, so a test fails on a wrong score, not only on a wrong shape. The design target beyond that (Qdrant's own client suites, the LangChain and LlamaIndex vector-store tests, and recall within 1% of a reference Qdrant on the hot tier) is listed in design §06 §8; we have not published recall numbers.

What is not supported

These answer 501 with Unsupported in Loams: <feature> over REST and UNIMPLEMENTED over gRPC:

  • Vectors: multivectors, a datatype other than float32, inference objects (server-side embedding), adding a sparse vector after creation, deleting a named vector.
  • Queries: order_by, formula, sample, relevance_feedback, weighted RRF, a prefetch score_threshold, a leaf prefetch without a query, MMR inside a prefetch, sparse recommend, discover, context and MMR, sparse rescoring, facet, search/matrix/*.
  • Filters: geo conditions and geo indexes, nested, has_vector and slice conditions, keys with [n] or quoted segments.
  • Writes: update_filter, update_mode other than upsert, payload-index deletion and type changes.
  • Cluster and storage: custom sharding and shard keys, cluster mutations, snapshot download, upload, recover and delete, full-storage snapshots.

Some of these are planned: float16 and uint8 vectors (D94), update_filter on top of conditional writes (D89), and formula through the ranking-expression IR (D91).

Gotchas

The full list, with the ruling behind each, is the loams-qdrant crate documentation. The ones you are most likely to hit:

  • wait does almost nothing, and that is the point. A write in Loams is acknowledged only after its durable append to the log, and an acknowledged write is already visible to reads. So wait=true never delays the answer; it only turns acknowledged into completed. ordering and read consistency are accepted and ignored: reads are strong. If you need read-your-writes across nodes with bounded staleness, send the Loams-Consistency-Token header from a previous write (consistency tokens).
  • Snapshots are manifest versions. Creating a snapshot names the newest retained manifest and writes nothing; listing shows every retained version, including ones the API did not create. Restoring is time travel on the collection, not a file upload (D38).
  • Cluster settings are echoed, not applied. Shard, replica, WAL, optimizer and strict-mode settings are stored and returned by GET /collections/{c}, but change nothing; cluster info is one synthetic local shard. ScoredPoint.version is always 0, and count with exact: false is exact anyway.
  • A write request holds at most 10,000 operations after planning; a larger one is 400 asking you to split it. Qdrant has no such limit. A query batch holds at most 1,000 queries, and a scroll limit above 100,000 is refused (D137).
  • Filters see flattened arrays. a.b also matches {"a": [{"b": …}]}, an integer condition matches an integral float (1 matches 1.0), and several sub-conditions in one FieldCondition are OR-ed. Text conditions use the standard analyzer over every string at the key, and a text index with other tokenizer options is refused. Filters on a collection not created through the Qdrant API (it has no payload field) are unsupported.
  • Recommend, discover and context are bounded. They score min(max(4 × (offset + limit), 100), max_candidates) candidates per example rather than walking the HNSW graph with a custom scorer, so a relevant point outside every example's neighbourhood can be missed. Qdrant's own custom scoring is approximate too; ours returns exact scores for what it finds.
  • Some payload updates are read-modify-write. set_payload with a non-trivial key, overwrite_payload with key, and delete_payload of paths with [] or [n] read the point and write its whole payload, so a concurrent write to the same point in between is lost.
  • Sparse IDF counts live points only. Qdrant counts deleted points until its optimizer runs, so IDF-weighted scores can differ slightly on a collection with recent deletes.
  • One port, someday. Loams proposes to also mount Qdrant's gRPC services on the engine's main port by service name, so a firewalled deployment needs one port (D601, proposed). Today they are on 6334 only. REST stays on 6333 either way: its paths would collide with the main port's.

The next part covers the other compatibility surface built for AI frameworks: the Elasticsearch REST API and Query DSL.

More from the blog