
Embedded, reflection-free hybrid search engine combining HNSW-based vector ANN, BM25 full-text and RRF fusion; supports quantized storage, metadata filters, pluggable analyzers and compact binary persistence.
An embedded, reflection-free hybrid search engine for Kotlin Multiplatform.
kromus is a pure-Kotlin search index that runs inside your app — on JVM, Android, iOS, Native, and the web (Wasm/JS) — with one implementation and identical behaviour on every target. No native library to link, no per-platform build, no server.
It ships in layers:
Status:
0.14.0, pre-1.0. All three layers, binary persistence, int8/binary quantization, metadata filters, pluggable analyzers, full-precision re-rank, an optional kemus storage adapter and an optionalkromus-onnxembedder are usable today; the API may still change before 1.0. See the roadmap for what's next.
On-device semantic search is now table stakes for AI features (private, offline, no per-query cost),
but every existing option on Kotlin is a C/C++ SQLite extension you bind per platform — sqlite-vec
(brute-force only), vectorlite/hnswlib (C++), ObjectBox (commercial, Android/JVM + a separate iOS
product, not one KMP artifact). There is no pure-Kotlin, common-code ANN index that runs on the whole
KMP matrix. That is the gap kromus fills.
| ANN / HNSW | Full-text / BM25 | Hybrid + RRF | Pure KMP (iOS + Wasm + Native) | |
|---|---|---|---|---|
| sqlite-vec | ✗ (brute) | ✗ | ✗ | C extension, per-platform |
| vectorlite / hnswlib | ✓ | ✗ | ✗ | C++, per-platform |
| ObjectBox | ✓ | ✗ | ✗ | ✗ (Android/JVM + iOS SDK) |
| SQLite FTS5 | ✗ | ✓ | ✗ | tied to SQLite |
| kromus | ✓ | ✓ | ✓ | ✓ common code |
// build.gradle.kts — coordinates published under the kormium org's namespace
kotlin {
sourceSets.commonMain.dependencies {
implementation("io.github.kormium:kromus-core:0.14.0")
// Optional companion modules — see their own readmes for details.
implementation("io.github.kormium:kromus-kemus:0.14.0") // persist into a kemus store
implementation("io.github.kormium:kromus-onnx:0.14.0") // on-device text embedder
implementation("io.github.kormium:kromus-sync:0.14.0") // keep an index fresh from a Flow
}
}kromus is embedder-agnostic: you bring the vectors (from any on-device or server embedding model)
as FloatArrays — see Embeddings — and kromus owns storage, graph construction and
retrieval.
import io.github.kromus.*
val index = VectorIndex<String>(dimensions = 384, metric = Metric.Cosine)
index.add("doc-1", embed("Kotlin coroutines guide"))
index.add("doc-2", embed("Structured concurrency in practice"))
index.add("doc-3", embed("Sourdough starter troubleshooting"))
val hits: List<SearchResult<String>> = index.search(embed("async programming"), k = 5)
// hits are closest-first; hits[i].score is a similarity (higher = closer)Re-adding a key replaces its vector; remove(key) drops it from results. See the KDoc on
VectorIndex, HnswConfig and Metric for tuning.
To cut memory on device, store vectors quantized — queries still run at full precision (asymmetric).
Quantization.Int8 is ~4× smaller with a small recall cost; Quantization.Binary is ~32× smaller
and coarse (great as a first-pass filter, typically re-ranked with full precision):
val index = VectorIndex<String>(384, config = HnswConfig(quantization = Quantization.Int8))Binary is coarse, so pair it with a full-precision re-rank: over-fetch candidates, then re-score them
against the original vectors (which you keep — a quantized index doesn't store them). This recovers
accurate top-k at a fraction of the memory:
val coarse = index.search(query, k = 100) // binary: fast, approximate
val exact = rerank(query, coarse.map { it.key }, k = 10) { fullVectors[it] }Attach string attributes to entries and restrict a query with a MetadataFilter. For vector search
the filter is applied during graph traversal, so a filtered query still returns up to k matches:
index.add("doc-1", embedding, attributes = mapOf("type" to "doc", "lang" to "en"))
index.search(query, k = 10) { it["type"] == "doc" && it["lang"] == "en" }TextIndex is a standalone BM25 index; HybridIndex combines a vector and a text index and fuses
their rankings with RRF — the recommended default, because vector search captures meaning while BM25
catches exact tokens (product codes, error strings, rare names) that embeddings miss.
The tokenizer is pluggable via Analyzer: Analyzer.standard(stopwords = Stopwords.english, stemmer = Stemmer.englishLight()) for Latin scripts, or Analyzer.ngram(2) for boundary-free languages
(CJK) and substring matching. Use the same analyzer for indexing and querying.
val index = HybridIndex<String>(dimensions = 384)
index.add("doc-1", embed("Kotlin coroutines guide"), "Kotlin coroutines guide")
index.add("doc-2", embed("Sourdough starter troubleshooting"), "Sourdough starter troubleshooting")
// fuses semantic similarity (vector) with keyword match (text)
val hits = index.search(vector = embed("async programming"), text = "coroutines", k = 10)
// or query a single modality
index.searchText("coroutines", k = 10)
index.searchVector(embed("async programming"), k = 10)Building an HNSW graph is expensive; persist a prebuilt index and reload it instantly (ship it with your app, or cache it on device). The format is a compact, dependency-free binary that is identical across platforms. Analyzers are functions and are not serialized — pass the same one when reloading.
val bytes: ByteArray = index.encodeToByteArray(KeyCodec.string)
val reloaded = decodeHybridIndex(bytes, KeyCodec.string) // or decodeVectorIndex / decodeTextIndexThe optional kromus-kemus module stores an index in a kemus
store (binary value), so it inherits kemus's persistence, TTL and offline→online sync — build once,
reload instantly:
index.saveTo(kemus, "my-index", KeyCodec.string)
val reloaded = loadHybridIndex(kemus, "my-index", KeyCodec.string)Runnable samples live in samples/: :quickstart, :hybrid, :quantization, :sync
(readable toy embedder), plus ./gradlew :samples:onnx:run — real semantic search with a genuine
all-MiniLM-L6-v2 model (auto-downloaded via kromus-onnx).
kromus is a search primitive, so it powers more than a search box:
rerank keep it small on device yet accurate.Where it doesn't fit: web-scale corpora (hundreds of millions of vectors, sharded/distributed) belong in a server-side vector DB — kromus is embedded. And it indexes vectors; you supply the model.
kromus indexes vectors; it does not compute them — by design. On-device embedding models are
heavy, platform-specific (native runtimes), separately licensed and versioned, and don't cover every
KMP target uniformly. Keeping them out of the core is exactly what lets kromus stay zero-dependency
and behave identically everywhere. You produce a FloatArray however you like and hand it in; that
embed(...) in the examples is your embedder.
Where the vectors typically come from:
all-MiniLM-L6-v2 at 384
dims, or multilingual-e5-small). One model, all mobile/desktop targets via the ONNX native libs.TextIndex/BM25).Contract: every vector in one index must have the same dimensions and come from the same
model — store the model id/version next to the index so you never mix embeddings from different models.
Batteries-included? The core stays model-free on purpose — but the optional
kromus-onnx module is the ready-to-run path. Its TextEmbedder pipeline (WordPiece
tokenizer → model → pooling → normalization) is shared common code on every target, including the
web; only the model runtime is per-platform (JVM backend ships today, web/iOS/Android/native plug into
the same OnnxSession).
val embedder = OnnxTextEmbedder(OrtOnnxSession(modelBytes), tokenizer, dimensions = 384)
index.add("doc-1", embedder.embed("Kotlin coroutines guide"))FloatArray and graph
structures in common code — no coroutines, serialization, crypto or native interop.HnswConfig.seed), and the engine uses only
fixed-order float arithmetic, so an index built from the same data on any platform ranks
identically. Reproducibility is a feature, not an accident.explicitApi(). The public surface is small, typed and ABI-validated.JVM · Android · iOS (x64/arm64/simulator) · linuxX64/Arm64 · macosX64/Arm64 · mingwX64 · JS · Wasm/JS.
HybridIndex).MetadataFilter, applied mid-traversal for vectors.kromus-kemus adapter — persist an index into a
kemus store (embedded / offline→online sync).rerank(query, candidates, k) { fullVector } — two-phase search for quantized indexes.kromus-onnx — a TextEmbedder whose pipeline is shared
on every target, with OnnxSession backends for JVM, Android, web (JS + Wasm), iOS and desktop-native.kromus-sync — keep an index fresh from a Flow<List<T>>
snapshot stream (e.g. kormium-observe); reconciles new/changed/removed with no data-layer dep.kromus-core, kromus-kemus, kromus-onnx and kromus-sync are all published.Apache License 2.0 — see LICENSE.
An embedded, reflection-free hybrid search engine for Kotlin Multiplatform.
kromus is a pure-Kotlin search index that runs inside your app — on JVM, Android, iOS, Native, and the web (Wasm/JS) — with one implementation and identical behaviour on every target. No native library to link, no per-platform build, no server.
It ships in layers:
Status:
0.14.0, pre-1.0. All three layers, binary persistence, int8/binary quantization, metadata filters, pluggable analyzers, full-precision re-rank, an optional kemus storage adapter and an optionalkromus-onnxembedder are usable today; the API may still change before 1.0. See the roadmap for what's next.
On-device semantic search is now table stakes for AI features (private, offline, no per-query cost),
but every existing option on Kotlin is a C/C++ SQLite extension you bind per platform — sqlite-vec
(brute-force only), vectorlite/hnswlib (C++), ObjectBox (commercial, Android/JVM + a separate iOS
product, not one KMP artifact). There is no pure-Kotlin, common-code ANN index that runs on the whole
KMP matrix. That is the gap kromus fills.
| ANN / HNSW | Full-text / BM25 | Hybrid + RRF | Pure KMP (iOS + Wasm + Native) | |
|---|---|---|---|---|
| sqlite-vec | ✗ (brute) | ✗ | ✗ | C extension, per-platform |
| vectorlite / hnswlib | ✓ | ✗ | ✗ | C++, per-platform |
| ObjectBox | ✓ | ✗ | ✗ | ✗ (Android/JVM + iOS SDK) |
| SQLite FTS5 | ✗ | ✓ | ✗ | tied to SQLite |
| kromus | ✓ | ✓ | ✓ | ✓ common code |
// build.gradle.kts — coordinates published under the kormium org's namespace
kotlin {
sourceSets.commonMain.dependencies {
implementation("io.github.kormium:kromus-core:0.14.0")
// Optional companion modules — see their own readmes for details.
implementation("io.github.kormium:kromus-kemus:0.14.0") // persist into a kemus store
implementation("io.github.kormium:kromus-onnx:0.14.0") // on-device text embedder
implementation("io.github.kormium:kromus-sync:0.14.0") // keep an index fresh from a Flow
}
}kromus is embedder-agnostic: you bring the vectors (from any on-device or server embedding model)
as FloatArrays — see Embeddings — and kromus owns storage, graph construction and
retrieval.
import io.github.kromus.*
val index = VectorIndex<String>(dimensions = 384, metric = Metric.Cosine)
index.add("doc-1", embed("Kotlin coroutines guide"))
index.add("doc-2", embed("Structured concurrency in practice"))
index.add("doc-3", embed("Sourdough starter troubleshooting"))
val hits: List<SearchResult<String>> = index.search(embed("async programming"), k = 5)
// hits are closest-first; hits[i].score is a similarity (higher = closer)Re-adding a key replaces its vector; remove(key) drops it from results. See the KDoc on
VectorIndex, HnswConfig and Metric for tuning.
To cut memory on device, store vectors quantized — queries still run at full precision (asymmetric).
Quantization.Int8 is ~4× smaller with a small recall cost; Quantization.Binary is ~32× smaller
and coarse (great as a first-pass filter, typically re-ranked with full precision):
val index = VectorIndex<String>(384, config = HnswConfig(quantization = Quantization.Int8))Binary is coarse, so pair it with a full-precision re-rank: over-fetch candidates, then re-score them
against the original vectors (which you keep — a quantized index doesn't store them). This recovers
accurate top-k at a fraction of the memory:
val coarse = index.search(query, k = 100) // binary: fast, approximate
val exact = rerank(query, coarse.map { it.key }, k = 10) { fullVectors[it] }Attach string attributes to entries and restrict a query with a MetadataFilter. For vector search
the filter is applied during graph traversal, so a filtered query still returns up to k matches:
index.add("doc-1", embedding, attributes = mapOf("type" to "doc", "lang" to "en"))
index.search(query, k = 10) { it["type"] == "doc" && it["lang"] == "en" }TextIndex is a standalone BM25 index; HybridIndex combines a vector and a text index and fuses
their rankings with RRF — the recommended default, because vector search captures meaning while BM25
catches exact tokens (product codes, error strings, rare names) that embeddings miss.
The tokenizer is pluggable via Analyzer: Analyzer.standard(stopwords = Stopwords.english, stemmer = Stemmer.englishLight()) for Latin scripts, or Analyzer.ngram(2) for boundary-free languages
(CJK) and substring matching. Use the same analyzer for indexing and querying.
val index = HybridIndex<String>(dimensions = 384)
index.add("doc-1", embed("Kotlin coroutines guide"), "Kotlin coroutines guide")
index.add("doc-2", embed("Sourdough starter troubleshooting"), "Sourdough starter troubleshooting")
// fuses semantic similarity (vector) with keyword match (text)
val hits = index.search(vector = embed("async programming"), text = "coroutines", k = 10)
// or query a single modality
index.searchText("coroutines", k = 10)
index.searchVector(embed("async programming"), k = 10)Building an HNSW graph is expensive; persist a prebuilt index and reload it instantly (ship it with your app, or cache it on device). The format is a compact, dependency-free binary that is identical across platforms. Analyzers are functions and are not serialized — pass the same one when reloading.
val bytes: ByteArray = index.encodeToByteArray(KeyCodec.string)
val reloaded = decodeHybridIndex(bytes, KeyCodec.string) // or decodeVectorIndex / decodeTextIndexThe optional kromus-kemus module stores an index in a kemus
store (binary value), so it inherits kemus's persistence, TTL and offline→online sync — build once,
reload instantly:
index.saveTo(kemus, "my-index", KeyCodec.string)
val reloaded = loadHybridIndex(kemus, "my-index", KeyCodec.string)Runnable samples live in samples/: :quickstart, :hybrid, :quantization, :sync
(readable toy embedder), plus ./gradlew :samples:onnx:run — real semantic search with a genuine
all-MiniLM-L6-v2 model (auto-downloaded via kromus-onnx).
kromus is a search primitive, so it powers more than a search box:
rerank keep it small on device yet accurate.Where it doesn't fit: web-scale corpora (hundreds of millions of vectors, sharded/distributed) belong in a server-side vector DB — kromus is embedded. And it indexes vectors; you supply the model.
kromus indexes vectors; it does not compute them — by design. On-device embedding models are
heavy, platform-specific (native runtimes), separately licensed and versioned, and don't cover every
KMP target uniformly. Keeping them out of the core is exactly what lets kromus stay zero-dependency
and behave identically everywhere. You produce a FloatArray however you like and hand it in; that
embed(...) in the examples is your embedder.
Where the vectors typically come from:
all-MiniLM-L6-v2 at 384
dims, or multilingual-e5-small). One model, all mobile/desktop targets via the ONNX native libs.TextIndex/BM25).Contract: every vector in one index must have the same dimensions and come from the same
model — store the model id/version next to the index so you never mix embeddings from different models.
Batteries-included? The core stays model-free on purpose — but the optional
kromus-onnx module is the ready-to-run path. Its TextEmbedder pipeline (WordPiece
tokenizer → model → pooling → normalization) is shared common code on every target, including the
web; only the model runtime is per-platform (JVM backend ships today, web/iOS/Android/native plug into
the same OnnxSession).
val embedder = OnnxTextEmbedder(OrtOnnxSession(modelBytes), tokenizer, dimensions = 384)
index.add("doc-1", embedder.embed("Kotlin coroutines guide"))FloatArray and graph
structures in common code — no coroutines, serialization, crypto or native interop.HnswConfig.seed), and the engine uses only
fixed-order float arithmetic, so an index built from the same data on any platform ranks
identically. Reproducibility is a feature, not an accident.explicitApi(). The public surface is small, typed and ABI-validated.JVM · Android · iOS (x64/arm64/simulator) · linuxX64/Arm64 · macosX64/Arm64 · mingwX64 · JS · Wasm/JS.
HybridIndex).MetadataFilter, applied mid-traversal for vectors.kromus-kemus adapter — persist an index into a
kemus store (embedded / offline→online sync).rerank(query, candidates, k) { fullVector } — two-phase search for quantized indexes.kromus-onnx — a TextEmbedder whose pipeline is shared
on every target, with OnnxSession backends for JVM, Android, web (JS + Wasm), iOS and desktop-native.kromus-sync — keep an index fresh from a Flow<List<T>>
snapshot stream (e.g. kormium-observe); reconciles new/changed/removed with no data-layer dep.kromus-core, kromus-kemus, kromus-onnx and kromus-sync are all published.Apache License 2.0 — see LICENSE.