HumemAI iconHumemAI

Subproject

ArcadeDB

A multi-model database engine that keeps documents, graphs, key-value, time series and vectors in one transactional engine, plus the Python distribution that runs that engine in process, benchmarked against the specialist systems in each category.

Illustration for ArcadeDB

Engine

Documents, graphs, key-value, time series and vectors share one storage engine.

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, and commits in the same transaction, so a write that touches a document, an edge and a vector index is one ACID transaction, atomic and durable as a unit, instead of three that have to be coordinated.

The indexes work the same way. LSM trees, full-text, geo, hash, and both dense and sparse vector indexes are all commit in the same transaction as the records they index. Replication comes along for the ride: Raft ships page changes from that shared log, so every model replicates correctly without anyone writing replication code per model.

Vectors are the exception worth naming. 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. A transactional record with an eventually consistent index over it is more than a standalone vector store offers, and less than a fully transactional index would be.

Embedded

The same engine, installed as a Python package and run inside your process.

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 embedded distribution removes all three. It ships the upstream engine unmodified, with a bundled runtime and platform wheels, so uv add arcadedb-embedded or pip install arcadedb-embedded is the entire setup and the database runs in your process.

This is a real package surface rather than a launcher. Transactions and lifecycle, schema and graph helpers, bulk ingest, import and export paths, and the vector features are all exposed and tested, with the example suite run in CI on every change.

Both halves of this work are maintained here. Fixes and features found through the benchmarking below are filed and, where possible, contributed upstream, so the engine and the Python distribution improve together rather than diverging.

The obvious question is what the Python boundary costs. The engine runs at the same speed either way; what gets charged for is handing results back. Against an in-process Java baseline doing the same work, a vector search costs 1.28x and a 100k-row scan 1.63x.

The more useful number is the one inside Python. Asking for row objects is 13.8x slower than asking for columns over the identical query, so which call you reach for matters far more than the language boundary does. Which call you reach for is worth checking 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.

What Python costs Same engine, same query, called from Java and from Python
EngineScaletime msvs Java
Java, in processvector search2.611.00
Pythonvector search3.331.28
Java, in process100k-row scan159.91.00
Python, to_columns100k-row scan260.71.63
Python, to_json_list100k-row scan401.42.51
Python, to_list100k-row scan3,60122.5

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.28x and the scan 1.63x 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 row objects is 13.8x slower than asking for columns over the same query, so the practical advice is to use the columnar or batched call for anything large.

Benchmarks

Graph traversal, against the database people actually compare it to.

Graph is what most people reach for ArcadeDB to do, so it goes first. The benchmark is LDBC-SNB, the Linked Data Benchmark Council's Social Network Benchmark, a standard synthetic social network, and the queries are Cypher, which is the language most graph work is written in. Neo4j is the engine anyone choosing a graph database weighs it against. LadybugDB is here because it is embedded and columnar, which makes it the closest comparison to running ArcadeDB inside your own process.

ArcadeDB embedded answers all four queries faster than Neo4j, at both sizes, by margins the table makes plain. That is the strongest head-to-head result on this page, and it is the one a graph user is looking for.

Graph traversal 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 an edge.

Graph traversal LDBC-SNB Interactive (SF1, SF10)
EngineModeScalepoint p50 ms1-hop p50 ms2-hop p50 mswrite p50 mspeak memory GiB
ArcadeDBembeddedSF1 (11k people)0.450.420.830.951.26
ArcadeDBserverSF1 (11k people)1.241.181.631.473.39
LadybugDBembeddedSF1 (11k people)0.161.044.069.650.22
Neo4jserverSF1 (11k people)4.964.353.777.854.61
ArcadeDBembeddedSF10 (73k people)0.480.581.630.718.06
ArcadeDBserverSF10 (73k people)1.211.412.661.737.87
LadybugDBembeddedSF10 (73k people)0.161.685.619.720.36
Neo4jserverSF10 (73k people)5.765.524.908.2212.9

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

Exact builds measured
  • arcadedb 26.8.1 (in-process)
  • arcadedb 26.8.1 arcadedata/arcadedb:26.8.1@49036720b167…
  • ladybugdb 0.18.1
  • neo4j 5-community neo4j@4bae36aff762…
  • Every engine traverses the same persons-and-KNOWS projection, with edges stored in both directions.

Analytical queries over the whole graph are a different job from those single traversals, and ArcadeDB has a separate mechanism for them. A Graph Analytical View is an in-core projection of the graph, built once, that the planner uses for queries touching most of the vertices instead of a handful. Because it is optional, it can be switched off, so the table below carries the same engine twice and the cost of the mechanism is visible rather than assumed.

The view is worth 6.5x on top degree and about 2.4x 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: it stores the graph in columns, which is the same reason DuckDB wins the analytical tabular queries further down. This is the honest shape of the multi-model tradeoff, one engine covering every model competently rather than beating a specialist at its own workload.

Graph analytics, with and without the Graph Analytical View LDBC-SNB, SF10
EngineModeaverage friend age msfriends in same city msmost friends mspeak memory GiB
ArcadeDB (GAV)embedded492.9500.456.68.16
ArcadeDBembedded1,2131,196368.08.12
ArcadeDB (GAV)server526.5528.967.57.92
LadybugDBembedded55.063.52.750.35
Neo4jserver571.5616.0320.112.9
Exact builds measured
  • arcadedb 26.8.1 (in-process)
  • arcadedb 26.8.1 arcadedata/arcadedb:26.8.1@49036720b167…
  • ladybugdb 0.18.1
  • neo4j 5-community neo4j@4bae36aff762…
  • 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. Building it took 2.0 seconds here, once, before any query was timed.
  • 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.

Benchmarks

Vector search, measured against the engines built only for vector search.

Vector search is the newest of these models and the one with the most specialised competition, so it gets the most detailed treatment here: two corpora, two kinds of vector, and recall reported beside every latency. It is also the workload ArcadeDB does not win, which the tables show rather than bury. Every corpus below is real and published, never generated, which matters most for the sparse case.

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 actually uses. Those words cost wildly 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 very long tail that appear almost nowhere, so some queries are far more expensive than others. Generated data spreads words out evenly, which quietly 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 tier
DEEP9.99M96the ten-million dense tier

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

Two of those sizes are ceilings rather than choices. 8.84 million is the entire Big-ANN sparse base set, so no larger sparse tier exists to run, 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 earn their place anyway by publishing exact nearest neighbours at ten-million scale, which is what makes recall comparable across engines at all. 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 numeral instead of the degree would have handed ArcadeDB twice the graph. Sparse search has no equivalent knob, so every engine runs its own defaults there and each table states what precision the index stores. Every query asks for the top 10.

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 genuinely different points on that trade.

Every engine in every table below runs in the same container envelope, one at a time on one machine, and each cell is the median of five repetitions rather than a single sample. The exact build measured sits under each table.

Sparse retrieval on real SPLADE vectors. ArcadeDB appears three times and every comparator once, because ArcadeDB is the engine under test: it runs in both deployments, and the fp32 row ablates our own default. 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. int8 posting weights are ArcadeDB's default and fp32 is the ablation, which is why recall sits beside every latency. Every engine's precision is now stated, read from its own documentation and source at the version we ran rather than guessed. Qdrant and Milvus keep sparse weights at full 32-bit precision. Elasticsearch keeps about 9 significant bits, which its own documentation puts at roughly 0.4% relative error, making it the lossiest of the six here. So quantization is a choice each of these engines makes, and the engine that gives up the most precision is not ours.

Sparse vector search Big-ANN'23 Sparse (real SPLADE over MS MARCO)
EngineModePrecisionScalep50 msrecall@10build speak memory GiB
ArcadeDBembeddedint88.84M82.90.99922.716.8
ArcadeDBembeddedfp328.84M86.21.00914.916.8
ArcadeDBserverint88.84M89.40.992,22816.8
Elasticsearchserver~9-bit8.84M55.81.003,11117.4
Milvusserverfp328.84M23.41.00720.113.6
Qdrantserverfp328.84M10.51.001,0689.69
ArcadeDBembeddedint81M11.30.9995.38.38
ArcadeDBembeddedfp321M11.51.0096.08.42
ArcadeDBserverint81M13.60.99245.08.51
Elasticsearchserver~9-bit1M9.831.00320.99.05
Milvusserverfp321M9.041.00102.24.76
Qdrantserverfp321M2.871.0091.92.28
ArcadeDBembeddedint8100k4.170.999.373.02
ArcadeDBembeddedfp32100k4.161.009.253.15
ArcadeDBserverint8100k5.290.9926.04.34
Elasticsearchserver~9-bit100k3.461.0033.94.79
Milvusserverfp32100k2.961.0010.91.27
Qdrantserverfp32100k0.891.008.170.69

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

Exact builds measured
  • arcadedb 26.8.1 (in-process)
  • arcadedb 26.8.1 arcadedata/arcadedb:26.8.1@49036720b167…
  • elasticsearch 9.4.1 docker.elastic.co/elasticsearch/elasticsearch@268f65f1b32e…
  • Milvus milvusdb/milvus@0ea40276f811…
  • 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.
  • 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.
  • Every number here is the first timed pass after the index is built. Running the same engines again over an index they have already read shows almost nothing: the largest gain any of the six makes is 1.18x at a million and 1.13x at 8.84 million, and the order of the table is identical either way. That is worth stating because the dense table below is NOT like this, where ArcadeDB alone gains about 9x on a second pass and the order depends on which pass you time.
  • ArcadeDB's server takes roughly twice as long to build as 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.

A fair question about the table above: those are first-pass numbers, taken right after each index is built. Does the picture change once an engine has warmed up? For sparse search, barely.

Nobody gains much and the order does not move. The largest gain by any engine is 1.18x at a million and 1.13x at 8.84 million, and at the larger size ArcadeDB's is the smallest of the six while Elasticsearch's is the largest. Read this against the dense table below, where ArcadeDB alone gains about nine times on a second pass: that is a property of how the two index structures reach their data, not of how we ran them.

Sparse search: what a second pass buys Big-ANN'23 Sparse, one build per engine, cold then warm
EngineModePrecisionScalecold p50 mswarm p50 msgain
ArcadeDBembeddedint88.84M87.984.51.04
ArcadeDBembeddedfp328.84M89.086.41.03
ArcadeDBserverint88.84M90.988.01.03
Elasticsearchserver~9-bit8.84M59.152.11.13
Milvusserverfp328.84M40.937.31.10
Qdrantserverfp328.84M16.716.71.00
ArcadeDBembeddedint81M12.010.21.18
ArcadeDBembeddedfp321M12.311.51.07
ArcadeDBserverint81M14.112.81.10
Elasticsearchserver~9-bit1M10.29.001.13
Milvusserverfp321M9.398.671.08
Qdrantserverfp321M2.982.931.02
Exact builds measured
  • arcadedb 26.8.1
  • Cold is the first timed pass after the index is built. Warm is the median of 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. Both halves are drawn from the same 1,000 dev queries in the same order.
  • One build per engine here, against five in the table above, which is why these are a separate table rather than two more columns on it. Reading a warm number from this protocol beside a cold number from that one is the mistake this table exists to avoid.
  • The order is the same cold and warm at both sizes. The dense table further down is not like this: there ArcadeDB alone gains about nine times on a second pass, so which pass you time decides the ranking, and it has to say which.

Dense retrieval at two scales, including the embedded and server deployments of the same engine. Cold is the first timed pass after the build; warm repeats the same query set. Every engine at ten million was measured both ways and every engine at the smaller scale was measured once, so the dashes are a property of the tier and not of the engine. Read latency against recall rather than on its own: Chroma is the quickest engine at both scales and also the one returning the fewest true neighbours, which is why the summary figure at the end of the next section 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-scale tiers
EngineModePrecisionScalecold p50 mswarm p50 msrecall@10build s
ArcadeDBembeddedfp321M2.040.99185.5
ArcadeDBserverfp321M2.980.99258.7
Chromaembeddedfp321M0.640.97146.0
DuckDB VSSembeddedfp321M1.790.9759.7
LanceDBembeddedint81M1.460.9621.0
Milvusserverfp321M3.490.9917.4
Qdrantserverfp321M1.041.0044.0
sqlite-vecembeddedfp321M141.11.007.63
ArcadeDBembeddedfp329.99M8.870.960.952,673
ArcadeDBembeddedint89.99M2.870.820.945,585
ArcadeDBserverfp329.99M36.62.100.953,777
Qdrantserverfp329.99M1.341.310.98521.9
Chromaembeddedfp329.99M0.700.710.933,765
DuckDB VSSembeddedfp329.99M2.592.550.93807.6
LanceDB (int8)embeddedfp329.99M3.203.150.93229.2
Milvusserverfp329.99M17.817.30.99144.2
sqlite-vecembeddedfp329.99M993.4993.41.0071.1

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

Exact builds measured
  • arcadedb 26.8.1 (in-process)
  • arcadedb 26.8.1 arcadedata/arcadedb:26.8.1@49036720b167…
  • chroma 1.5.9
  • duckdb vss 1.5.4
  • lancedb 0.34.0
  • Milvus (fp32) milvusdb/milvus@0ea40276f811…
  • qdrant v1.18.2 qdrant/qdrant@75eab8c4ba42…
  • sqlite-vec 0.1.9
  • 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.
  • Cold is the first timed pass after the index is built; warm is a repeat of the same query set. Only ArcadeDB moves between them, because it pages its index off disk while the others are resident from build. Every comparator here is within 3% of itself.

Benchmarks

Tables, time series, and the transaction that spans every model at once.

The same harness runs tabular work in both OLTP and OLAP shapes, time series on TSBS, the Time Series Benchmark Suite, and a cross-model transaction that starts from a vector hit, traverses the graph, and updates a document. The tables below and the summary figure at the end all use compressed labels, which are worth having to hand:

LabelWhat it means
TxnTransaction.
OLTPOnline transaction processing: many small reads and writes, counted in operations per second.
OLAPOnline analytical processing: a few large scanning queries, timed in milliseconds. Its rows are in the tables further down.
TSTime series. Agg is an aggregation over a window; pts/s is points ingested per second.
TPC-H Q1The first query of a long-standing analytical benchmark.
Sparse, DenseThe two kinds of vector above. The number beside each is the corpus size.

Tabular transactional and analytical work against PostgreSQL and DuckDB.

Tabular OLTP and OLAP Synthetic orders workload
EngineModeread p50 msinsert p50 msOLTP ops/sOLAP total mspeak memory GiB
ArcadeDBembedded0.060.228,36271k12.7
ArcadeDBserver0.610.781,46678k12.9
DuckDBembedded0.978.60274.8108.21.98
PostgreSQLserver1.022.48523.33,2470.12

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

Exact builds measured
  • arcadedb 26.8.1 (in-process)
  • arcadedb 26.8.1 arcadedata/arcadedb:26.8.1@49036720b167…
  • duckdb 1.5.5
  • postgres 17.10 postgres@de1e13ca9437…
  • PostgreSQL's memory cell is not comparable to the other two. It keeps its data in shared memory and in the file cache the kernel holds for it, and this column counts neither, so it reads 0.12 GiB where the run's actual peak was 3.85. Most of even that 0.12 is our own benchmark client rather than the database: 0.105 of it. We checked that this is the measure and not the setting, by giving a PostgreSQL container a 2 GB buffer pool and watching this column stay at 5 MiB.

The same two shapes on TPC-H and TPC-C, the long-standing analytical and transactional benchmarks.

Tabular (TPC-H and TPC-C shapes) TPC-H queries, TPC-C new-order
EngineModeQ1 msQ6 msnew-order p50 msOLTP ops/speak memory GiB
ArcadeDBembedded11k852.80.342,3342.40
ArcadeDBserver12k892.41.11841.87.38
DuckDBembedded15.57.349.26106.41.86
PostgreSQLserver1,011111.92.35375.21.58

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

Exact builds measured
  • arcadedb 26.8.1 (in-process)
  • arcadedb 26.8.1 arcadedata/arcadedb:26.8.1@49036720b167…
  • duckdb 1.5.5
  • postgres 17.10 postgres@de1e13ca9437…
  • 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, because the 1.58 GiB shown is 1.578 of Python client and 0.006 of database.

Time series against QuestDB and DuckDB on TSBS. ArcadeDB appears twice because it offers two ways to store this data. The native TIMESERIES type is a dedicated layout that keeps points for one series together in time order, and the document path stores each reading as an ordinary document, which is what you get if you model the data without knowing the specialised type exists. Both are supported and both are real choices, so the gap between them is what the specialised layout is worth: 46 times the ingest rate and a twelve-hour aggregate 68 times quicker, against a slightly slower lookup of the newest reading.

Time series TSBS cpu-only, 2,592,000 points
EngineModeingest pts/snewest reading ms12h aggregate ms
arcadedb (native TIMESERIES)embedded1.86M0.7225.0
arcadedb (document path)embedded40k0.431,696
QuestDBserver432k0.802.03
DuckDBembedded1.86M1.504.44

Measured rows: benchmarks/experiments/results/l4_tsbs.jsonl

Exact builds measured
  • arcadedb 26.8.1
  • questdb 9.1.1
  • duckdb 1.5.5
  • No engine takes a settle step, and that was measured rather than assumed: sealing the write buffer makes the aggregation faster and the last-point query slower, since the unsealed tail a scan walks is where the newest point lives. Settling only ours would have been a one-sided advantage.
  • 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 one-tag/ten-tag run prices the schema at 2.0x on ingest and 2.6x faster on last-point.
  • 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: telling the engine to look only at the past hour made it slower, 0.860 ms against 0.720, because evaluating the time filter costs more than the scan it saves.

The cross-model transaction is the argument for a unified engine stated as an experiment rather than a claim. Against a composed stack of a vector store plus a graph database, the interesting output is not the latency, it is what a failure part-way through leaves behind.

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

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

We interrupted 200 operations against each system. The composed stack was left half-updated in all 200. ArcadeDB and SurrealDB, each wrapping the work in one transaction, were left half-updated in none of them. The count is worth stating because a handful of trials would not settle it: with five attempts and no failures you cannot rule out a failure rate near one in two, where two hundred puts it under two in a hundred. Before each interruption the two stores are checked and found to agree exactly, which is what makes a disagreement afterwards mean something.

One thing this is not: a criticism of Qdrant or Neo4j. Both do exactly what they are asked, correctly, every time. The gap is between them, and it would appear just the same with any other pair. Nor does it mean a setup like this cannot be made to work. A team building on two stores would add machinery to repair the divergence afterwards, replaying the write that went missing until both sides agree, and with that in place the damage is eventually mended rather than permanent. What the experiment measures is what that machinery costs you, since it is the thing a single engine with one transaction does not need.

The cross-model operation: a vector hit expands over graph edges and updates a document. ArcadeDB and SurrealDB 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
EngineModep50 msp99 mspeak memory GiB
ArcadeDB (one transaction)embedded1.825.810.93
Qdrant + Neo4j (no shared transaction)server19.727.46.78
SurrealDBembedded7.688.690.80

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

Exact builds measured
  • neo4j 5-community neo4j@4bae36aff762…
  • 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. ArcadeDB here writes to disk, while SurrealDB runs entirely in memory and the composed stack's vector half does too. Part of why they answer faster is that they never touch 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.
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.

One figure for all of it. Every row above appears here as a ratio against the strongest comparator for that workload.

ArcadeDB latency against the best specialist engine at each corpus size
The whole evaluation in one figure, not just the vector part: ArcadeDB embedded against the best specialist on each workload, log scale, anything right of the line a win. It wins the operational and cross-model rows, matches DuckDB on time-series ingest, and loses every scan-, bulk- and search-bound row, which is the honest shape for a general engine measured against specialists. It does not win vector search. Every bar is a first timed pass after the index is built, on both sides, and at ten million vectors that is 8.87 ms for ArcadeDB against Qdrant's 1.34 and Chroma's 0.70. The dense bar divides by Qdrant rather than by Chroma, which the table shows is faster, because Chroma returns 93.4% of the true neighbours where ArcadeDB returns 95.1%: each row compares us against one specialist, chosen this way: of the engines that find at least as many of the true nearest neighbours as we do, the fastest. Comparing against a quicker engine that finds fewer would not be a fair race. The pass matters only for us: run the same query set again and ArcadeDB answers in 0.96 ms, because it pages its index off disk and the second pass finds it resident, while Qdrant moves to 1.31 and Chroma to 0.71. So the dense row shows both. The bar is the first pass and the diamond is the same comparison in steady state, a 1.4x win reached without any comparator getting faster. No other row can show both, because every other lane times a single pass. The cross-model row is measured against SurrealDB, which is the fastest engine here that also does the whole operation in one transaction, rather than against the composed stack, which is slower and has no transaction spanning its two engines at all. The rows use each engine's own idiom: tabular OLTP is SQL against PostgreSQL, the graph row is Cypher against LadybugDB, and TPC-H Q1 is SQL against DuckDB.

Choosing

Embedded or server: the deployment choice, and what it actually costs.

The tables above run the same engine version in both deployments, so the difference between them is deployment and not the engine. Running the database in a separate process costs something, and that cost is really two things added together: packing the answer into a format that can travel over a connection, and the connection itself. The table below separates them by measuring a third setup in between, an HTTP server running inside the same process. Embedded to that middle setup adds the packing without adding a second process. The middle setup to a separate container adds the second process without changing the packing.

The same projection answered three ways at six result sizes, which is what separates the wire format from the process boundary.

What the client/server split costs 200,000-row projection, one engine, three deployments
EngineScalein-process msin-process server, HTTP msseparate container, HTTP mspacking cost msseparate process ms
1 rows11.052.471.691.42-0.78
10 rows100.861.901.341.04-0.56
100 rows1001.232.822.031.59-0.79
1,000 rows1,0003.326.004.562.68-1.44
10,000 rows10,00020.429.630.19.160.52
100,000 rows100,000201.1271.3276.070.24.71

Measured rows: benchmarks/experiments/results/e4decomp_2681

Exact builds measured
  • 26.8.1
  • Every number is milliseconds. One released engine (26.8.1) in all three setups, 15 repetitions after 3 warmup, identical cpuset 0-11, memory cap 8g and heap 6g.
  • All three setups 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, which is the useful part. Packing the answer costs something at every size and grows with the result. The process boundary is so small that at the smaller sizes it disappears into the noise and measures slightly negative. So the cost of running client and server as separate processes on one machine is essentially the serialization, not the separation, and the lever that would actually move it is a cheaper wire format rather than co-location.

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

Use the embedded distribution 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, starts in milliseconds, 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 embedded package can also start a server in-process, so this is not a one-way door.

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.