HumemAI iconHumemAI

Subproject

ArcadeDB

A multi-model database engine that keeps documents, graphs, vectors, and time series in one transactional engine, plus the Python package that runs that engine inside your process, benchmarked against the comparators in each category.

Illustration for ArcadeDB

Engine

One storage engine for documents, graphs, vectors, and time series.

ArcadeDB is a Java engine. It runs embedded inside a Java process or as a server behind HTTP, Bolt, Postgres, and Redis protocols, and almost everyone runs the server. HumemAI wraps the same engine for Python through JPype, so it also runs inside a Python process; that package is the Python section further down. It answers SQL, in its own dialect, and Cypher, so the two industry-standard query languages and the skills and tools around them carry over; the other single-stack multi-model engines on this page each have a language of their own, SurrealQL, AQL, and MongoDB's query language. Every benchmark table on this page measures both deployments of the same build, which is what the Mode column means. Upstream also lists key-value and full-text search among its models; this page does not measure them.

Most databases that call themselves multi-model are several engines behind one API. ArcadeDB is not. Everything it stores sits on the same pages, goes through the same write-ahead log (WAL), and commits in the same transaction, so a write that touches a document, an edge, and a vector index is one ACID transaction (atomic, consistent, isolated, and durable) instead of three that have to be coordinated.

The indexes work the same way. LSM trees (log-structured merge trees), full-text, geo, hash, and both dense and sparse vector indexes all commit in the same transaction as the records they index. Replication follows from that: Raft ships page changes from the shared log, so every model replicates without replication code per model.

Vectors are the one exception. The vector records are transactional, logged, and replicated like everything else, but the nearest-neighbour graph used to search them is not: it is built in the background and can be rebuilt. The data is the source of truth and the search structure catches up to it. That is more than a standalone vector store offers, and less than a fully transactional index would be.

Benchmarks

One machine, fixed limits, one job at a time.

Every number on this page was measured on one machine: an Intel Core i9-12900HK (14 cores, 6 performance and 8 efficient, 20 threads, 24 MiB L3), 64 GiB of memory, a Samsung 980 PRO 2 TB NVMe drive, Ubuntu 26.04 with Docker. Each run is pinned to the twelve hardware threads of the six performance cores (cpuset 0-11) and to a memory cap that depends on the size: 8 GiB up to 100k documents, 16 GiB at 1M, 32 GiB at 20M rows, 36 GiB at ten million vectors, with a JVM (Java virtual machine) heap of half the cap for the Java engines, except at ten million vectors, where every Java engine runs 24 of the 36 GiB because the vector build fills it. One job runs at a time; a served engine and its client share the same cap. Every comparator is pinned by image digest, and the build measured is listed under each table.

The method behind these numbers is documented in full at docs.humem.ai/arcadedb, which covers the protocol, the answer checking that compares every engine's result rather than only its speed, how to run a lane yourself, and how this page is generated and gated. The conditions below hold for every table on this page. A table adds its own beneath its caption where they differ. Latency columns are p50 and p99, the median and the 99th percentile of the query times, in milliseconds.

  • Every engine runs in Docker under an identical cpuset and memory cap, one job at a time, on the same host.
  • Peak memory is the largest amount an engine held in its own address space, added over every container a run used, and it leaves out the file cache the kernel keeps on the engine's behalf. That is the right number for engines that manage their own memory, and an undercount for engines that lean on the kernel instead, so compare it down one engine's rows rather than across engines that work differently.
  • Each printed cell is the median of 5 repetitions, with min and max carried alongside; nothing here is a single sample.
  • Defaults first. Where a default would make the comparison meaningless, it is equalized and the override is disclosed rather than hidden.
  • Comparators are pinned by sha256 image digest, not by a floating tag.
  • Durability is at each engine's default, and the defaults differ: ArcadeDB does not flush its write-ahead log at commit (txWalFlush=0), PostgreSQL and Neo4j fsync at every commit, and SQLite is the one comparator not at its default: it runs in WAL mode with synchronous=NORMAL, the common production setting, because its default rollback journal fsyncs twice per commit. On the write rows (document OLTP, TPC-C new-order, graph writes, the cross-model transaction) ArcadeDB's lead over the fsyncing servers is largely this default plus the absence of a network hop, not the engine. The next campaign matches every engine at the relaxed end instead: each one commits without waiting for the disk, which is what SQLite's setting above already means. Three engines have no setting for it, Neo4j, DuckDB, and LadybugDB, each measured rather than assumed, and each is named on the tables it appears on.

Benchmarks

Document OLTP and OLAP on TPC.

Most people arrive with tables. ArcadeDB stores them as documents and answers the same SQL, so documents come first. Every engine below answers the same questions in its own language: SQL where it has one, MongoDB's aggregation pipeline, SurrealQL for SurrealDB. The benchmarks are TPC-C and TPC-H, the long-standing transaction and analytics benchmarks. TPC-C is OLTP, online transaction processing: many small reads and writes. TPC-H is OLAP, online analytical processing: a few large scanning queries. Both run at SF1, where SF is the scale factor, the size of the generated data set.

TPC-C's new-order transaction (read a part, insert an order line, update stock) on the TPC-H tables, against DuckDB and PostgreSQL. PostgreSQL appears twice, at its image defaults and tuned with its buffer and work memory fitted to the container.

Document OLTP TPC-C new-order on the TPC-H SF1 tables
EngineModeSizenew-order p50 msnew-order p99 msOLTP ops/singest documents/singest total speak memory GiBdisk GiB
ArcadeDBembeddedTPC-H SF1 (6.0M line items)0.400.802,07341k150.96.640.62
ArcadeDBserverTPC-H SF1 (6.0M line items)1.152.0681932k192.99.220.65
ArangoDBserverTPC-H SF1 (6.0M line items)2.653.8636395k65.14.281.02
DuckDBembeddedTPC-H SF1 (6.0M line items)9.6618.6992.46M2.523.360.071
MongoDBserverTPC-H SF1 (6.0M line items)6.9517.1137123k50.37.130.77
PostgreSQLserverTPC-H SF1 (6.0M line items)2.395319364k17.13.350.79
SQLiteembeddedTPC-H SF1 (6.0M line items)0.0110.01920k334k18.63.360.39
SurrealDBembeddedTPC-H SF1 (6.0M line items)0.140.216,98511k550.67.332.51
SurrealDBserverTPC-H SF1 (6.0M line items)10.119.39022k283.38.890.67

↑ higher is better, ↓ lower is better; the best value in each column, within one size, is bold

Measured rows: benchmarks/experiments/results/runs_paper.csv

Exact builds measured
  • arcadedb 26.9.1-dev · 8d6af9475 (in-process)
  • arcadedb 26.9.1-dev · 8d6af9475 arcadedb-c25:8d6af9475
  • arangodb 3.12.11 arangodb@563cb2c07af0…
  • duckdb 1.5.5
  • mongo 8.2.12 mongo@41afd6e1183f…
  • postgres 17.10 postgres@de1e13ca9437…
  • sqlite 3.46.1
  • surrealdb 2.3.10
  • surrealdb v3.2.4 surrealdb/surrealdb@6a5002363ff5…
  • Q1 and Q6 are TPC-H's own query numbers. Q1 groups and aggregates the whole line-item table, so it measures a full scan; Q6 sums one column under a narrow filter, so it measures how well an engine skips what it does not need.
  • New-order is TPC-C's checkout transaction: it reads a customer and a warehouse, inserts an order with its line items, and updates stock, all in one transaction.
  • PostgreSQL's memory cell is not comparable to the other two, for the reason given under the table above: this column counts memory an engine holds in its own address space, and PostgreSQL holds its data in shared memory and the kernel's file cache instead. On this workload the effect is at its most extreme: nearly all of the figure shown is the Python client and almost none of it is the database.
  • Disk is what the workload left on disk, in GiB: the engine's writable layer plus its volumes after the cell, minus the same engine's empty footprint; for a served row that is the server container alone, the client is only the driver. It is read after the queries, so it includes anything querying wrote; a server reading is taken once two samples agree within 1%, an embedded reading once on the stopped container. A blank cell is a row measured before the disk reading existed (2026-08-14). Neo4j's value includes the transaction-log files it preallocates in 256 MiB steps, which is how Neo4j uses disk; turning that off would have slowed its writes, so it stays on.
  • Each repetition runs 1,000 new-order transactions; the p50 and p99 are over those, and OLTP ops/s is their rate.

TPC-H Q1 (scan every line item, group, and sum) and Q6 (filter a date range, sum one column) on the same documents. This is the workload ArcadeDB loses by the widest margin on the page.

Document OLAP TPC-H Q1 and Q6 at SF1
EngineModeSizeQ1 p50 msQ1 p99 msQ6 p50 msQ6 p99 msingest documents/singest total speak memory GiBdisk GiB
ArangoDBserverTPC-H SF1 (6.0M line items)12k13k1,7641,77395k65.14.281.02
DuckDBembeddedTPC-H SF1 (6.0M line items)15.516.97.238.692.46M2.523.360.071
MongoDBserverTPC-H SF1 (6.0M line items)9,8159,9381,1391,270123k50.37.130.77
PostgreSQLserverTPC-H SF1 (6.0M line items)330.71,074105.8109.6364k17.13.350.79
SQLiteembeddedTPC-H SF1 (6.0M line items)9,2339,2681,0271,033334k18.63.360.39
SurrealDBembeddedTPC-H SF1 (6.0M line items)102k104k11k11k11k550.67.332.51
SurrealDBserverTPC-H SF1 (6.0M line items)55k55k5,3775,56322k283.38.890.67

↑ higher is better, ↓ lower is better; the best value in each column, within one size, is bold

Measured rows: benchmarks/experiments/results/runs_paper.csv

Exact builds measured
  • arangodb 3.12.11 arangodb@563cb2c07af0…
  • duckdb 1.5.5
  • mongo 8.2.12 mongo@41afd6e1183f…
  • postgres 17.10 postgres@de1e13ca9437…
  • sqlite 3.46.1
  • surrealdb 2.3.10
  • surrealdb v3.2.4 surrealdb/surrealdb@6a5002363ff5…
  • Q1 and Q6 are TPC-H's own query numbers. Q1 groups and aggregates the whole line-item table, so it measures a full scan; Q6 sums one column under a narrow filter, so it measures how well an engine skips what it does not need.
  • New-order is TPC-C's checkout transaction: it reads a customer and a warehouse, inserts an order with its line items, and updates stock, all in one transaction.
  • PostgreSQL's memory cell is not comparable to the other two, for the reason given under the table above: this column counts memory an engine holds in its own address space, and PostgreSQL holds its data in shared memory and the kernel's file cache instead. On this workload the effect is at its most extreme: nearly all of the figure shown is the Python client and almost none of it is the database.
  • Disk is what the workload left on disk, in GiB: the engine's writable layer plus its volumes after the cell, minus the same engine's empty footprint; for a served row that is the server container alone, the client is only the driver. It is read after the queries, so it includes anything querying wrote; a server reading is taken once two samples agree within 1%, an embedded reading once on the stopped container. A blank cell is a row measured before the disk reading existed (2026-08-14). Neo4j's value includes the transaction-log files it preallocates in 256 MiB steps, which is how Neo4j uses disk; turning that off would have slowed its writes, so it stays on.
  • ArcadeDB has no row on this table. Answer checking built for the next campaign found that the two queries it ran here were not the questions the comparators answered: one of the five aggregates was missing from our Q1 text, and its Q6 excluded the boundary discount because the engine reads `>= 0.05` against a decimal literal as strictly greater. Both errors made its numbers faster than the truth, so they are withdrawn rather than shown with a caveat, and the next campaign measures them with every engine's answer compared.
  • Each repetition runs every query 100 times; the p50 and p99 are over those runs.
  • ArangoDB at TPC-H SF10 (60.0M line items): the analytics cell exceeded its 2 hour budget, the same budget every engine on this table had, on its first attempt and was not retried; there is no row.
  • MongoDB at TPC-H SF10 (60.0M line items): the analytics cell exceeded its 2 hour budget, the same budget every engine on this table had, on its first attempt and was not retried; there is no row.
  • SQLite at TPC-H SF10 (60.0M line items): the analytics cell exceeded its 2 hour budget, the same budget every engine on this table had, on its first attempt and was not retried; there is no row.
  • SurrealDB (embedded) at TPC-H SF10 (60.0M line items): the analytics cell failed inside its budget and was not retried, so there is no row. What it reported: container exited 137 with no output (cgroup OOM kill: raise the envelope or bound the harn
  • SurrealDB (server) at TPC-H SF10 (60.0M line items): the analytics cell failed inside its budget and was not retried, so there is no row. What it reported: websockets.exceptions.ConnectionClosedError: no close frame received or sent

Benchmarks

Graph OLTP and OLAP on LDBC.

Graph is what most people use ArcadeDB for. The benchmark is LDBC-SNB, the Linked Data Benchmark Council's Social Network Benchmark, a synthetic social network at two scale factors, SF1 and SF10. ArcadeDB, Neo4j, and LadybugDB answer the queries in Cypher, the most common graph query language; SurrealDB answers the same queries in SurrealQL. Neo4j is the most widely used graph database, so it is the comparator. LadybugDB is here because it is embedded and columnar, the closest comparator to running ArcadeDB inside your own process.

ArcadeDB embedded answers all four queries faster than Neo4j at both sizes. That is the strongest head-to-head result on this page.

Graph OLTP against Neo4j and LadybugDB on LDBC-SNB. Point is a single vertex lookup, 1-hop and 2-hop walk that many edges out from a starting person, and write inserts one edge.

Graph OLTP LDBC-SNB Interactive (SF1, SF10)
EngineModeSizepoint p50 mspoint p99 ms1-hop p50 ms1-hop p99 ms2-hop p50 ms2-hop p99 mswrite p50 mswrite p99 msingest vertices+edges/singest total speak memory GiBdisk GiB
ArcadeDBembeddedSF1 (11k people)0.490.950.451.190.706.420.560.8589k2.131.750.012
ArcadeDBserverSF1 (11k people)1.242.281.202.131.497.401.341.8326k7.453.330.068
ArangoDBserverSF1 (11k people)0.811.130.981.442.3116.30.811.0898k1.940.290.047
DuckPGQembeddedSF1 (11k people)0.250.300.931.212.243.7210.516.7203k0.940.140.036
FalkorDBserverSF1 (11k people)0.130.290.210.470.381.020.180.4141k4.690.170
LadybugDBembeddedSF1 (11k people)0.160.251.051.274.044.719.8219.2389k0.490.220.0055
MemgraphserverSF1 (11k people)0.140.270.150.270.200.760.150.2574k2.590.460.023
Neo4jserverSF1 (11k people)6.1612.05.739.294.757.227.5919.437k5.134.690.015
SurrealDBembeddedSF1 (11k people)0.0440.0500.709.4614.9185.60.160.1914k13.60.560.10
SurrealDBserverSF1 (11k people)0.110.300.190.570.503.0517.632.320k9.510.310.047
ArcadeDBembeddedSF10 (73k people)0.501.030.611.401.6717.50.660.9288k22.98.050.13
ArcadeDBserverSF10 (73k people)1.191.761.352.192.5019.01.411.8128k71.47.930.16
ArangoDBserverSF10 (73k people)0.851.191.052.065.8273.80.931.1792k21.80.780.88
DuckPGQembeddedSF10 (73k people)0.260.321.341.654.269.3910.617.9299k6.700.270.094
FalkorDBserverSF10 (73k people)0.120.520.230.470.742.680.180.3823k86.50.350
LadybugDBembeddedSF10 (73k people)0.170.381.664.165.5510.99.7711.7543k3.690.370.052
MemgraphserverSF10 (73k people)0.140.310.150.240.302.280.150.2572k27.80.630.24
Neo4jserverSF10 (73k people)7.4012.57.039.976.3110.58.5214.344k45.013.00.66
SurrealDBembeddedSF10 (73k people)0.0460.0541.2024.059.4800.40.180.2011k175.35.061.09
SurrealDBserverSF10 (73k people)0.170.670.411.441.2213.817.629.718k112.42.020.26

↑ higher is better, ↓ lower is better; the best value in each column, within one size, is bold

Measured rows: benchmarks/experiments/results/runs_paper.csv

Exact builds measured
  • arcadedb 26.9.1-dev · 8d6af9475 (in-process)
  • arcadedb 26.9.1-dev · 8d6af9475 arcadedb-c25:8d6af9475
  • arangodb 3.12.11 arangodb@563cb2c07af0…
  • duckpgq 1.5.4
  • falkordb v4.20.6 falkordb/falkordb@0a9fe4d1ee0b…
  • ladybugdb 0.19.1
  • memgraph 3.13.1 memgraph/memgraph@4710bee1ab5b…
  • neo4j 2026.07.1 neo4j@1ee8f6fa220f…
  • surrealdb 2.0.0
  • surrealdb v3.2.4 surrealdb/surrealdb@6a5002363ff5…
  • Every engine traverses the same persons-and-KNOWS projection, with edges stored in both directions.
  • Disk is what the workload left on disk, in GiB: the engine's writable layer plus its volumes after the cell, minus the same engine's empty footprint; for a served row that is the server container alone, the client is only the driver. It is read after the queries, so it includes anything querying wrote; a server reading is taken once two samples agree within 1%, an embedded reading once on the stopped container. A blank cell is a row measured before the disk reading existed (2026-08-14). Neo4j's value includes the transaction-log files it preallocates in 256 MiB steps, which is how Neo4j uses disk; turning that off would have slowed its writes, so it stays on.
  • Each repetition runs every read against a fresh set of start persons (SF1 (11k people): 500, SF10 (73k people): 200) and commits up to 1,000 writes; the p50 and p99 are over those.
  • Ingest paths: ArcadeDB embedded loads through the Java API (newVertex, newEdge) in 5,000-record transactions; served sends CREATE VERTEX and CREATE EDGE statements as sqlscript batches over HTTP; Neo4j and Memgraph UNWIND batches over bolt; FalkorDB the same UNWIND batches over the Redis protocol; LadybugDB COPY from CSV, its native bulk path; DuckPGQ Arrow INSERT SELECT into the persons and knows tables under a property graph.

Graph OLAP, queries over the whole graph, is a different job from single traversals, and ArcadeDB has a separate mechanism for it. A Graph Analytical View (GAV) is an in-memory copy of the graph, built once, that the planner uses for queries touching most of the vertices. It is optional, so the table below carries the same engine twice, with the view and without it, and the cost of building the view is measured rather than assumed.

At SF10 the view is worth 6.9x on top degree and about 2.6x on the other two, which is enough to move ArcadeDB from behind Neo4j to ahead of it on all three. LadybugDB wins all three regardless, because it stores the graph in columns, the same reason DuckDB wins the analytical queries above.

Graph OLAP LDBC-SNB, SF10
EngineModeSizeaverage friend age p50 msaverage friend age p99 msfriends in same city p50 msfriends in same city p99 msmost friends p50 msmost friends p99 msview build singest vertices+edges/singest total speak memory GiBdisk GiB
ArcadeDBembeddedSF1 (11k people)110.5118.4107.4118.737.563.488k2.162.700.012
ArcadeDB (GAV)embeddedSF1 (11k people)42.853.940.670.09.7717.81.0560k3.182.700.015
ArcadeDBserverSF1 (11k people)116.5123.6115.7122.738.448.325k7.573.330.068
ArcadeDB (GAV)serverSF1 (11k people)45.455.043.048.78.2217.11.0422k8.533.340.068
ArangoDBserverSF1 (11k people)532.1547.2544.2571.345.448.298k1.950.520.047
LadybugDBembeddedSF1 (11k people)9.1310.39.8210.90.861.45405k0.470.220.0045
Neo4jserverSF1 (11k people)54.564.155.964.131.447.137k5.104.610.014
SurrealDBembeddedSF1 (11k people)2,5782,5992,2452,2661,3371,54014k13.80.570.10
SurrealDBserverSF1 (11k people)700.1712.2697.0705.2104.1105.820k9.680.310.047
ArcadeDBembeddedSF10 (73k people)1,2011,2111,1711,191361.9375.788k22.98.110.13
ArcadeDB (GAV)embeddedSF10 (73k people)461.1468.9440.2465.452.461.52.0680k25.08.190.16
ArcadeDBserverSF10 (73k people)1,1911,2111,1951,219349.8363.028k70.97.980.17
ArcadeDB (GAV)serverSF10 (73k people)469.2478.1453.1472.456.865.62.0427k73.08.060.18
ArangoDBserverSF10 (73k people)7,8537,9478,1938,300427.5439.991k21.90.780.41
LadybugDBembeddedSF10 (73k people)54.457.861.963.52.693.18546k3.670.350.030
Neo4jserverSF10 (73k people)630.9643.9816.5833.7328.7344.444k45.413.00.66
SurrealDBserverSF10 (73k people)7,8347,9507,9318,006942.4956.818k112.82.140.26

↑ higher is better, ↓ lower is better; the best value in each column, within one size, is bold

Measured rows: benchmarks/experiments/results/runs_paper.csv

Exact builds measured
  • arcadedb 26.9.1-dev · 8d6af9475 (in-process)
  • arcadedb 26.9.1-dev · 8d6af9475 arcadedb-c25:8d6af9475
  • arangodb 3.12.11 arangodb@563cb2c07af0…
  • ladybugdb 0.19.1
  • neo4j 2026.07.1 neo4j@1ee8f6fa220f…
  • surrealdb 2.0.0
  • surrealdb v3.2.4 surrealdb/surrealdb@6a5002363ff5…
  • Three questions, each asked of the whole graph. Average friend age: for every city, the average age of the friends of the people who live there. Friends in same city: how many friendships connect two people in the same city. Most friends: which people have the highest number of friends. All three times are milliseconds.
  • The Graph Analytical View is a copy of the graph that ArcadeDB builds in memory, laid out for questions that sweep the whole graph rather than follow a few links. It is built once, before any query is timed, and the view build column is what that took.
  • The two rows labelled ArcadeDB (embedded) are the same engine on the same data, differing only in whether that view is built. Both return identical answers.
  • The benefit is uneven, and the three queries show why. Top degree gains most because it only walks adjacency. The other two read a property from the far end of every edge traversed, and that lookup costs the same either way, so it comes to dominate once the traversal itself is cheap.
  • Disk is what the workload left on disk, in GiB: the engine's writable layer plus its volumes after the cell, minus the same engine's empty footprint; for a served row that is the server container alone, the client is only the driver. It is read after the queries, so it includes anything querying wrote; a server reading is taken once two samples agree within 1%, an embedded reading once on the stopped container. A blank cell is a row measured before the disk reading existed (2026-08-14). Neo4j's value includes the transaction-log files it preallocates in 256 MiB steps, which is how Neo4j uses disk; turning that off would have slowed its writes, so it stays on.
  • Each repetition runs every query 100 times; the p50 and p99 are over those runs.
  • SurrealDB (embedded) at SF10 (73k people): the analytics cell exceeded its 2 hour budget, the same budget every engine on this table had, on its first attempt and was not retried; there is no row.
  • SurrealDB (embedded) at SF1, full network (3.2M vertices, 13.8M edges): the analytics cell failed inside its budget and was not retried, so there is no row. What it reported: PHASE build-messages-start scale=sf1full msg_limit=0 person_limit=0
  • Ingest paths: ArcadeDB embedded loads through the Java API (newVertex, newEdge) in 5,000-record transactions; served sends CREATE VERTEX and CREATE EDGE statements as sqlscript batches over HTTP; Neo4j and Memgraph UNWIND batches over bolt; FalkorDB the same UNWIND batches over the Redis protocol; LadybugDB COPY from CSV, its native bulk path; DuckPGQ Arrow INSERT SELECT into the persons and knows tables under a property graph.

Benchmarks

Vector search, dense and sparse.

Vector search is the newest of these models and the one with the most comparators, so it gets the most detail: three corpora, two kinds of vector, and recall reported beside every latency. It is also a workload ArcadeDB does not win, and the tables show that. Every engine on the dense table today indexes with HNSW (Hierarchical Navigable Small World), a graph of near neighbours that a query walks; an engine with a different index says so in the table's notes. Every corpus below is real and published, not generated, which matters most for sparse search.

A SPLADE (Sparse Lexical And Expansion model) vector stores one weight per word in the vocabulary, and nearly every weight is zero, so a search only has to look at the few words a query uses. Those words cost very different amounts. A common word has to be checked against a huge number of documents; a rare one against almost none.

Real writing has a few words that appear everywhere and a long tail that appear almost nowhere, so some queries are far more expensive than others. Generated data spreads words out evenly, which removes the expensive case and makes any approximate index look better than it is. Dense search uses published image descriptors for the same reason.

CorpusVectorsDimensionsUsed for
SPLADE over MS MARCO100k, 1M, 8.84M30,109every sparse row
SIFT1M128the smaller dense size
DEEP9.99M96the ten-million dense size

MS MARCO is a public search-relevance corpus, and the sparse vectors come from Big-ANN, a benchmark challenge for approximate nearest-neighbour search. SIFT and DEEP are standard image-descriptor sets.

Two of those sizes are ceilings, not choices. 8.84 million is the entire Big-ANN sparse base set, so no larger sparse size exists, and DEEP's 9.99 million is the ten-million slice that ships with exact ground truth. The dense corpora are also narrow beside a modern text embedding, which runs 768 to 3072 numbers wide against SIFT's 128 and DEEP's 96. That width is fixed by how the descriptors were produced, and these sets are used anyway because they publish exact nearest neighbours at ten million vectors, which is what makes recall comparable across engines. It does mean the dense rows describe index behaviour at 96 and 128 dimensions, and a 1536-dimension embedding is a different question.

The index settings are matched rather than left to each vendor's defaults. Dense search builds HNSW at ef_construction 100 and queries at ef_search 100 everywhere, with a graph degree of 16 neighbours per node. ArcadeDB spells that maxConnections 32, because its bound is per layer while hnswlib-style engines double theirs at the base layer, so matching the number instead of the degree would have given ArcadeDB twice the graph. Sparse search has no equivalent setting, so every engine runs its own defaults there and each table states what precision the index stores. Every query asks for the top 10, and recall@10 is the share of those 10 that are true nearest neighbours; the 10 is the k in recall@k.

Recall is reported next to every latency. A vector benchmark without a quality number is not a comparison, since any engine can be made faster by searching less thoroughly, and the engines here sit at different points on that trade.

Every engine in every table below runs under the same limits, one at a time on one machine, and each cell is the median of five repetitions. The exact build measured sits under each table. Two words recur: cold is the first timed pass after the index is built, and warm is a second pass over the same queries.

Every engine gets a settle step before any query is timed, the one-off operation that leaves it answering from a finished index rather than a half-built one: Elasticsearch refreshes and force-merges to a single segment, Milvus flushes and loads, Qdrant waits until the collection reports green, and ArcadeDB compacts its LSM segments.

Each comparator's sparse precision is read from its own documentation and source at the version measured. Qdrant and Milvus keep sparse weights at full 32-bit precision. Elasticsearch keeps about 9 significant bits, which its documentation puts at roughly 0.4% relative error, the lossiest of the engines here. ArcadeDB stores int8 posting weights by default and can store fp32; both are measured.

Sparse search on real SPLADE vectors against Elasticsearch, Milvus, and Qdrant. ArcadeDB appears four times, in both deployments and at both precisions: int8 posting weights are its default and fp32 is the ablation.

Sparse vector search Big-ANN'23 Sparse (real SPLADE over MS MARCO)
EngineModePrecisionSizecold p50 mscold p99 mswarm p50 mswarm p99 msgainrecall@10ingest+index vectors/singest+index total speak memory GiBdisk GiB
ArcadeDBembeddedint8100k vectors4.059.513.678.211.110.9911k9.243.000.15
ArcadeDBembeddedfp32100k vectors3.9710.43.707.961.20111k8.713.150.19
ArcadeDBserverint8100k vectors4.9312.04.498.841.170.993,95725.34.360.19
ArcadeDBserverfp32100k vectors5.0210.74.328.351.2113,86325.94.370.23
Elasticsearchserver~9-bit100k vectors3.426.872.824.591.311.002,86934.94.780.094
Milvusserverfp32100k vectors3.017.682.957.661.0315,53918.11.320.27
pgvectorserverfp32100k vectors1.011.800.841.591.200.821,51965.80.140.46
Qdrantserverfp32100k vectors0.881.310.891.120.98112k8.440.690.25
ArcadeDBembeddedint81M vectors11.436.511.035.71.090.9911k94.58.361.48
ArcadeDBembeddedfp321M vectors11.435.811.135.31.08111k94.58.421.83
ArcadeDBserverint81M vectors12.939.712.136.91.140.994,023248.68.621.50
ArcadeDBserverfp321M vectors12.839.111.836.71.1513,967252.18.651.86
Elasticsearchserver~9-bit1M vectors10.021.69.0820.61.141.002,981335.59.050.90
Milvusserverfp321M vectors9.4036.711.140.90.7518,331120.05.534.62
pgvectorserverfp321M vectors1.302.591.252.551.060.771,498667.50.313.62
Qdrantserverfp321M vectors2.854.152.874.200.99111k94.82.271.45
ArcadeDBembeddedint88.84M vectors86.1307.584.4303.61.040.999,614919.716.813.1
ArcadeDBembeddedfp328.84M vectors86.8320.985.2314.111.009,714910.216.816.2
ArcadeDBserverint88.84M vectors87.8309.985.0308.61.040.993,8262,31116.913.1
ArcadeDBserverfp328.84M vectors89.8311.686.4320.01.061.003,8432,30116.916.3
Elasticsearchserver~9-bit8.84M vectors53.6127.855.2131.711.002,8273,12817.48.01
Milvusserverfp328.84M vectors37.6137.236.1142.91.041.008,5661,03216.248.7
pgvectorserverfp328.84M vectors2.544.991.282.941.830.721,5025,8870.3929.2
Qdrantserverfp328.84M vectors15.824.416.324.711.004,8581,82014.712.3

↑ higher is better, ↓ lower is better; the best value in each column, within one size, is bold

Measured rows: benchmarks/experiments/results/runs_paper.csv

Exact builds measured
  • arcadedb 26.9.1-dev · 8d6af9475 (in-process)
  • arcadedb 26.9.1-dev · 8d6af9475 arcadedb-c25:8d6af9475
  • elasticsearch 9.4.1 docker.elastic.co/elasticsearch/elasticsearch@268f65f1b32e…
  • milvus v2.6.13 milvusdb/milvus@0ea40276f811…
  • pgvector 0.8.6 pgvector/pgvector@dca0d688bbb3…
  • qdrant v1.18.2 qdrant/qdrant@75eab8c4ba42…
  • Recall is reported beside every latency: ArcadeDB quantizes posting weights to int8 by default, so a latency number without its recall is not comparable.
  • ingest+index total s is one timer around inserting the documents and building the index; the two are not timed separately (Qdrant builds its index while ingesting, so the split is not defined there). ingest+index vectors/s divides the document count by it.
  • Elasticsearch runs with index-time token pruning disabled. Its 9.x default prunes on thresholds tuned for a different model's vectors and costs recall on this corpus, which would have printed a quality gap belonging to that default rather than to the engine, and printed it in our favour.
  • Cold is the first timed pass after the index is built; warm is the same engine run again over an index it has already read, and gain is cold over warm. Here a second pass changes little and the order of the table is the same either way. The dense table below is not like this: there ArcadeDB gains the most on a second pass and the order depends on which pass you time.
  • ArcadeDB's server takes longer to build than its embedded deployment, and that gap is loading the data, not building the index. Both run the same index code. The embedded one is handed the numbers directly, because the database is running inside the same program. The server has to be sent them, and the only way in is a written-out INSERT statement: a document here has about 127 non-zero weights, so each one arrives as roughly 254 numbers spelled out as text, which the server then has to read back into numbers.
  • Disk is what the workload left on disk, in GiB: the engine's writable layer plus its volumes after the cell, minus the same engine's empty footprint; for a served row that is the server container alone, the client is only the driver. It is read after the queries, so it includes anything querying wrote; a server reading is taken once two samples agree within 1%, an embedded reading once on the stopped container. A blank cell is a row measured before the disk reading existed (2026-08-14). Neo4j's value includes the transaction-log files it preallocates in 256 MiB steps, which is how Neo4j uses disk; turning that off would have slowed its writes, so it stays on.
  • Cold p50 and p99 are the first timed pass after the build, median of five builds. Warm and gain come from a separate run of the same arms: one build per engine, then five more passes over a different half of the query set, so a warm number cannot be explained by the engine having already answered that exact query; gain is that run's cold over its warm.
  • Each pass answers 1,000 queries; cold is the first pass after the build and warm pools the four passes after it, over five builds.
  • Ingest paths: ArcadeDB embedded loads through the Java API (newDocument with int and float arrays) in 500-record transactions, then COMPACT INDEX; served sends INSERT statements as sqlscript batches over HTTP; Qdrant, Milvus, and Elasticsearch upsert or bulk-index in batches, then settle (Elasticsearch refresh and force-merge, Milvus flush and load).

Warm is the same pair measured again on a separate one-build run. The largest gain by any engine is 1.15x at a million and 1.83x at 8.84 million, and at the larger size no engine gains more than 83%, and that gain is pgvector's. Compare the dense table below, where ArcadeDB alone gains about 8x on a second pass: that comes from how the two index structures reach their data, not from how the runs were made.

Dense search at two sizes, with both deployments of ArcadeDB: one build, then five passes, cold the first and warm the rest. Read latency against recall: Chroma is the fastest engine at both sizes and also returns the fewest true neighbours, so the summary figure below compares ArcadeDB against Qdrant, the fastest engine whose recall is at least ArcadeDB's.

Dense vector search DEEP-10M (deep-image-96-angular) and SIFT-1M
EngineModePrecisionSizecold p50 mscold p99 mswarm p50 mswarm p99 msrecall@10ingest+index vectors/singest+index total speak memory GiBdisk GiB
ArcadeDBembeddedint81M vectors1.352.250.841.140.984,357229.58.250.93
ArcadeDBembeddedfp321M vectors1.954.170.781.130.996,447155.17.330.80
ArcadeDBserverint81M vectors2.353.631.742.190.983,370296.88.300.97
ArcadeDBserverfp321M vectors2.915.581.732.160.994,330231.07.480.81
ArangoDBserverfp321M vectors5.897.615.827.530.9911k93.31.811.04
Chromaembeddedfp321M vectors0.640.790.650.870.976,639150.61.150.78
DuckDB VSSembeddedfp321M vectors1.783.871.802.100.9817k59.42.920.77
LanceDBembeddedint81M vectors1.483.091.481.900.9648k20.91.950.85
Milvusserverfp321M vectors0.991.530.971.310.975,546180.33.611.63
Neo4jserverfp321M vectors11.816.310.712.40.995,730174.510.03.10
pgvectorserverfp321M vectors1.822.221.792.190.996,502153.80.0522.56
Qdrantserverfp321M vectors1.081.411.131.461.0023k43.50.670.59
sqlite-vecembeddedfp321M vectors126.4128.8126.4128.91.00127k7.860.0390.50
SurrealDBserverfp321M vectors1,5034,1083.985.380.986,338157.813.10.74
ArcadeDBembeddedint89.99M vectors2.864.600.811.220.942,6523,76728.38.57
ArcadeDBembeddedfp329.99M vectors8.7488.61.041.580.954,3042,32128.37.56
ArcadeDBserverint89.99M vectors3.978.532.002.560.942,0814,80128.48.59
ArcadeDBserverfp329.99M vectors8.69100.02.142.780.953,0163,31228.47.60
Chromaembeddedfp329.99M vectors0.700.950.720.920.932,5913,85612.76.59
DuckDB VSSembeddedfp329.99M vectors2.756.852.743.540.9312k836.425.28.08
LanceDBembeddedint89.99M vectors442.9462.8437.9455.00.9344k225.913.36.82
Milvusserverint89.99M vectors1.331.591.301.540.967,0941,40812.921.9
Milvusserverfp329.99M vectors1.391.621.371.620.987,0971,40815.529.5
Neo4jserverfp329.99M vectors85.0147.883.5142.10.975,5271,80837.716.2
pgvectorserverfp329.99M vectors2.783.652.583.290.955,1151,9533.6216.8
Qdrantserverint89.99M vectors1.131.571.131.450.9822k461.46.935.35
Qdrantserverfp329.99M vectors1.261.681.251.600.9817k598.85.944.42
sqlite-vecembeddedint89.99M vectors679.2687.6678.0686.40.92171k58.33.651.12
sqlite-vecembeddedfp329.99M vectors928.3947.3928.1944.31138k72.23.603.80

↑ higher is better, ↓ lower is better; the best value in each column, within one size, is bold

Measured rows: benchmarks/experiments/results/runs_paper.csv, benchmarks/experiments/results/dense_mp5_<pin or 2681>

Exact builds measured
  • arcadedb 26.9.1-dev · 8d6af9475
  • arangodb 3.12.11
  • chroma 1.5.9
  • duckdb vss 1.5.5
  • lancedb 0.37.1
  • milvus v2.6.13
  • neo4j 2026.07.1
  • pgvector 26.9.1
  • qdrant 1.18.2
  • sqlite-vec 0.1.9
  • surrealdb 26.9.1
  • ingest+index total s is one timer around inserting the vectors and building the index; the two are not timed separately (Qdrant and Chroma build the index while ingesting, so the split is not defined there). ingest+index vectors/s divides the vector count by it.
  • ArcadeDB's maxConnections is a Vamana per-layer degree, not hnswlib's M. Matching the parameter names would compare a half-degree graph against a full-degree one, so the graphs are matched by effect instead.
  • ArangoDB's vector index is FAISS IVF (inverted lists over trained centroids), not HNSW, so the degree match above does not apply to it; its rows record nLists (about the square root of the corpus) and nProbe (an eighth of the lists) instead.
  • Cold is the first timed pass after the index is built; warm is a repeat of the same query set. ArcadeDB pages its index off disk while most comparators are resident from build, so read the two columns against each other rather than either alone.
  • Milvus's dense rows run with segments sealed at 50% of the maximum segment size (the image default is 12%), so a 10M ingest lands directly in the 6 to 8 segment layout that Milvus's own compaction otherwise reaches at an unpredictable moment; without it, runs landed on many small segments and read slower with higher recall, at a moment the engine chose. One line changed from the image's configuration; sparse rows are at the default.
  • ArcadeDB fp32 rows at 9.99M carry graphBuildCacheSize pinned to the corpus size (9,990,000) on both deployments, a user decision so the served build is not left on the wrong side of the engine's cache knee (issue #7146; the budget 26.10.1 makes the default). INT8 rows run this engine's default of 100,000. Comparators have no equivalent setting.
  • Disk is what the workload left on disk, in GiB: the engine's writable layer plus its volumes after the cell, minus the same engine's empty footprint; for a served row that is the server container alone, the client is only the driver. It is read after the queries, so it includes anything querying wrote; a server reading is taken once two samples agree within 1%, an embedded reading once on the stopped container. A blank cell is a row measured before the disk reading existed (2026-08-14). Neo4j's value includes the transaction-log files it preallocates in 256 MiB steps, which is how Neo4j uses disk; turning that off would have slowed its writes, so it stays on.
  • Each pass answers 1,000 queries; cold is the first pass after the build and warm pools the four passes after it, over five builds.
  • SurrealDB (embedded) at 9.99M vectors: the search cell exceeded its 8 hour budget, the same budget every engine on this table had, on its first attempt and was not retried; there is no row.
  • SurrealDB (server) at 9.99M vectors: the search cell failed inside its budget and was not retried, so there is no row. What it reported: websockets.exceptions.ConnectionClosedError: no close frame received or sent
  • SurrealDB (embedded) at 1M vectors: the search cell exceeded its 4 hour budget, the same budget every engine on this table had, on its first attempt and was not retried; there is no row.
  • ArangoDB at 9.99M vectors is withheld: its search answered with a recall@10 of 0.0001 across 5 repetition(s), which is not a measurement of search but of a broken index, so its latency is not printed beside engines answering correctly. The cause is investigated on the bench host before anything is claimed about it (BUGS F55).
  • Ingest paths: ArcadeDB embedded issues INSERT per vector in 10,000-row transactions through the Python package, then CREATE INDEX ... LSM_VECTOR; served sends 500-statement sqlscript batches over HTTP with each vector spelled out as text, then the same CREATE INDEX; Chroma add() in batches of 5,000; LanceDB an Arrow table then create_index; Qdrant and Milvus upsert in batches; DuckDB VSS and sqlite-vec executemany.

Benchmarks

Time series on TSBS.

Time series on TSBS, the Time Series Benchmark Suite: ingest, the newest reading of one host, and a twelve-hour aggregate across all hosts.

Time series against QuestDB and DuckDB on TSBS. ArcadeDB appears twice per deployment because it has two ways to store this data: the native `TIMESERIES` type keeps the points of one series together in time order, and the document path stores each reading as an ordinary document, which is what you get if you do not know the native type exists. The gap between them is what the native type is worth: 46 times the ingest rate and a twelve-hour aggregate 68 times faster, against a slightly slower lookup of the newest reading.

Time series TSBS cpu-only, 2,592,000 points
EngineModeSizenewest reading p50 msnewest reading p99 ms12h aggregate p50 ms12h aggregate p99 msingest points/singest total speak memory GiBdisk GiB
ArcadeDB (native time series)embedded2.59M points0.431.6530.445.31.96M1.325.290.088
ArcadeDB (document path)embedded2.59M points0.572.321,5051,53641k63.16.190.25
ArcadeDB (document path)server2.59M points1.303.431,5931,63055k47.16.480.29
ArcadeDB (native time series)server2.59M points10.441.03.8161.4582k4.453.940.12
DuckDBembedded2.59M points1.303.984.417.851.88M1.380.950.011
MongoDBserver2.59M points0.400.8311.011.3188k13.80.960.0013
QuestDBserver2.59M points0.602.911.814.341.31M1.991.320.16
SQLiteembedded2.59M points0.00220.0080146.1150.81.03M2.510.690.11
TimescaleDBserver2.59M points0.0500.3844.045.8519k50.830.63

↑ higher is better, ↓ lower is better; the best value in each column, within one size, is bold

Measured rows: benchmarks/experiments/results/runs_paper.csv

Exact builds measured
  • arcadedb 26.9.1-dev · 8d6af9475
  • duckdb 1.5.5
  • mongodb 8.2.12
  • questdb 9.1.1
  • sqlite 3.46.1
  • timescaledb 2.28.3
  • No engine settles inside the ingest timer. QuestDB's WAL apply runs after the clock stops, and the newest-reading query is asked unbounded on every engine, so the unsealed tail a scan walks costs the same everywhere. Sealing the write buffer makes the aggregation faster and the last-point query slower, and settling only ours would have been a one-sided advantage. Rows that record a settle time did that settling after the timer stopped.
  • One tag and three fields, not the ten and ten the TSBS cpu schema defines. The reduction is applied identically to every engine, so the comparison is internally fair, but it is not the full benchmark; a matched full-schema run costs more on ingest and answers the newest reading faster.
  • Newest reading means the most recent value each sensor has reported, which is what a monitoring dashboard asks for when it shows the current state of a fleet. TSBS calls this query last-point. It is run without a time bound on every engine.
  • Disk is what the workload left on disk, in GiB: the engine's writable layer plus its volumes after the cell, minus the same engine's empty footprint; for a served row that is the server container alone, the client is only the driver. It is read after the queries, so it includes anything querying wrote; a server reading is taken once two samples agree within 1%, an embedded reading once on the stopped container. A blank cell is a row measured before the disk reading existed (2026-08-14). Neo4j's value includes the transaction-log files it preallocates in 256 MiB steps, which is how Neo4j uses disk; turning that off would have slowed its writes, so it stays on.
  • Each repetition runs every query 100 times; the p50 and p99 are over those runs.
  • Ingest paths: the ArcadeDB document path issues INSERT per point through the Python package (embedded) or sqlscript batches over HTTP (served); the native TIMESERIES type takes columns through the async executor's append_samples (embedded) or InfluxDB line protocol at /api/v1/ts/{db}/write (served); DuckDB inserts an Arrow table; QuestDB takes line protocol over TCP.

Benchmarks

One transaction across a vector, a graph edge, and a document.

One operation touches three models: a vector search finds a record, a graph hop expands it, and a document update records the result. ArcadeDB does that in one transaction, and so do the other single engines on the table: SurrealDB, embedded and served, Neo4j with its vector index, and PostgreSQL + pgvector + AGE. The comparator is a composed stack, Qdrant for the vectors and Neo4j for the graph, and against it the number that matters is not the latency but what a failure part-way through leaves behind.

One operation writes both stores in turn: the graph database takes the update first, and the vector store gets its copy second. We raise an error in the gap between the two, a gap that exists in any design where two systems acknowledge separately. The composed stack is left half-updated. The graph database has kept a write the vector store never received, the two disagree about the same records from then on, and neither knows anything is wrong.

That damage stays. Nothing goes back to look for it, so the only records that recover are the ones a later write happens to touch. The corruption is partial and silent: you cannot find it by spot-checking a few records. One engine wrapping the same work in one transaction undoes all of it, leaving the counters where the completed operations left them.

We interrupted 200 trials against each system. The composed stack was left half-updated in all 200. Every single engine on the table, each wrapping the work in one transaction, was left half-updated in none. The count matters: with five trials and no failures you cannot rule out a failure rate near one in two, and two hundred puts it under two in a hundred. Before each interruption the two stores are checked and agree exactly, which is what makes a disagreement afterwards mean something.

This is not a criticism of Qdrant or Neo4j. Both do what they are asked, correctly, every time. The gap is between them, and it belongs to any design in which two systems acknowledge separately; the single engines on the table close it by holding all three models under one transaction. Nor does it mean two stores cannot be made to work: a team would add machinery to repair the divergence afterwards, replaying the missing write until both sides agree. The experiment measures what that machinery buys you, because one engine with one transaction does not need it.

The cross-model operation: a vector hit expands over graph edges and updates a document. ArcadeDB, SurrealDB, Neo4j, and PostgreSQL + pgvector + AGE each do it in one transaction; the composed stack has no transaction spanning its two engines.

Cross-model transaction Vector hit to graph traversal to document update, in one transaction
EngineModeSizep50 msp99 msingest+index vertices+edges/singest+index total speak memory GiBdisk GiB
ArcadeDB (one transaction)embedded50k products2.035.7719k10.41.920.038
ArcadeDB (one transaction)server50k products8.8713.214k13.94.050.099
ArangoDBserver50k products5.257.0943k4.630.570.14
Neo4j (vector index)server50k products17.92814k14.66.820.019
PostgreSQL + pgvector + AGEserver50k products9.0515.22,32086.20.110.16
Qdrant + Neo4j (no shared transaction)server50k products20.736.034k5.936.810.0098
SurrealDBembedded50k products8.299.63629318.20.820.24
SurrealDBserver50k products16.124.216k12.20.910.48

↑ higher is better, ↓ lower is better; the best value in each column, within one size, is bold

Measured rows: benchmarks/experiments/results/runs_paper.csv

Exact builds measured
  • arcadedb 26.9.1-dev · 8d6af9475 (in-process)
  • arcadedb 26.9.1-dev · 8d6af9475 arcadedb-c25:8d6af9475
  • arangodb 3.12.11 arangodb@563cb2c07af0…
  • neo4j 2026.07.1 neo4j@1ee8f6fa220f…
  • PostgreSQL 17.11 + pgvector 0.8.6 + age 1.7.0 dbbench:pg-age
  • surrealdb 2.0.0
  • surrealdb v3.2.4 surrealdb/surrealdb@6a5002363ff5…
  • Atomic means all or nothing: the whole update happens, or none of it does, with no state in between that anyone can observe. One engine can promise that across a vector, a graph edge, and a document because they share a transaction. Qdrant and Neo4j cannot promise it to each other, because nothing spans the two.
  • So the interesting result here is not the speed. It is what a crash halfway through leaves behind. The raw data records, for each run, whether an interrupted write left the two stores disagreeing, and whether they still disagreed after restarting. That is what this comparison exists to show.
  • Read the times with one caveat, which cuts against ArcadeDB. Every engine on this table writes to disk except the composed stack's vector half: Qdrant runs in memory (:memory:) until its own re-run, so part of why the composed stack's queries answer as they do is that half of it never touches a disk. The all-or-nothing result above does not depend on this, since a half-finished update is visible in memory just as it is on disk, but the millisecond columns do.
  • Because the composed stack's Qdrant half runs in memory, its disk value is Neo4j's alone. SurrealDB embedded runs on the SDK's SurrealKV store on disk and SurrealDB server on RocksDB, and each has its own disk reading.
  • Disk is what the workload left on disk, in GiB: the engine's writable layer plus its volumes after the cell, minus the same engine's empty footprint; for a served row that is the server container alone, the client is only the driver. It is read after the queries, so it includes anything querying wrote; a server reading is taken once two samples agree within 1%, an embedded reading once on the stopped container. A blank cell is a row measured before the disk reading existed (2026-08-14). Neo4j's value includes the transaction-log files it preallocates in 256 MiB steps, which is how Neo4j uses disk; turning that off would have slowed its writes, so it stays on.
  • Each repetition runs the transaction 300 times; the p50 and p99 are over those.
  • ingest+index total s is one timer around loading the vertices and edges and creating the vector index. Ingest paths: ArcadeDB embedded loads with the Python package's graph_batch (5,000 records per commit, vertices then edges) and then CREATE INDEX ... LSM_VECTOR; served sends CREATE VERTEX and CREATE EDGE batches as sqlscript over HTTP, then the same CREATE INDEX; SurrealDB inserts through its Python client into an in-memory database; the composed stack upserts vectors into Qdrant and loads the graph into Neo4j with UNWIND.

The same operation interrupted mid-way, 40 trials per run over five runs. The composed stack is left torn in 40 of 40 trials; every single engine, ArcadeDB embedded and server, SurrealDB embedded and server, Neo4j with its vector index, and PostgreSQL + pgvector + AGE, in 0 of 40, because the whole operation is one transaction that commits or does not.

Cross-model transaction: what survives a crash The same vector-graph-document operation, killed mid-way, then inspected
EngineModeSizetrialstorn results
ArcadeDB (one transaction)embedded50k products400
ArcadeDB (one transaction)server50k products400
ArangoDBserver50k products400
Neo4j (vector index)server50k products400
PostgreSQL + pgvector + AGEserver50k products400
Qdrant + Neo4j (no shared transaction)server50k products4040
SurrealDBembedded50k products400
SurrealDBserver50k products400

↑ higher is better, ↓ lower is better; the best value in each column, within one size, is bold

Measured rows: benchmarks/experiments/results/runs_paper.csv

Exact builds measured
  • arcadedb 26.9.1-dev · 8d6af9475 (in-process)
  • arcadedb 26.9.1-dev · 8d6af9475 arcadedb-c25:8d6af9475
  • arangodb 3.12.11 arangodb@563cb2c07af0…
  • neo4j 2026.07.1 neo4j@1ee8f6fa220f…
  • PostgreSQL 17.11 + pgvector 0.8.6 + age 1.7.0 dbbench:pg-age
  • surrealdb 2.0.0
  • surrealdb v3.2.4 surrealdb/surrealdb@6a5002363ff5…
  • A trial writes the three products, kills the process between them, reopens, and checks whether every product is present or none. Torn means some but not all: the counts the page's E2 prose quotes are these.
Latency of a vector to graph to document operation, single engine against a composed stack
The cross-model operation: one engine doing it in a single transaction against a composed stack that cannot.

Benchmarks

Every metric on this page in one figure.

Every metric above appears here as a ratio against the strongest comparator on that row, first pass and repeat pass side by side. Rows follow this page: documents, graph, vectors, time series, and the cross-model transaction, each table in the order shown above and its columns in order, ingest included.

A row marked first pass only has no bar on the right because it is measured once: ingest, writes, transactions, and the 100k sparse tier have no repeat over a warm index, which is defined only for reads. A row marked comparator below resolution has a comparator latency recorded to too few decimals for a ratio.

Each row picks its comparator on the first pass, the fastest engine or the highest throughput, and on the vector rows the fastest engine whose recall is at least ArcadeDB's; the same engine is then read on the repeat pass. At ten million vectors that is 8.75 ms for ArcadeDB against Qdrant's 1.26 and Chroma's 0.70. The dense bar divides by Qdrant rather than Chroma, which the table shows is faster, because Chroma returns 93.4% of the true neighbours where ArcadeDB returns 95.3%.

The repeat pass matters most for ArcadeDB: run the same queries again and ArcadeDB answers in 1.04 ms, because it pages its index off disk and the second pass finds it resident, while Qdrant moves to 1.25 and Chroma to 0.72. So the dense rows flip between the panels, a 1.2x win, with the comparators where they were. The graph rows move too, in both directions, because Neo4j and LadybugDB gain on a repeat pass as well.

On the first pass ArcadeDB wins the OLTP, write, and cross-model rows, matches DuckDB on time-series ingest, and loses every scan-, bulk-, and search-bound row, vector search included. The cross-model row is measured against SurrealDB embedded, the fastest of the single engines here that also do the whole operation in one transaction, rather than the composed stack, which is slower and has no transaction spanning its two engines. Each row uses the comparator's own language: document OLTP is SQL against PostgreSQL, the graph rows are Cypher against LadybugDB or Neo4j, and TPC-H is SQL against DuckDB.

ArcadeDB latency against the best specialist engine at each corpus size
ArcadeDB embedded against the best comparator on every metric, log scale, with anything right of the line a win. The left panel is the first timed pass after the index is built, and the right panel is the same pair measured again.

Embedded

The engine as a Python package.

ArcadeDB is a Java engine, and that is friction for Python work: a separate runtime to install, a service to start, and a network hop between your code and your data. The Python package removes all three. It ships the upstream engine unmodified, with a bundled Java runtime and platform wheels, so uv add arcadedb-embedded or pip install arcadedb-embedded is the whole setup and the database runs inside your process.

It is a full API, not a launcher. Transactions and lifecycle, schema and graph helpers, bulk ingest, import and export, and the vector features are all exposed and tested, with the example suite run in CI on every change.

The engine and the Python package are maintained together. Fixes and features found through the benchmarks above are filed and, where possible, contributed upstream. Both are Apache-2.0, and both maintainers, Arcade Data for the engine and HumemAI for this package, have said they will keep it that way.

What does the Python boundary cost? The engine runs at the same speed either way; what you pay for is handing results back. Against Java in the same process doing the same work, a vector search costs 1.04x and a 100k-document scan 1.75x.

The bigger number is inside Python. Asking for record objects is 15.1x slower than asking for columns over the same query, so which call you use, to_list or to_columns, matters more than the language boundary does. Check that before blaming the engine for a slow loop.

The same query answered from Java and from Python, and the three ways Python can ask for the results: `to_columns`, `to_json_list`, and `to_list`.

What Python costs Same engine, same query, called from Java and from Python
EngineSizetime msvs Java
Java, in processvector search1.521
Pythonvector search1.581.04
Java, in process100k-document scan145.31
Python, to_columns100k-document scan254.61.75
Python, to_json_list100k-document scan384.02.64
Python, to_list100k-document scan3,85726.6

↑ higher is better, ↓ lower is better; the best value in each column, within one size, is bold

Measured rows: benchmarks/python-bindings/jpype_overhead/results/mini_results.csv

  • The engine itself runs at the same speed either way. What Python is charged for is moving results across the boundary, which is why the vector search costs 1.04x and the scan 1.75x rather than anything scaling with the work the engine did.
  • The path you choose inside Python matters far more than the language boundary does. Asking for record objects is 15.1x slower than asking for columns over the same query, so the practical advice is to use the columnar or batched call for anything large.

Starting the engine is the other cost, and it is paid once per process, not per query. A cold process reaches its first database call in about 0.45 s, most of it the Java runtime starting; after that, opening and closing an empty database costs about 4.4 ms. The table below is one session each, open, do one thing, close, for every kind of data at four sizes, embedded and against the server.

Session cost by kind of data and size, embedded and against the server. The dense-vector rows at 1M and 10M show an engine defect, the first query after a write waited on a full index rebuild; it is filed as #7183, fixed upstream for 26.10.1, and a later campaign re-measures it.

Session cost, open to close synthetic, one structure per row
EngineModeSizeJVM start msfirst open mscold process msopen and close msone query msone write mswrite, then query mspeak memory GiB
Dense vectorsembedded10k185.0201.9506.58.4123.318.8555.82.73
Dense vectorsserver10k9.6536.521.2559.31.87
Documentsembedded10k178.1151.5439.74.745.2713.813.30.35
Documentsserver10k7.419.5217.419.20.37
Documents, ten indexesembedded10k180.1167.1466.511.011.840.139.50.78
Documents, ten indexesserver10k17.417.775.971.20.79
Empty databaseembedded10k181.6156.1449.34.444.2413.413.20.25
Empty databaseserver10k6.966.9015.717.30.30
Graphembedded10k191.7160.1465.96.488.6113.913.92.05
Graphserver10k8.6012.517.918.82.08
Sparse vectorsembedded10k181.2164.0458.75.315.8425.225.21.04
Sparse vectorsserver10k7.7211.130.928.71.16
Time seriesembedded10k179.8170.8475.818.911.223.822.22.00
Time seriesserver10k12.714.823.824.51.96
Dense vectorsembedded100k183.6206.2503.416.7100.024.2207.62.94
Dense vectorsserver100k10.5114.627.7211.94.22
Documentsembedded100k184.7156.0447.94.624.8612.514.00.83
Documentsserver100k6.639.1416.118.40.91
Documents, ten indexesembedded100k184.8168.8470.911.011.238.938.72.66
Documents, ten indexesserver100k24.021.841.744.52.09
Empty databaseembedded100k185.3155.0460.84.234.2413.513.80.24
Empty databaseserver100k7.567.3515.515.50.29
Graphembedded100k181.2159.5456.15.928.7113.314.22.76
Graphserver100k8.1811.216.418.72.75
Sparse vectorsembedded100k181.2159.5447.75.436.0921.018.72.73
Sparse vectorsserver100k7.549.9027.527.74.12
Time seriesembedded100k180.4168.0481.710.610.521.420.52.69
Time seriesserver100k11.915.120.625.92.67
Dense vectorsembedded1M185.2194.3496.798.4878.694.11,81012.8
Dense vectorsserver1M11.4919.3102.82,04312.7
Documentsembedded1M182.9146.9435.54.654.9514.113.94.69
Documentsserver1M6.107.9115.318.34.44
Documents, ten indexesembedded1M181172.4476.222.323.156.655.910.4
Documents, ten indexesserver1M22.933.387.265.910.4
Empty databaseembedded1M182.0153.4440.75.355.3814.413.50.47
Empty databaseserver1M8.157.1518.017.60.52
Graphembedded1M178.2146.0433.55.799.0712.114.110.5
Graphserver1M7.8210.418.518.410.5
Sparse vectorsembedded1M178.4155.0440.35.296.1822.422.910.5
Sparse vectorsserver1M7.2313.027.126.110.5
Time seriesembedded1M187.1165.2471.910.510.520.717.410.1
Time seriesserver1M12.814.721.323.910.1
Dense vectorsembedded10M187.9193.2493.81,42018k1,48818k24.7
Dense vectorsserver10M20.418k1,49118k24.7
Documentsembedded10M180.5144.4428.65.065.3014.013.415.3
Documentsserver10M6.077.6513.915.815.3
Time seriesembedded10M186.0172.5477.610.511.222.319.715.2
Time seriesserver10M13.514.826.622.615.3

↑ higher is better, ↓ lower is better; the best value in each column, within one size, is bold

Measured rows: benchmarks/experiments/results/runs_paper.csv

  • The SESSION is open + action + close. Reporting open and close alone hides work triggered between them.
  • Cold start is measured in a fresh subprocess and reported beside every session number, because a millisecond open inside a process that takes half a second to reach its first database call is not a millisecond to whoever launched it.
  • Known at this engine build: a vector database's no-op session close grows with the index (8 ms at 10k, 98 ms at 1M, 1.4 s at 10M), and a session with one search in it at 10M is 17.6 s, because the first search after a write started a full asynchronous graph rebuild and close() waited on it. Filed as #7183, fixed upstream in #7191 for 26.10.1; the October re-pin re-measures it.
  • A clean close should be O(what was written), not O(what is stored): write nothing and closing should cost the same at 10k documents and 10M.
  • Server rows have no JVM start, first open, or cold process: the server is already running when the probe connects, so those three columns describe the embedded process only. The session columns are measured for both.
  • Graph with the analytical view is withheld: its query grew from a bounded set of seeds to an unbounded 2-hop, so the cell is re-measured in October

Embedded or server is the deployment choice, and the tables above run the same engine build in both, so the difference between them is the deployment and not the engine. SurrealDB also has both modes, and its two rows above sit far apart: its Python package embeds a core a major version behind its server, on a different storage engine, so the embedded row wins only the point lookups the wire would have cost and loses or times out on every bulk cell. ArcadeDB's engine and Python package are released together from the same build, so that gap does not exist here.

Running the database in a separate process costs two things added together: turning the answer into a wire format that can travel over a connection, and the connection itself. The table below separates them by measuring a third deployment in between, an HTTP server running inside the same process. Embedded to that middle deployment adds the wire format without a second process. The middle deployment to a separate container adds the second process without changing the wire format.

The same projection answered by three deployments at six result sizes. The first column to the second is the wire format; the second to the third is the process boundary.

What the client/server split costs 200,000-document projection, one engine, three deployments
EngineSizein-process msin-process server, HTTP msseparate container, HTTP ms
1 documents11.262.582.89
10 documents100.961.972.16
100 documents1001.483.594.04
1,000 documents1,0003.355.274.56
10,000 documents10,00021.227.826.6
100,000 documents100,000207.3241.5252.2

↑ higher is better, ↓ lower is better; the best value in each column, within one size, is bold

Measured rows: benchmarks/experiments/results/e4decomp_8d6af9475

Exact builds measured
  • arcadedb 26.9.1-dev · 8d6af9475
  • Every number is milliseconds. One engine build (arcadedb 26.9.1-dev · 8d6af9475) in all three deployments, 15 repetitions after 3 warmup, identical cpuset 0-11, memory cap 8g and heap 6g.
  • All three deployments turn the answer into Python objects the same way, so the difference is how the database was deployed and not how we read the result.
  • The separate container runs on the same machine, talking over the local network interface. It says what running the database beside your program costs, and says nothing about a database on another machine across a real network.
  • The separate-process column goes slightly negative at the smaller result sizes. That is not a container being faster than an in-process server; it is the boundary term sitting below what this design can resolve, so run-to-run noise swamps it and the sign flips. Reported rather than clamped to zero, because the negative values are the evidence for the claim: at these sizes co-locating costs nothing measurable. The packing cost, in the column beside it, stays firmly positive at every size.

The two are nowhere near equal. The wire format costs something at every size and grows with the result. The process boundary, the second column against the third, is so small that at the smaller sizes it disappears into the noise. So the cost of running client and server as separate processes on one machine is the wire format, not the separation, and the lever that would move it is a cheaper wire format, not co-location.

Server deployment cost relative to embedded, by result size
What the server deployment costs relative to embedded, by result size.

Use embedded when the database serves one process: notebooks, tests, single-node services, agent tooling, and anything where a network hop per query is pure cost. It installs with pip install, starts in under half a second, and has no service to operate.

Use the server when more than one process or machine needs the same data, when you want the Postgres, Redis, Bolt, or HTTP wire protocols, or when you need Raft replication and failover. The Python package can also start a server inside your process, so you can move from one to the other later.

This is a deployment decision, not a performance one. The engine is the same in both, and the difference you will feel is the boundary you put around it.

Licenses

What each engine's license allows.

Not every engine on this page is open source in the sense the Open Source Initiative (OSI) defines. ArcadeDB and the Python package are both Apache-2.0.

EngineLicenseAny use, freeYour code stays yours
ArcadeDB engineApache-2.0yesyes
arcadedb-embedded (the Python package)Apache-2.0yesyes
PostgreSQL and pgvectorPostgreSQL Licenseyesyes
Apache AGEApache-2.0yesyes
DuckDBMITyesyes
SQLitepublic domainyesyes
sqlite-vecApache-2.0yesyes
MongoDB CommunitySSPL-1.0nono
Neo4j CommunityGPL-3.0 (Enterprise is commercial)yesno
LadybugDBMITyesyes
QdrantApache-2.0yesyes
MilvusApache-2.0yesyes
ElasticsearchAGPL-3.0 (also SSPL and ELv2)yesno
ChromaApache-2.0yesyes
LanceDBApache-2.0yesyes
QuestDBApache-2.0yesyes
TimescaleDBTimescale License (the image used here); the core alone is Apache-2.0nono
SurrealDBBusiness Source License 1.1nono
ArangoDBBusiness Source License 1.1nono

Two tests, each answered from the license text at the pinned version in September 2026. Any use, free: anyone may run the engine for any purpose, including selling a product or a service built on it, without paying the vendor; the SSPL (MongoDB), the Business Source License (SurrealDB and ArangoDB), and the Timescale License (the TimescaleDB image used here; its core alone is Apache-2.0) all reserve some uses. Your code stays yours: you may modify the engine and ship or serve a product on it without publishing your own code; the GPL (Neo4j Community) and the AGPL (Elasticsearch, whose core is offered under a choice of three licenses since 2024 and whose sparse-vector search measured here is in that core) require sharing what you distribute or serve. Rows in bold pass both tests. Both tests are yes for permissive licenses (Apache-2.0, MIT, the PostgreSQL License) and the public domain, yes and no for copyleft, and no for source-available. The Open Source Initiative approves every license in the first two groups and none in the third. A license can change with a release.

Four engines on this page are single-stack multi-model databases, one process serving documents, graph, and vectors: ArcadeDB, ArangoDB, MongoDB, and SurrealDB. Among them ArcadeDB is the only one that passes both tests above, Apache-2.0 since its first commit in 2021, and the only one whose engine and Python bindings ship together in monthly releases; MongoDB moved from the AGPL to the SSPL in 2018, ArangoDB from Apache-2.0 to the Business Source License in 2024, and SurrealDB has used the Business Source License since its public release. On staying that way, ArcadeDB's founder wrote in February 2026 that the project will never change its license; this page reports that as his published statement, not as a guarantee. HumemAI, which maintains arcadedb-embedded, makes the same commitment for the Python package: the engine and its bindings both stay Apache-2.0. PostgreSQL with pgvector and AGE reaches the same one-transaction result by loading extensions into one process, and it is open source under both tests; it is not counted among the four because three projects maintain its three models.