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:
- Vector search — a pure-Kotlin HNSW approximate-nearest-neighbour index for semantic / similarity search over embeddings.
- Full-text search — an inverted index with BM25 ranking and pluggable analyzers.
- Hybrid queries — vector + full-text fused with Reciprocal Rank Fusion (RRF), the 2026 best practice that lifts recall well above either retriever alone.
Status:
0.16.0, pre-1.0. All three layers, binary persistence, int8/binary quantization, metadata filters, pluggable analyzers, full-precision re-rank, graph compaction, an optional kemus storage adapter and an optionalkromus-onnxembedder are usable today; the API may still change before 1.0. See the changelog for what has moved and the roadmap for what's next.API reference: kormium.github.io/kromus — all four modules, cross-linked, rebuilt from each release tag.
On-device semantic search is now table stakes for AI features (private, offline, no per-query cost), and on the JVM and Android there are already good ways to get it. What there is not, as far as we can find, is one implementation in common code that covers the whole KMP matrix — Android and iOS and Native and Wasm — and that puts vector, full-text and hybrid retrieval in a single artifact. That is the gap kromus fills, and it is a narrow one: the claim is about coverage in common code, not about being the only vector search available to Kotlin.
| ANN / HNSW | Full-text / BM25 | Hybrid + RRF | Runs on the whole KMP matrix | |
|---|---|---|---|---|
| sqlite-vec | ✗ (brute) | ✗ | ✗ | C extension, per-platform |
| sqlite-vector | ✗ (brute) | ✗ | ✗ | C extension; Elastic License |
| vectorlite / hnswlib (C++) | ✓ | ✗ | ✗ | C++, per-platform |
| hnswlib (Java) | ✓ | ✗ | ✗ | ✗ — JVM/Android only |
| JVector | ✓ | ✗ | ✗ | ✗ — JVM only |
| ObjectBox | ✓ | ✗ | ✗ | ✗ — separate Java and Swift SDKs |
| SQLite FTS5 | ✗ | ✓ | ✗ | tied to SQLite |
| kromus | ✓ | ✓ | ✓ | ✓ common code |
Worth being precise about the neighbours, because "there is nothing else" would be false:
- On the JVM and Android you do not need a native binary. hnswlib for Java is a pure-JVM HNSW on Maven Central — thread-safe, serializable, incremental. JVector is a more advanced embedded engine in the same slice. Neither reaches iOS, Native or Wasm, which is the only reason kromus exists rather than a wrapper around one of them.
- ObjectBox is not a paid product. Its language bindings are Apache 2.0 and its core is documented as always free to use; what it is not is open-source at the core, or one artifact — vector search is offered to Java/Kotlin and to Swift as separate SDKs.
- sqlite-vec may not stay brute-force. Its ANN tracking issue has been open since 2024 and is still choosing between IVF, HNSW and DiskANN, but that is a matter of when, not whether.
Absence of evidence is not evidence of absence: this is what a search of the ecosystem turned up in August 2026, not a proof that no common-code alternative exists. If you know of one, an issue correcting this table is welcome.
// build.gradle.kts — coordinates published under the kormium org's namespace
kotlin {
sourceSets.commonMain.dependencies {
implementation("io.github.kormium:kromus-core:0.16.0")
// Optional companion modules — see their own readmes for details.
implementation("io.github.kormium:kromus-kemus:0.16.0") // persist into a kemus store
implementation("io.github.kormium:kromus-onnx:0.16.0") // on-device text embedder
implementation("io.github.kormium:kromus-sync:0.16.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] }A quantized index does not keep the originals, so fullVectors is yours to hold. An unquantized index
does keep them, and index.vectorOf(key) hands them back — useful for re-ranking a fused result list
without a second copy of the corpus.
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" }Mid-traversal filtering is what lets a filtered query still return k results — but it has a cost
worth knowing about. Rejected entries are traversed and then discarded, so a filter that matches
almost nothing keeps the search from ever filling its candidate list and it ends up walking the whole
graph. maxVisited caps that: the query stops early and returns the matches it found.
index.search(query, k = 10, maxVisited = 2000) { it["type"] == "doc" }Set it per query, or once in HnswConfig(maxVisited = …). The benchmarks below show
what it saves. To change an entry's metadata without re-indexing it, use updateAttributes — a
re-add would insert a whole new vector and leave the old one behind as a tombstone.
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)remove(key), and re-adding a key that already exists, do not free anything: the old vector stays
in memory and remains in the graph as a routing hop, which is what keeps the graph connected and
lookups correct. Over a long-lived index on changing data those tombstones accumulate — memory grows
and queries slow down, because the search walks nodes it can never return.
compact() is the reclaim step. It rebuilds the graph over the live entries only:
if (index.tombstones > index.size / 2) index.compact()It costs a full rebuild, so trigger it on churn rather than per edit — at app start, or after a bulk sync. Nothing is re-approximated: quantized codes survive a rebuild bit-exact, and a full-precision index comes out byte-identical to one built from the same entries.
updateAttributes(key, …) exists so metadata edits never create a tombstone in the first place.
A search reads the graph and changes nothing in it, but it is still not safe to run two at once on the same index: a traversal's working state — visited marks, candidate heaps, layer buffers — is reused between calls, which is what makes a query allocate almost nothing. Unguarded, concurrent searches mostly throw, and the ones that survive return the wrong neighbours.
A searcher owns that state, so searchers do not interact:
// one per thread or coroutine, not one shared between them
val searcher = index.searcher()
val hits = searcher.search(queryEmbedding, k = 10)The index's own contract is unchanged: nothing may write to it while a search runs. Searchers make
reads parallel, not reads-and-writes concurrent. If something is writing — a sync keeping the index
fresh — use kromus-sync's .concurrent() wrapper, which arranges both: searches run
alongside each other, a writer runs alone, and a writer is not starved by a steady stream of searches.
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 / decodeTextIndexA persisted index outlives the build that wrote it, so decoding is defensive: blobs carry a magic
header and a format version, every read is bounds-checked, and anything unreadable — a truncated
file, a stale cache from an older format — raises a KromusFormatException rather than crashing.
Pre-1.0 the format can change between versions, so treat a rebuild as the migration path:
val index = try {
decodeHybridIndex(cached, KeyCodec.string)
} catch (e: KromusFormatException) {
buildIndexFromScratch() // and cache the new bytes
}Encoding is deterministic: the same index content produces the same bytes on every platform — and across a reload, since records and the string-keyed maps inside them are written in a fixed order rather than in whatever order a map happens to iterate. So an index can be content-hashed, cached by digest, or compared in a test.
There is one way to build an index — add, in common code — and it runs wherever you run it. Because
construction and encoding are deterministic and identical on every target, the same code produces the
same bytes on a phone, on a server, or in a Gradle task. What differs is not the building but the
deployment, and there are two shapes:
Built where it is used. The index lives, changes and is searched in the same process.
kromus-sync keeps it in step with your data and .concurrent() guards it against a
writer and readers sharing it. This is the shape for content the user creates.
Built elsewhere, read here. Build on a server or in CI, ship the bytes, and the device only loads
and searches. The two costs a device is worst at — running an embedding model over the whole corpus,
and constructing the graph — are paid once on a machine that does not mind. Nothing writes to the
index on device, so there is no lock to take: hand each thread a searcher().
This is the shape for content you ship. samples/prebuilt builds its index in a
Gradle task and loads it from resources.
"On a server" and "at compile time" are the same shape with a different build machine — same bytes, same guarantees, same loading code. And the two shapes compose: a prebuilt index over your content plus a small local one over the user's, searched together and fused, which is what most apps actually want.
Two things do not move to the server. Queries are still embedded on the device, with the model the corpus was embedded with — which is what the next section is about. And the whole blob is still inflated into memory on load, which is the ceiling on how large a shipped index can usefully be (#27).
An index means nothing on its own. Its vectors are only comparable to queries embedded by the same model, and its terms only match queries tokenized by the same analyzer — and neither travels in the bytes. Pair a blob with the wrong one and nothing throws; the results are simply wrong.
That is easy to avoid while you build and search in one process, and easy to get wrong the moment an index is built somewhere else — on a server, in CI, by a colleague. So a blob can carry what produced it, and loading can insist:
// wherever the index is built
val blob = index.encodeToByteArray(KeyCodec.string, provenance = "all-MiniLM-L6-v2/mean/l2")
// wherever it is loaded — throws KromusFormatException if the two disagree
val index = decodeHybridIndex(blob, KeyCodec.string, expect = "all-MiniLM-L6-v2/mean/l2")
provenanceOf(blob) // read it without decoding, e.g. to decide whether to fetch a newer assetThe string is opaque to kromus — put in whatever identifies the pairing: model name and revision,
analyzer configuration, corpus date. The guard is opt-in: pass no expect and nothing is checked.
A full encode rewrites everything, however little moved. That is right for "build once, ship it" and wrong for an index a sync keeps in step with changing data — at 50 000 vectors it is tens of megabytes after every batch, which on device is a write-amplification problem, not just a slow one.
encodeDelta writes only what changed since the last save, and the decoders take a base plus the
deltas recorded after it:
val base = index.encodeToByteArray(KeyCodec.string) // also checkpoints the index
index.add("doc-99", embed("something new"))
val delta = index.encodeDelta(KeyCodec.string) // null if nothing changed
val reloaded = decodeHybridIndex(base, listOf(delta!!), KeyCodec.string)An insert relinks tens of existing nodes, so a delta is not a plain append — but a stored vector is immutable, so a changed node owes only its adjacency and never its vector. That is what keeps a delta small. See the numbers below.
Deltas accumulate; fold the chain back into a snapshot periodically by decoding it and re-encoding.
dirtyNodes says how much has changed, and needsFullSnapshot says when a delta is not an option —
compact() and clear() renumber internal ids, and deltas are written in terms of them. A delta
names the revision it applies to, so replaying one out of order, skipping one, or mixing in a delta
from a different index is rejected rather than quietly corrupting the result.
The 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)Full KDoc for every published module is at kormium.github.io/kromus,
generated from the release tag and cross-linked across kromus-core, kromus-kemus, kromus-onnx
and kromus-sync. Most of the reasoning that is not in this readme lives there: what efSearch
trades, when rerank earns its second pass, why a remove leaves a routing hop behind, how a
metadata filter is applied mid-traversal.
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).
Numbers from ./gradlew :benchmarks:run — 50 000 vectors × 128 dims, 200 queries, single-threaded on
one JVM. They are machine-specific; what travels is the shape of the curves and the ratios between
modes. Reproduce them yourself, or point the suite at your own sizes with
--args="--vectors 200000 --dim 384".
Recall is only meaningful next to the corpus it was measured on. A tightly clustered corpus makes any graph index look perfect, and one that is effectively uniform noise defeats them all — so the suite reports the dataset's contrast (how much closer the true 10th neighbour is than an average corpus member) alongside every result. The runs below sit at 2.2×, in the range real embedding corpora occupy.
Exact brute-force search over the same corpus costs 12 950 µs per query. That is the number an approximate index exists to beat.
Full precision, m = 16, efConstruction = 200, k = 10.
efSearch |
recall@10 | mean query | p95 query | vs brute force |
|---|---|---|---|---|
| 16 | 0.862 | 84 µs | 123 µs | 154× |
| 32 | 0.964 | 105 µs | 143 µs | 123× |
| 64 | 0.997 | 167 µs | 246 µs | 78× |
| 128 | 1.000 | 219 µs | 277 µs | 59× |
| 256 | 1.000 | 325 µs | 425 µs | 40× |
The default efSearch = 64 gives essentially exact results roughly 80× faster than scanning the
corpus. Raise it when recall matters more than latency; lower it for the reverse.
Same graph settings throughout, efSearch = 64. "Serialized" is what encodeToByteArray produces —
the bytes you actually ship or cache.
| mode | serialized | build | recall@10 | mean query |
|---|---|---|---|---|
None |
30.7 MiB | 21.9 s | 0.997 | 145 µs |
Int8 |
12.5 MiB | 19.9 s | 0.986 | 149 µs |
Binary |
7.0 MiB | 11.5 s | 0.283 | 73 µs |
Binary + re-rank |
7.0 MiB | — | 0.845 | 189 µs |
Int8 is close to free: 2.5× smaller for one point of recall. Binary is 4.4× smaller and the
fastest to build and query, but on its own it is genuinely coarse — pair it with a full-precision
re-rank, which is what the last row measures (over-fetch 100, re-score, keep 10).
One entry in 200 matches the filter here — the case that makes filtered vector search expensive, because rejected entries are still traversed.
| query | mean latency | hits returned (k = 10) |
|---|---|---|
| unfiltered | 155 µs | 10.0 |
| filtered, no budget | 14 835 µs | 10.0 |
filtered, maxVisited = 2000 |
573 µs | 4.5 |
filtered, maxVisited = 500 |
134 µs | 0.4 |
Unbounded, a filter this selective costs 96× an ordinary query — it walks most of the graph to fill its result. A budget is a real trade, not a free win: it buys back the latency and gives up results. Pick it against your own filters and how many hits the screen actually needs.
Every entry replaced once — the shape of an index kept in step with changing data.
| state | live entries | graph slots | serialized | mean query |
|---|---|---|---|---|
| fresh | 50 000 | 50 000 | 30.7 MiB | 151 µs |
| after replacing every entry | 50 000 | 100 000 | 60.5 MiB | 386 µs |
after compact() |
50 000 | 50 000 | 30.7 MiB | 215 µs |
Left alone, one round of replacements doubles the memory and makes queries 2.6× slower. compact()
takes it back, at the cost of a full rebuild (29 s here) — which is why it belongs at app start or
after a bulk sync, not on the edit path.
What a save costs after a batch of edits. Both columns are measured against the same index state, so the crossover is real rather than an artefact of comparing against a stale snapshot.
| edits | index | full encode | delta | smaller by | full | delta |
|---|---|---|---|---|---|---|
| 1 | 50 001 | 30.7 MiB | 5.6 KiB | 5578× | 99 ms | 3.9 ms |
| 10 | 50 011 | 30.7 MiB | 31.7 KiB | 990× | 71 ms | 1.4 ms |
| 100 | 50 111 | 30.7 MiB | 273.8 KiB | 115× | 99 ms | 4.1 ms |
| 1000 | 51 111 | 31.3 MiB | 2.3 MiB | 14× | 87 ms | 19.4 ms |
The gap narrows as the batch grows, which is the signal to fold: once a batch is worth a sizeable fraction of the index, a delta stops being a bargain and a fresh snapshot is the better save.
One index, one searcher per thread, no lock.
| threads | searches/sec | vs 1 thread | mean query |
|---|---|---|---|
| 1 | 4 986 | 1.00× | 201 µs |
| 2 | 10 334 | 2.07× | 194 µs |
| 4 | 23 115 | 4.64× | 173 µs |
| 8 | 40 150 | 8.05× | 199 µs |
| 16 | 46 005 | 9.23× | 348 µs |
Near-linear to the physical cores. Past them throughput still climbs but latency does too — 16 threads buy 15% more work for 75% slower queries, which is the wrong trade if anyone is waiting on one. How far it scales depends on whether the working set still fits cache, so read the row your corpus sits at rather than the last one.
10 000 vectors, efSearch = 64 — the context every recall number needs.
| cluster spread | contrast | recall@10 |
|---|---|---|
| 0.5 | 4.64× | 1.000 |
| 1.0 | 2.03× | 1.000 |
| 2.0 | 1.41× | 0.850 |
| 4.0 | 1.37× | 0.647 |
As a corpus approaches uniform noise its true neighbours stop being meaningfully nearer than random points, and recall falls away — for any graph index, not just this one. If your own recall looks disappointing, measure the contrast of your embeddings before blaming the index.
kromus is a search primitive, so it powers more than a search box:
- On-device search — notes, mail, messages, documents, bookmarks. Private, offline, no server and no per-query cost. Hybrid (vector + BM25) plus metadata filters give both meaning and exact-token matches; n-gram analyzers add typo-tolerant and CJK search.
- On-device / local RAG — retrieve the most relevant chunks of the user's own data to feed a
local (or remote) LLM, without shipping private data to a server. kromus is the retriever;
quantization +
rerankkeep it small on device yet accurate. - Similarity & recommendations — "more like this", related items, and near-duplicate detection / semantic dedup (contacts, tickets, media), on device or on the backend.
- Classification & routing via k-NN — match a query embedding against labelled exemplars for intent detection, auto-tagging or moderation triage. The product is a label, not a result list.
- Semantic cache for LLM calls — before calling the model, check whether a similar prompt was already answered; serve the cached answer and save tokens and latency.
- Backend / edge search — an embedded index inside a Kotlin/Ktor service for small-to-medium corpora (no separate search cluster), and — via the Native/Wasm targets — in edge/serverless runtimes where JVM-only Lucene won't run.
- Offline-first apps with sync — build the index, store it in a kemus binary value, and get persistence, TTL and offline→online sync (field, logistics, healthcare, retail).
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:
- On-device (Android / iOS / desktop)
- ONNX Runtime — run a sentence-transformer exported to ONNX (e.g.
all-MiniLM-L6-v2at 384 dims, ormultilingual-e5-small). One model, all mobile/desktop targets via the ONNX native libs. - MediaPipe Text Embedder (Google AI Edge) — Android / iOS.
- Apple NaturalLanguage sentence embeddings — iOS / macOS.
- ONNX Runtime — run a sentence-transformer exported to ONNX (e.g.
- Server / JVM — any embedding API (OpenAI, Cohere, Voyage, Jina), local models via Ollama / llama.cpp, or JVM libraries (DJL, ONNX Runtime for the JVM).
- No model (lexical) — a hashing / character-n-gram vectorizer produces fuzzy lexical vectors
with zero dependencies on every target: handy for demos and typo-tolerance, but not semantic
(for lexical relevance, prefer
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"))An index is a single-writer structure and is not thread-safe: locking inside the traversal loop
would tax the common case of building and querying in one place. The on-device case is often not that
case, though — a background sync or an import writes while the UI searches — so kromus-sync ships a
guard for it:
val index = HybridIndex<String>(dimensions = 384).concurrent()
scope.launch { docs.observe(db).syncTo(index, keyOf = { it.id }) { HybridDoc(embed(it.body), it.body) } }
val hits = index.search(embed(query), text = query, k = 10) // safe from any coroutineThe wrapper takes a Mutex around every operation, and the syncTo overloads for it run the
embedding outside the lock, so a slow model never blocks a search. It lives in kromus-sync rather
than the core because it needs coroutines, and the core has no dependencies.
- Zero dependencies in the vector layer. HNSW is arithmetic over
FloatArrayand graph structures in common code — no coroutines, serialization, crypto or native interop. - Deterministic. Level assignment is seeded (
HnswConfig.seed), float arithmetic runs in a fixed order, BM25 scores accumulate in query order and break ties by insertion order, and serialization writes records by id rather than by hash iteration order. So an index built from the same data on any platform ranks identically and encodes to identical bytes. Reproducibility is a feature, not an accident. - Reflection-free,
explicitApi(). The public surface is small, typed and ABI-validated. - Embedder-agnostic. kromus is the index, not the model. Bring your own vectors.
JVM · Android · iOS (x64/arm64/simulator) · linuxX64/Arm64 · macosX64/Arm64 · mingwX64 · JS · Wasm/JS.
- Vector layer ✅ HNSW ANN index, cosine / dot / euclidean, in-memory.
- Full-text layer ✅ Inverted index + BM25, pluggable analyzers.
- Hybrid ✅ RRF fusion of vector + full-text (
HybridIndex). - Persistence ✅ Compact, cross-platform binary encode/decode for all three indexes.
- Quantization ✅ int8 (~4×) and binary (~32×) quantization, asymmetric full-precision queries.
- Metadata filters ✅ string attributes +
MetadataFilter, applied mid-traversal for vectors. - Analyzers ✅ pluggable tokenizer: stemming, stop-words, CJK/substring n-grams.
- kemus storage ✅ optional
kromus-kemusadapter — persist an index into a kemus store (embedded / offline→online sync). - Re-rank ✅
rerank(query, candidates, k) { fullVector }— two-phase search for quantized indexes. - Embeddings ✅ optional
kromus-onnx— aTextEmbedderwhose pipeline is shared on every target, withOnnxSessionbackends for JVM, Android, web (JS + Wasm), iOS and desktop-native. - Sync ✅ optional
kromus-sync— keep an index fresh from aFlow<List<T>>snapshot stream (e.g.kormium-observe); reconciles new/changed/removed with no data-layer dep. - Maven Central ✅
kromus-core,kromus-kemus,kromus-onnxandkromus-syncare all published. - Compaction ✅
compact()rebuilds a graph over its live entries, reclaiming the tombstones that removals and replacements leave behind;updateAttributesavoids creating them at all. - Traversal budget ✅
maxVisitedbounds the cost of a highly selective metadata filter. - Benchmarks ✅ a suite covering recall, latency, quantization, filters and churn — see above.
- Concurrency ✅ optional
Mutex-guarded index wrappers inkromus-syncfor background writers. - Incremental persistence ✅
encodeDeltawrites only what changed; decoders replay a base plus its deltas, with the chain checked so a stray delta cannot be applied. - Parallel search ✅
searcher()gives a reader its own traversal state, so searches run on every core;kromus-sync's wrappers pair that with a writer-preferring lock.
Next: multi-value and numeric metadata filters, an incremental "add to a persisted index without a full re-encode" path, and SIMD-friendly distance kernels where a platform offers them without breaking the one-implementation rule.
Bug reports, failing tests and measured performance work are all welcome — see CONTRIBUTING.md for the setup, the three properties the library will not trade away, and what a change to the public API or the persistence format requires.
Apache License 2.0 — see LICENSE.