DEV Community

Cover image for Kdrant: an idiomatic, coroutine-first Kotlin client for Qdrant
AS
AS

Posted on

Kdrant: an idiomatic, coroutine-first Kotlin client for Qdrant

If you build on the JVM and want to use Qdrant, the official client is io.qdrant:client — and it's built for Java. Every call returns a ListenableFuture, requests are assembled with protobuf builders, and it drags a gRPC/Netty stack onto your classpath. From Kotlin, that means fighting the language:

// official Java client, from Kotlin
val future: ListenableFuture<UpdateResult> = client.upsertAsync("articles", points)
val result = future.get() // block, or bolt on a future→coroutine bridge yourself
Enter fullscreen mode Exit fullscreen mode

You reach for coroutines, you get futures. You want a DSL, you get protobuf builders.

Meet Kdrant

Kdrant is the client you'd actually want to write Kotlin against:

  • Coroutine-first — every operation is a suspend function, with cooperative cancellation and timeouts
  • Type-safe DSLs for collections, points, payloads, and filters
  • Small footprint — a pure-Kotlin REST engine on Ktor + kotlinx-serialization; no gRPC, Netty, or protobuf
  • Typed errors — a sealed KdrantException you can handle exhaustively

It's stable (1.1.0, SemVer) and published to Maven Central under io.github.nacode-studios.

Quick start

Requires JDK 17+. One dependency:

dependencies {
    implementation("io.github.nacode-studios:kdrant-transport-rest:1.1.0")
}
Enter fullscreen mode Exit fullscreen mode

Spin up Qdrant locally:

docker run -p 6333:6333 qdrant/qdrant
Enter fullscreen mode Exit fullscreen mode

Connect, create a collection, upsert, search:

val qdrant = Kdrant(host = "localhost", port = 6333) {
    apiKey = System.getenv("QDRANT_API_KEY") // omit for a local, unauthenticated node
    requestTimeout = 5.seconds
}

qdrant.use { client ->
    client.createCollection("articles") {
        vector { size = 1_536; distance = Distance.COSINE }
    }

    client.upsert("articles", wait = true) {
        point(id = 1) {
            vector(embedding) // your List<Float> from any embedding model
            payload("title" to "Introduction", "lang" to "en", "year" to 2026)
        }
    }

    val hits = client.search("articles") {
        query(queryVector)
        limit = 5
        filter { must { "lang" eq "en" } }
    }
}
Enter fullscreen mode Exit fullscreen mode

Kdrant stores and searches vectors you already have — it does not generate embeddings.

A filter DSL that reads like Kotlin

Qdrant's full filtering model, expressed declaratively:

val query = filter {
    must {
        "lang" eq "en"
        "year" gte 2024
        "price" between 10.0..99.0
    }
    should {
        matchAny("tag", "featured", "promo")
        geoRadius("location", GeoPoint(lon = 13.40, lat = 52.52), radius = 5_000.0)
    }
    mustNot { "archived" eq true }
}
Enter fullscreen mode Exit fullscreen mode

Decode results straight into your types

@Serializable data class Article(val title: String, val lang: String)

val articles: List<Hit<Article>> = qdrant.searchAs<Article>("articles") {
    query(queryVector); limit = 5
}
val first: Article? = articles.firstOrNull()?.payload
Enter fullscreen mode Exit fullscreen mode

Hybrid search (dense + sparse)

The modern /points/query engine is fully supported. Fuse several prefetch sources with Reciprocal Rank Fusion for true dense + keyword hybrid search:

val hits = qdrant.search("articles") {
    prefetch { query(denseVector); using = "text"; limit = 50 }
    prefetch { querySparse(indices, values); using = "keywords"; limit = 50 }
    rrf()   // or dbsf()
    limit = 10
}
Enter fullscreen mode Exit fullscreen mode

recommend / discover / context, grouped and batch search, multi-vectors, and a Flow-based scroll are all there too.

Building RAG? It plugs in.

Kdrant ships first-class integrations so you don't wire it by hand:

  • Spring Boot starter — an auto-configured QdrantClient bean
  • Spring AI — implements VectorStore
  • LangChain4j — implements EmbeddingStore

There's a runnable example-rag service (ingest → embed → store → retrieve) with a docker-compose for Qdrant, so you can see it end to end.

The honest tradeoff

For raw throughput and streaming, gRPC/HTTP2 still wins — reach for the official client when that is your bottleneck. Kdrant trades that for idiomatic Kotlin and a much smaller footprint:

Kdrant Official io.qdrant:client
Wire protocol REST over Ktor CIO gRPC (HTTP/2)
Heavy deps none — pure Kotlin shaded Netty, protobuf, gRPC, Guava
Added footprint ~3–5 MB ~15–20 MB
API style suspend + Flow, DSL ListenableFuture, protobuf builders
GraalVM native friendly needs gRPC/Netty/protobuf config

For typical RAG and embedding-search workloads, that's a trade I'll take.

Try it

Feedback is very welcome — especially on API ergonomics. If there's something you'd want from a Kotlin-native Qdrant client, open an issue and let's talk.

Top comments (2)

Collapse
 
topstar_ai profile image
Luis Cruz

I appreciate how Kdrant provides a coroutine-first approach, which aligns well with Kotlin's asynchronous programming model, making it easier to write efficient and readable code. The use of type-safe DSLs for collections, points, and filters is also a great feature, as it reduces the likelihood of runtime errors and improves code maintainability. I'm curious to know how the performance of Kdrant compares to the official Java client, especially in terms of latency and throughput, have you conducted any benchmarks to measure the differences?

Collapse
 
tonytonycoder11 profile image
AS

Thanks, that means a lot — the coroutine + DSL ergonomics were basically the whole reason I built it, so it's good to hear that part landed.

Honest answer on perf: I haven't done a proper head-to-head against the official client yet. There's a JMH benchmark in the repo (the benchmarks module) that measures upsert and search latency end-to-end against a real Qdrant, p50/p90/p99, but that's Kdrant on its own, not a side-by-side — so I'd rather not throw out comparison figures I can't stand behind.

What I'd expect: gRPC should come out ahead on raw throughput and anything streaming-heavy, since HTTP/2 multiplexing and binary protobuf just carry less overhead than REST/JSON (I say as much in the README). For a single query or upsert I'd expect them to be pretty close though, because most of the time goes into Qdrant's ANN search and the network round-trip, not into how the request got serialized. The coroutines aren't really about making one call faster either — they just make firing off lots of concurrent calls far less painful than juggling ListenableFutures on a thread pool.

Where the gap is already real is footprint: ~3–5 MB with no gRPC/Netty/protobuf on the classpath, versus ~15–20 MB — which also makes GraalVM native a lot less of a headache.

A proper apples-to-apples benchmark is honestly overdue. The harness is already there, so if you end up pointing it at your own workload I'd love to see what you get.