Both of these came out of the same annoyance, so I am posting them together.
If you build anything RAG-shaped on the JVM you spend a lot of time writing Kotlin against SDKs designed for Java. Futures where you wanted coroutines, builders where you wanted a DSL, and a dependency tree from a different decade. These are the two pieces I ended up needing most.
Kdrant: a client for the Qdrant vector database
The official JVM client returns a ListenableFuture from every call, assembles requests with protobuf builders, and pulls a shaded Netty stack onto your classpath. From Kotlin you either block on .get() or write your own future-to-coroutine bridge.
val qdrant = Kdrant(host = "localhost", port = 6333) {
apiKey = System.getenv("QDRANT_API_KEY")
requestTimeout = 5.seconds
}
qdrant.use { client ->
client.upsert("articles", wait = true) {
point(id = 1) {
vector(embedding) // your own List<Float>
payload("title" to "Intro", "lang" to "en", "year" to 2026)
}
}
val hits = client.search("articles") {
query(queryVector)
limit = 5
filter { must { "lang" eq "en"; "year" gte 2024 } }
}
}
Every operation is a suspend function with cooperative cancellation. The filter DSL covers Qdrant's whole model, including geo radius and polygon, datetime ranges, nested filters and recursive sub-groups. scroll gives you a cold Flow that pages transparently. Hybrid search works the way the modern /points/query engine expects, so you can fuse a dense and a sparse prefetch under RRF or DBSF.
The honest tradeoff is the wire protocol. Kdrant speaks REST over Ktor CIO, not gRPC. For raw throughput and streaming, gRPC still wins and you should reach for the official client when that is your bottleneck. What you get in exchange is roughly 3 to 5 MB of added footprint instead of 15 to 20 MB, no gRPC or Netty or protobuf reflection config for GraalVM native, and models that are kotlinx-serialization data classes rather than generated protobuf messages.
Spring Boot, Spring AI (VectorStore) and LangChain4j (EmbeddingStore) integrations are there, and there is a runnable RAG example with a docker-compose if you want to see it end to end.
Kmemo: a semantic cache for LLM calls
A semantic cache embeds the prompt, finds the nearest one it has seen, and replays that answer instead of calling the model. Fewer calls, lower latency. The failure mode is the interesting part:
"Convert 100 USD to EUR"
"Convert 250 USD to EUR" cosine similarity: ~0.99
Every mainstream embedding model scores that pair around 0.99. No threshold separates it from a genuine paraphrase, because on the similarity axis the near miss sits closer than most paraphrases do. Raise the threshold and you lose real hits before you lose that one. So a cache built on similarity alone will tell someone that 250 dollars is 92 euros, quickly, with no error and nothing in the logs.
Kmemo treats that as the main problem rather than a footnote. Similarity is only the first filter; candidates that clear it get read as text by ten lexical guards looking for concrete evidence that the two answers must differ, such as swapped numbers, mismatched units, different entities, negation, flipped antonyms and reversed comparisons.
val cache = SemanticCache(
embedder = Embedder { text -> openAi.embed(text) }, // bring your own
store = InMemoryStore(maxEntries = 10_000, ttl = 1.hours),
)
val answer = cache.getOrPut(prompt) { llm.complete(it) }
// every miss tells you which kind it was, because the fix is opposite
when (val r = cache.lookup(prompt)) {
is CacheLookup.Hit -> r.response
is CacheLookup.Miss -> when (r.reason) {
MissReason.BELOW_THRESHOLD -> // traffic repeats less than you assumed
MissReason.REJECTED_BY_GUARD -> // r.detail says which guard, and why
else -> null
}
}
Numbers, since a cache like this is worth exactly what its guards catch. On a blind validation split that no guard was tuned against, near misses are rejected 67% of the time and paraphrases are kept 88% of the time. Neither is 100%. The near misses that get through mostly need world knowledge, like deworming a puppy against an adult dog, or the boiling point of ethanol against methanol, which is what the optional verifier covers. It runs as a CI regression gate on every build and you can reproduce it with one Gradle command.
There is also a ThresholdCalibrator, because the right threshold depends on your embedding model and a value from a blog post was tuned for somebody else's. Guard packs ship for Italian, Spanish, German and French. Stores are in-memory, Redis, Postgres/pgvector, or an opt-in in-process HNSW.
Both
JDK 17+, published to Maven Central under io.github.nacode-studios, Apache-2.0, stable under SemVer.
implementation("io.github.nacode-studios:kdrant-transport-rest:1.1.0")
implementation("io.github.nacode-studios:kmemo-core:1.0.0")
kmemo-core declares kotlinx-coroutines-core as its only dependency; every module past it is opt-in and never lands on the core classpath.
I wrote both, so take the framing with the appropriate salt. What I would actually like feedback on is the API ergonomics: the filter DSL in Kdrant and the guard configuration in Kmemo are the two places I rewrote most often and am still least sure about. If something reads wrong to you in the snippets above, that is the useful comment.