Repository navigation
Porting Zayit to Android: feasibility notes (search stack verified on ART) #606
Replies: 1 comment
Update: ONNX Runtime + tokenizer verified on ART (API 35)The semantic addon's embedding pipeline was checked the same way as Lucene: on an API 35 emulator (x86_64), run straight on ART through The test reproduces the embedder exactly (v5 normalization → DJL Android dependencies used:
The max per-component difference is 9e-4, which is int8 kernel rounding. On a query/passage pair, the score goes from 0.5974 on desktop to 0.5978 on Android, so rankings don't change. No patch needed, unlike Lucene. Things to know for the build:
Still open:
So the search stack, lexical and semantic, works on Android 15+, with the two Lucene class patches described above as the only code change. |
Uh oh!
There was an error while loading. Please reload this page.
Notes on what an Android port of Zayit would take, starting with its riskiest part: search. The search stack (lexical Lucene, plus the semantic addon's dense KNN on the same fused index) was tested on real Android runtimes (ART) to find the lowest Android version it can run on.
TL;DR
How it was tested
text,book_id/is_base_bookpoints, storedline_id, a 384-d COSINEvecfield, 20k docs. It was written on desktop with Lucene 10.4.google_apis), the test opens the index withNIOFSDirectory, then runs aTermQuery, aKnnFloatVectorQuery, and aKnnFloatVectorQuerywith the sameBooleanQueryfilter asVectorSearcher.dalvikvm64, with Lucene dexed by D8, in both--min-api 26and--min-api 35(the result was the same).java.lang.StackWalker/java.lang.RecordmissingHashMap.newHashMapmissing (SegmentInfos): the index doesn't even openRuntime.version()Runtime.version()"Same results" means the same
line_ids in the same order for all three queries.What breaks on ART, and the fixes
org.apache.lucene.internal.vectorization.VectorizationProvider:lookup()starts withRuntime.version().feature(), unguarded. ART has noRuntime.Version, soLucene104Codecfails to instantiate. That kills everything, lexical search included: postings decoding,VectorUtil, the HNSW scorers.Fix: return
DefaultVectorizationProviderfirst when!Constants.IS_HOTSPOT_VM. That is the scalar path; there is no Panama Vector API on Android anyway.org.apache.lucene.util.HotspotVMOptions: on ART theHotSpotDiagnosticMXBeanlookup fails as expected, but thecatchblock then callsClass.getModule()/ModuleLayer, which don't exist on ART. This class is loaded throughConstants→RamUsageEstimator→ArrayUtil, so it is everywhere.Fix: in that
catch, just log the warning.MMapDirectoryis Panama-only in Lucene 10. UseNIOFSDirectory, as we already do for GraalVM inLuceneSearchEngine,LuceneLookupSearchServiceandVectorSearcher. Only the condition needs widening (GraalVM or Android).DbDeltaUpdateServiceusesFSDirectory.opentoo.META-INF/services/*from the Lucene jars, and keep the codec, postings and vector format classes in R8. Without them, SPI finds no codec.Not needed, and still broken:
IndexWriter(it also callsRuntime.version()). The app only reads the index, so it doesn't matter.Not tested yet:
lucene-analysis-common,lucene-queryparserandlucene-highlighter, which the lexical engine also uses. They need the same check, and might need more patches.Gradle patching
Idea: commit the two patched classes as sources (a copy of the upstream files plus the small diff), and swap them into
lucene-corefor the Android build only. Two possible ways:A. AGP Instrumentation API (recommended).
androidComponents.onVariants { it.instrumentation.transformClassesWith(LucenePatch::class.java, InstrumentationScope.ALL) {} }, with anAsmClassVisitorFactory:isInstrumentable()matches only the two class names.ClassReader(patchedBytes).accept(next, 0)).lucene-core(compileOnly) and loaded as resources.B. Gradle artifact transform on the
lucene-corejar (TransformAction+ an attribute requested by the Android*RuntimeClasspathconfigurations). It rewrites the jar and replaces the two.classentries. This works with any toolchain, at the cost of a bit more attribute plumbing.In both cases, a guard is needed: the patch records the SHA-256 of the upstream classes it replaces, and the build fails if they change. Then a Lucene bump can't silently ship a stale patch. Diff the upstream file, re-apply, update the hash.
Upstreaming is an option too: a guard in
VectorizationProvider.lookup()andHotspotVMOptionscosts little. But Lucene doesn't officially support Android, and API 35 would remain the floor because ofnewHashMap,StackWalker,List.getFirst, etc.Semantic search (addon)
com.microsoft.onnxruntime:onnxruntime-androidhas the sameai.onnxruntime.*Java API as the desktop jar. Use the full AAR, not the-mobileone, which has a reduced op set and may lack the int8 quantized ops.ai.djl.android:tokenizer-native, or rewrite it in pure Kotlin. It is plain NFKC + whitespace + WordPiece, so the rewrite is small.embedNormalizedalready accepts batches). To be measured on a real device.Data size (the real constraint)
On desktop:
seforim.dbtakes about 6.8 GB andseforim.db.luceneabout 8.4 GB, of which about 1.9 GB is float32 vectors.Options:
Lucene99HnswScalarQuantizedVectorsFormat, int8 ≈ ÷4). This must go with a recall check.The rest of the app
SeforimLibrarycore/daoalready declare Android targets. Thesearchmodule and the generator are JVM-only.SeforimApp/src/androidMainexists, but it's only a stub (manifest + icons).commonMainstate.jvmMain. Moving the platform-free parts tocommonMain(or a shared Android/JVM source set) is a prerequisite.Proposed next steps
analysis-common,queryparserandhighlighter, then on a real slice of the production index (lexical + KNN), and measure latency withNIOFSDirectory.All reactions