Skip to content

Elasticsearch vs OpenSearch vs Luxir: Indexing Benchmarks

Indexing 10 million Wikipedia documents until they were searchable in a single segment took Luxir 19.3 to 19.6 seconds. It took Elasticsearch 177 to 190 seconds and OpenSearch 194 to 196, 9.0 to 10.1 times as long. On a structured corpus built for faceting, with no text, Luxir took 6.1 seconds, Elasticsearch 54 to 55, and OpenSearch 58 to 59: 8.8 to 9.7 times as long. Each range covers 1, 2, and 4 GiB of indexing RAM, which made little difference to any of the three engines.

Most of the gap is in the force merge to one segment. Getting every document searchable took Elasticsearch and OpenSearch 4.1 to 4.7 times as long as Luxir on full text, and 5.8 to 6.9 times on the facet corpus. The force merge after it took them 30 to 38 times as long on full text, and 14.5 to 16.7 times on the facet corpus. Their force merges kept about one hardware thread busy, and Luxir’s kept 13 busy on full text. Over the whole run, they used 3.6 to 4.2 times Luxir’s server CPU time on full text, and 4.8 to 5.6 times on the facet corpus.

OpenSearch took 2% to 11% longer than Elasticsearch on full text and 7% to 10% longer on the facet corpus, and used 10% to 16% more CPU. Both were given the fastest configuration I found for each corpus. With their shipped settings, at 2 GiB, they were 5% to 30% slower than that. For a closer look at the two, see Elasticsearch vs OpenSearch: indexing.

Results as of 2026-11-01
Elasticsearch 9.7.0-SNAPSHOT, Elastic’s official snapshot build of main at 87a91d32c (built 2026-10-30), bundled JDK, 20 GiB heap
OpenSearch 3.10.0 nightly build 12323, the official CI build of main at b86aeacb4 (built 2026-10-31), bundled JDK, 20 GiB heap
Luxir Commit b0fa285 from main, -march=native
Hardware munin: AMD Ryzen 9 9955HX (16 cores, 32 threads), 64 GB RAM. 14 cores for the server, 2 for the feeder
Storage Every index in RAM (tmpfs), so no engine waits on a disk
Runs Ten runs of every engine at every point, each on a freshly started server and an empty index. Every number is the mean of the ten
SearchBench luxir-search/searchbench, measured at the tag indexing-munin-20261101 on my fork. The full-text runs used the commit before it, which differs only in the bulk feeder
Raw results Release indexing-munin-20261101: every run’s record, including the configurations tried and the runs superseded, the generated tables, and the campaign driver, with a SHA-256 checksum

Both reference engines are unreleased official builds of their main branches from the same week, and both are on Lucene 10.5.2.

Seconds to index 10 million Wikipedia documents to one searchable segment, at 1, 2, and 4 GiB of indexing RAM. Elasticsearch 177.3, 190.2, and 181.2 seconds; OpenSearch 196.4, 193.8, and 195.5; Luxir 19.6, 19.6, and 19.3. Each bar is split where every document became searchable: about 66 to 69 seconds for Elasticsearch, 75 for OpenSearch, and 16 for Luxir.

Seconds to index the 10 million document facet corpus to one searchable segment, at 1, 2, and 4 GiB of indexing RAM. Elasticsearch 54.7, 54.1, and 53.8 seconds; OpenSearch 58.3, 58.1, and 59.3; Luxir 6.1 at every level. Each bar is split where every document became searchable: about 24 seconds for Elasticsearch, 27 to 28 for OpenSearch, and 4 for Luxir.

The solid part of each bar is the time until every document was searchable. The hatched part is the force merge to one segment that followed.

Seconds from the first document sent, the mean of ten runs. ± is the standard deviation of the ten. Force merge is the time from visible to one segment, which for Elasticsearch and OpenSearch includes the _flush before it. Server CPU-seconds run from the first document to one segment. Index is the data directory at one segment.

RAM Engine Acknowledged Visible Force merge One segment Server CPU-seconds Index
1 GiB Elasticsearch 60.6 s 65.7 s 111.5 s 177.3 s ± 10.9 1,746 10.59 GiB
1 GiB OpenSearch 71.4 s 74.5 s 121.9 s 196.4 s ± 6.4 1,928 10.58 GiB
1 GiB Luxir 15.0 s 15.9 s 3.7 s 19.6 s ± 0.3 474 8.93 GiB
2 GiB Elasticsearch 58.7 s 66.2 s 124.0 s 190.2 s ± 7.5 1,738 10.58 GiB
2 GiB OpenSearch 71.4 s 74.6 s 119.2 s 193.8 s ± 5.2 1,926 10.59 GiB
2 GiB Luxir 14.3 s 16.1 s 3.5 s 19.6 s ± 0.9 464 8.93 GiB
4 GiB Elasticsearch 55.4 s 69.2 s 111.9 s 181.2 s ± 16.3 1,666 10.59 GiB
4 GiB OpenSearch 71.9 s 75.7 s 119.8 s 195.5 s ± 4.8 1,938 10.59 GiB
4 GiB Luxir 13.4 s 16.2 s 3.1 s 19.3 s ± 0.2 461 8.93 GiB
RAM Engine Acknowledged Visible Force merge One segment Server CPU-seconds Index
1 GiB Elasticsearch 23.2 s 24.2 s 30.5 s 54.7 s ± 1.4 547 1.96 GiB
1 GiB OpenSearch 25.5 s 26.8 s 31.5 s 58.3 s ± 2.3 629 1.95 GiB
1 GiB Luxir 3.4 s 4.2 s 1.9 s 6.1 s ± 0.1 113 0.62 GiB
2 GiB Elasticsearch 23.1 s 24.0 s 30.0 s 54.1 s ± 1.4 545 1.96 GiB
2 GiB OpenSearch 25.4 s 27.8 s 30.2 s 58.1 s ± 2.2 624 1.95 GiB
2 GiB Luxir 3.2 s 4.0 s 2.1 s 6.1 s ± 0.0 114 0.62 GiB
4 GiB Elasticsearch 23.0 s 23.9 s 29.9 s 53.8 s ± 1.3 545 1.96 GiB
4 GiB OpenSearch 25.6 s 26.9 s 32.5 s 59.3 s ± 1.1 631 1.95 GiB
4 GiB Luxir 3.2 s 4.1 s 2.1 s 6.1 s ± 0.1 114 0.62 GiB

Elasticsearch’s full-text runs varied the most: at 4 GiB, from 164.9 to 218.0 seconds to one segment.

The server’s CPU time, split at visible, at 2 GiB of indexing RAM. The other levels are within 5%. A busy hardware thread is one CPU-second per second; the server has 28.

Luxir Elasticsearch OpenSearch
Full text: CPU-seconds until visible 418 1,600 1,781
Full text: hardware threads busy until visible 25.9 24.2 23.9
Full text: hardware threads busy in the force merge 13.3 1.1 1.2
Facet: CPU-seconds until visible 104 510 592
Facet: hardware threads busy until visible 25.7 21.2 21.3
Facet: hardware threads busy in the force merge 4.9 1.2 1.1

Until documents are visible, all three engines keep most of the server busy, so the time to visible follows the CPU work. Elasticsearch and OpenSearch spend 3.8 and 4.3 times Luxir’s CPU time to get there on full text, and 4.9 and 5.7 times on the facet corpus.

The force merge is different. Elasticsearch and OpenSearch keep about one hardware thread busy through it. A Lucene merge runs on a single thread, except for vector graphs, which neither corpus has. The last merge, the one that produces the single segment, has nothing to run beside it. Luxir splits a merge into tasks by field, and the postings of a large text field by term range, and runs the tasks in parallel under its indexing memory budget. On full text that kept 13 threads busy, and the force merge took 3.1 to 3.7 seconds against 111 to 124. On the facet corpus, Luxir’s force merge took about 2 seconds on about five threads, against 30 to 33.

Elasticsearch and OpenSearch with their own index settings, at 2 GiB, three runs each. Only the mapping, the skip index on the numeric fields, one shard, and no replicas are set, so the refresh interval, merge auto-throttling, the translog, and merge threads are all at each engine’s defaults. The nodes are the same as above: 20 GiB heap, a 2 GiB indexing buffer, security off.

Corpus Engine Visible Force merge One segment Against its fastest configuration Against Luxir
Full text Elasticsearch 74.3 s 134.4 s 208.6 s ± 8.7 10% slower 10.6x
Full text OpenSearch 79.2 s 124.6 s 203.8 s ± 7.5 5% slower 10.4x
Facet Elasticsearch 23.6 s 46.8 s 70.4 s ± 2.1 30% slower 11.5x
Facet OpenSearch 26.4 s 37.1 s 63.5 s ± 5.0 9% slower 10.4x

On the facet corpus, both made documents visible in about the same time as in their fastest configuration, and the difference is in the force merge. The full-text runs were fed by the earlier feeder, as were the full-text results above (see Feeding).

Full text. The first 10,000,000 documents of the English Wikipedia line-document file that Lucene’s own benchmark suite, luceneutil, uses: Wikipedia from May 2012, split into chunks of at most 1,024 characters. Each document also carries the fields of the facet corpus below, so it is 13.6 GB of JSON in all. It is the same corpus as the full-text search benchmark.

Facet. 10,000,000 documents without the text: an id, nine string fields with 10 to 5 million distinct values (Zipf or uniformly distributed), seven string fields marking nested subsets from 99% down to 0.01% of the documents, two integer fields, and a date: 4.0 GB of JSON. It is the corpus of the string faceting benchmark, and indexing it is mostly about building the per-field structures that faceting and filtering read, not about text analysis.

The engines build the same logical index:

  • id: an exact-match identifier, searchable and readable per document.
  • body (full text only): analyzed text with positions, so phrase queries work, and stored.
  • The string fields: exact-match searchable, with per-document values for faceting and sorting.
  • The numeric and date fields: per-document values with a block-level skip index (minimum and maximum per block of documents), which is what range filters and sorting use here. None of the three engines builds a points (BKD) index on them.

Elasticsearch and OpenSearch map the strings as keyword, the numbers as long, integer, and date with index: false, and turn on the skip index with index.mapping.use_doc_values_skipper: true (Elasticsearch) or skip_list: true (OpenSearch). To check that the two build the same thing, I read each engine’s finished segment with Lucene’s own API, using that engine’s jars. For every field, the internal metadata fields included, they record the same index options, doc values type, skip index, points, and norms. In both, the only field with points is _seq_no, the engine’s own per-document sequence number.

Luxir uses its suffix templates: _s is an exact-match index plus a column, _i and _dt are columns with zone maps (its block-level minimum and maximum), id is its identifier type, and body is text, stored.

The indexes are not the same size, mostly because of what each stores. Elasticsearch and OpenSearch keep _source, the whole original JSON of every document. Luxir stores body and keeps everything else as columns only. See what is not equivalent.

Each run starts a fresh server on an empty data directory, creates the index, and then times, from the first document sent:

  • Acknowledged: every document has been accepted by the server.
  • Visible: every document is searchable. For Luxir that is a commit, which makes documents searchable and durable in one step. For Elasticsearch and OpenSearch it is a _refresh. Their acknowledged documents are already durable in the transaction log, so a Lucene commit (_flush) is not needed to make them searchable.
  • One segment: right after visible, a force merge to a single segment is requested and has finished, committed, and been made searchable. For Luxir it is a commit with max_segments: 1 that waits for the merge. For Elasticsearch and OpenSearch it is a _flush, then _forcemerge?max_num_segments=1, then a _refresh. The _flush takes under a second. Without it, Elasticsearch’s full-text runs often ran this 64 GB machine out of memory near the end of the force merge, with every index held in RAM.

Ending at one segment gives every engine the same finish line. “Done indexing” otherwise depends on where each merge policy happens to stop, and a single segment is how SearchBench serves its query benchmarks.

After each run the harness checks that the index holds all 10,000,000 documents, live, in exactly one segment. All 192 runs in the results passed. The server’s CPU time comes from the kernel’s accounting for the server process.

Luxir takes one HTTP connection streaming newline-delimited JSON to its /update endpoint. It batches the stream on the server and spreads the batches over its indexing threads itself. A second stream made the facet corpus slower (6.6 s against 6.1 to one segment). Documents are sent with allow_dups, so Luxir does not check for an existing document with the same id.

Elasticsearch and OpenSearch take 32 concurrent _bulk connections, each a kept-alive connection sending 1,000 documents per request and waiting for the response before sending the next. Neither gets near its indexing speed from one connection that waits for each response, so concurrent connections are how they are fed in practice. With 64 or 128 connections, both were no faster on the facet corpus. The document ids are generated by the engine, which lets both skip the check for an existing document.

The feeders are SearchBench’s own and run on the two cores (four threads) set aside from the server. An earlier version of the bulk feeder opened a new connection for every request and parsed every response in full. On the facet corpus that held both Elasticsearch and OpenSearch back by about 16% while they were still feeding, so the facet runs were all redone with the current feeder. The full-text runs are the earlier feeder’s: there the servers are the limit, and runs with the current feeder came out the same.

The RAM level is the memory an engine may use for its in-memory indexing buffers before writing segments.

  • Luxir: --indexing.max-ram-mb, the cap on its indexing memory. Merges also reserve memory against it.
  • Elasticsearch and OpenSearch: indices.memory.index_buffer_size, set as an absolute size. Their merges use memory from the rest of the heap, which is fixed at 20 GiB at every level.

So the equivalence is approximate, and it favors Elasticsearch and OpenSearch.

What was done to make Elasticsearch and OpenSearch fast

Section titled “What was done to make Elasticsearch and OpenSearch fast”

I want the reference engines at their best. These choices all favor them:

  • Each engine’s fastest configuration I found, per corpus. Before the measured runs, I tried a set of settings on each at 2 GiB and kept the fastest to one segment (table below).
  • Index in RAM. No disk I/O. The tmpfs is sized at 1 TiB, so no engine sees a small disk.
  • Merge auto-throttling off (index.merge.scheduler.auto_throttle: false), so background merges are not rate-limited.
  • Engine-generated ids, so a new document never needs a lookup of an existing one.
  • One shard, no replicas.
  • A 20 GiB heap, five times the largest indexing buffer, on the bundled JDK.
  • 32 bulk connections. 64 and 128 were no faster.
  • Security off on both, and machine learning off on Elasticsearch.

The configurations tried at 2 GiB of indexing RAM, in seconds to one segment: two runs each on the facet corpus, one on full text. Each starts from the bulk-load posture (refresh off during the feed, merge auto-throttling off) and changes one or two settings. A change was kept only if it beat the posture by more than 5%; the configuration used for each corpus is in bold.

Configuration Facet Full text
Elasticsearch, bulk-load posture 78.2 192.0
Elasticsearch, 1 s refresh 54.5 212.6
Elasticsearch, 512 MB translog flush threshold 56.8 214.0
OpenSearch, bulk-load posture 60.9 199.7
OpenSearch, 14 merge threads 57.7 206.7
OpenSearch, 1 s refresh 57.5 255.4
OpenSearch, 1 s refresh and 14 merge threads 59.9
OpenSearch, 10 GB translog flush threshold 200.4
OpenSearch, 10 GB translog flush threshold and 14 merge threads 195.5

OpenSearch’s two facet changes tie. 14 merge threads also won an earlier one-run pilot (58.9 s against 61.9). On full text, OpenSearch with a 10 GB translog threshold and 14 merge threads was 2% faster than the posture, under the 5% bar. OpenSearch’s defaults on this machine are 4 merge threads and a 512 MB translog threshold. Tried with the earlier feeder only, a 10 GB threshold made OpenSearch’s facet corpus slower (80.3 s against 66.3), and Elasticsearch with both a 1 s refresh and a 512 MB threshold was slower than with the 1 s refresh alone (66.5 s against 59.0).

What was not done:

  • No changes to thread pools, the garbage collector, or JVM options beyond the heap size.
  • No change to _source. In an earlier run of this benchmark (Elasticsearch 9.5.5, 2026-10-25), cutting _source down to what Luxir stores made Elasticsearch slower to one segment, not faster: 87 s against 80 on the facet corpus and 300 s against 184 on full text, at 2 GiB, medians of three runs. Those runs are in the raw results.
  • Translog durability left at the default (a sync per request). On tmpfs a sync costs next to nothing.
  • What is stored differs. Elasticsearch and OpenSearch store the whole source of every document. Luxir stores the text and keeps every other field as a column. That is part of why the indexes differ in size (table above).
  • The RAM levels are approximate. See Indexing RAM.
  • In RAM. On a real disk, write bandwidth and sync costs would add to every engine’s time, and more to whichever writes more bytes.
  • The engines are fed differently, each the way it is meant to be fed: one stream for Luxir, 32 bulk connections for the others.
  • One machine, one node, one shard, append-only. Updates and deletes, replicas, and multiple shards are not measured here.

From a checkout of SearchBench at indexing-munin-20261101, with the CPU set up as on the hardware page. The campaign driver is in the raw results, not in SearchBench itself. Its README covers what each phase is and what to adapt to another machine: the paths at the top of run.sh, its CPU sets, and how it pins clocks.

Build Luxir at the commit above with its default gcc-release preset, which compiles with -march=native. The Elasticsearch and OpenSearch downloads install their newest builds. The builds measured here may no longer be downloadable, but each phase’s *-build.json records their URLs and checksums.

Terminal window
scripts/prepare-corpus.sh standard # full text
scripts/prepare-corpus.sh facet
engines/download.sh elasticsearch-snapshot
engines/download.sh opensearch-snapshot
tar xzf indexing-munin-20261101.tar.gz
mkdir indexing
cp indexing-munin-20261101/{run.sh,ingest.py,summarize.py,FieldInfosDump.java,page-tables.py} indexing/
# set MAIN and PINNED_LUXIR at the top of indexing/run.sh, then:
for phase in campaign defaults campaign-facet defaults-facet; do indexing/run.sh $phase; done
python3 indexing/summarize.py # writes TABLES.md
python3 indexing/page-tables.py indexing/summary.json full=campaign,facet=campaign-facet

campaign and defaults also run the facet corpus, and those runs are what the earlier feeder held back. This page’s facet numbers come from campaign-facet and defaults-facet.