Skip to content

Commit b4c08af

Browse files
committed
feat(duckdb): add exact-search backend
1 parent 4ea1810 commit b4c08af

8 files changed

Lines changed: 456 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ All the database client supported
5757
| mongodb | `pip install vectordb-bench[mongodb]` |
5858
| tidb | `pip install vectordb-bench[tidb]` |
5959
| vespa | `pip install vectordb-bench[vespa]` |
60+
| duckdb | `pip install vectordb-bench[duckdb]` |
6061
| oceanbase | `pip install vectordb-bench[oceanbase]` |
6162
| hologres | `pip install vectordb-bench[hologres]` |
6263
| tencent_es | `pip install vectordb-bench[tencent_es]` |

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ cockroachdb = [ "psycopg[binary,pool]", "pgvector" ]
7575
clickhouse = [ "clickhouse-connect" ]
7676
vespa = [ "pyvespa" ]
7777
lancedb = [ "lancedb" ]
78+
duckdb = [ "duckdb>=1.5.5,<2.0.0" ]
7879
oceanbase = [ "mysql-connector-python" ]
7980
alisql = [ "mysqlclient" ]
8081
polardb = [ "PyMySQL" ]

tests/test_duckdb.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import multiprocessing as mp
2+
from collections.abc import Iterator
3+
from concurrent.futures import ProcessPoolExecutor
4+
from copy import deepcopy
5+
from pathlib import Path
6+
7+
import pandas as pd
8+
import pytest
9+
10+
pytest.importorskip("duckdb")
11+
12+
from vectordb_bench import config
13+
from vectordb_bench.backend.clients.api import MetricType
14+
from vectordb_bench.backend.clients.duckdb.config import DuckDBConfig, DuckDBIndexConfig
15+
from vectordb_bench.backend.clients.duckdb.duckdb import DuckDB
16+
from vectordb_bench.backend.filter import non_filter
17+
from vectordb_bench.backend.runner.concurrent_runner import ConcurrentInsertRunner
18+
from vectordb_bench.backend.runner.rate_runner import RatedMultiThreadingInsertRunner
19+
20+
21+
class SingleBatchDataset:
22+
class Fields:
23+
train_id_field = "id"
24+
train_vector_field = "emb"
25+
scalar_labels_file_separated = False
26+
27+
data = Fields()
28+
29+
def iter_batches(self, batch_size: int) -> Iterator[pd.DataFrame]:
30+
del batch_size
31+
yield pd.DataFrame(
32+
{
33+
"id": [40, 10, 30, 20],
34+
"emb": [
35+
[10.0, 0.0, 0.0],
36+
[0.0, 1.0, 0.0],
37+
[0.8, 0.2, 0.0],
38+
[-1.0, 0.0, 0.0],
39+
],
40+
}
41+
)
42+
43+
44+
class StreamingDataset:
45+
def __init__(self) -> None:
46+
self.batch_index = 0
47+
48+
def __iter__(self) -> "StreamingDataset":
49+
return self
50+
51+
def __next__(self) -> pd.DataFrame:
52+
if self.batch_index == 4:
53+
raise StopIteration
54+
start = self.batch_index * config.NUM_PER_BATCH
55+
self.batch_index += 1
56+
return pd.DataFrame(
57+
{
58+
"id": list(range(start, start + config.NUM_PER_BATCH)),
59+
"emb": [[1.0, 0.0, 0.0]] * config.NUM_PER_BATCH,
60+
}
61+
)
62+
63+
64+
def make_client(
65+
path: Path,
66+
drop_old: bool = True,
67+
metric_type: MetricType = MetricType.COSINE,
68+
) -> DuckDB:
69+
return DuckDB(
70+
dim=3,
71+
db_config=DuckDBConfig(db_path=str(path)).to_dict(),
72+
db_case_config=DuckDBIndexConfig(metric_type=metric_type),
73+
drop_old=drop_old,
74+
)
75+
76+
77+
def search_in_process(client: DuckDB, query: list[float], k: int) -> list[int]:
78+
client.prepare_filter(non_filter)
79+
with client.init():
80+
return client.search_embedding(query, k=k)
81+
82+
83+
@pytest.mark.parametrize(
84+
("metric_type", "expected"),
85+
[
86+
(MetricType.COSINE, [40, 30, 10]),
87+
(MetricType.L2, [30, 10, 20]),
88+
(MetricType.IP, [40, 30, 10]),
89+
(MetricType.DP, [40, 30, 10]),
90+
],
91+
)
92+
def test_duckdb_exact_search_reopens_after_copy_round_trip(
93+
tmp_path: Path,
94+
metric_type: MetricType,
95+
expected: list[int],
96+
) -> None:
97+
client = make_client(tmp_path / "vectors.duckdb", metric_type=metric_type)
98+
dataset = SingleBatchDataset()
99+
batch = next(dataset.iter_batches(4))
100+
101+
with client.init():
102+
count, error = client.insert_embeddings(batch["emb"].tolist(), batch["id"].tolist())
103+
104+
assert error is None
105+
assert count == 4
106+
107+
client = deepcopy(client)
108+
with client.init():
109+
assert client.search_embedding([1.0, 0.0, 0.0], k=3) == expected
110+
111+
112+
def test_duckdb_loads_through_concurrent_runner(tmp_path: Path) -> None:
113+
client = make_client(tmp_path / "runner.duckdb")
114+
runner = ConcurrentInsertRunner(
115+
db=client,
116+
dataset=SingleBatchDataset(),
117+
normalize=False,
118+
max_workers=4,
119+
)
120+
121+
assert runner.max_workers == 1
122+
assert runner.task() == 4
123+
124+
with client.init():
125+
assert client.search_embedding([1.0, 0.0, 0.0], k=2) == [40, 30]
126+
127+
128+
def test_duckdb_rolls_back_failed_load(tmp_path: Path) -> None:
129+
client = make_client(tmp_path / "rollback.duckdb")
130+
131+
def abort_load() -> None:
132+
with client.init():
133+
count, error = client.insert_embeddings([[1.0, 0.0, 0.0]], [1])
134+
assert error is None
135+
assert count == 1
136+
raise RuntimeError("abort load")
137+
138+
with pytest.raises(RuntimeError, match="abort load"):
139+
abort_load()
140+
141+
with client.init():
142+
assert client.search_embedding([1.0, 0.0, 0.0], k=1) == []
143+
144+
145+
def test_duckdb_serializes_streaming_insert_threads(tmp_path: Path) -> None:
146+
client = make_client(tmp_path / "streaming.duckdb")
147+
runner = RatedMultiThreadingInsertRunner(
148+
rate=config.NUM_PER_BATCH * 4,
149+
db=client,
150+
dataset_iter=StreamingDataset(),
151+
)
152+
queue = mp.Queue()
153+
154+
try:
155+
runner.run_with_rate(queue)
156+
finally:
157+
queue.close()
158+
queue.join_thread()
159+
160+
expected_ids = set(range(config.NUM_PER_BATCH * 4))
161+
with client.init():
162+
assert set(client.search_embedding([1.0, 0.0, 0.0], k=len(expected_ids))) == expected_ids
163+
164+
165+
def test_duckdb_supports_spawned_concurrent_searches(tmp_path: Path) -> None:
166+
client = make_client(tmp_path / "concurrent.duckdb")
167+
dataset = SingleBatchDataset()
168+
batch = next(dataset.iter_batches(4))
169+
with client.init():
170+
count, error = client.insert_embeddings(batch["emb"].tolist(), batch["id"].tolist())
171+
172+
assert error is None
173+
assert count == 4
174+
175+
context = mp.get_context("spawn")
176+
with ProcessPoolExecutor(max_workers=2, mp_context=context) as executor:
177+
futures = [executor.submit(search_in_process, client, [1.0, 0.0, 0.0], 2) for _ in range(2)]
178+
179+
assert [future.result() for future in futures] == [[40, 30], [40, 30]]

vectordb_bench/backend/clients/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ class DB(Enum):
4949
Clickhouse = "Clickhouse"
5050
Vespa = "Vespa"
5151
LanceDB = "LanceDB"
52+
DuckDB = "DuckDB"
5253
OceanBase = "OceanBase"
5354
S3Vectors = "S3Vectors"
5455
Hologres = "Alibaba Cloud Hologres"
@@ -217,6 +218,11 @@ def init_cls(self) -> type[VectorDB]: # noqa: PLR0911, PLR0912, C901, PLR0915
217218

218219
return LanceDB
219220

221+
if self == DB.DuckDB:
222+
from .duckdb.duckdb import DuckDB
223+
224+
return DuckDB
225+
220226
if self == DB.S3Vectors:
221227
from .s3_vectors.s3_vectors import S3Vectors
222228

@@ -435,6 +441,11 @@ def config_cls(self) -> type[DBConfig]: # noqa: PLR0911, PLR0912, C901, PLR0915
435441

436442
return LanceDBConfig
437443

444+
if self == DB.DuckDB:
445+
from .duckdb.config import DuckDBConfig
446+
447+
return DuckDBConfig
448+
438449
if self == DB.S3Vectors:
439450
from .s3_vectors.config import S3VectorsConfig
440451

@@ -637,6 +648,11 @@ def case_config_cls( # noqa: C901, PLR0911, PLR0912, PLR0915
637648

638649
return _lancedb_case_config.get(index_type)
639650

651+
if self == DB.DuckDB:
652+
from .duckdb.config import DuckDBIndexConfig
653+
654+
return DuckDBIndexConfig
655+
640656
if self == DB.S3Vectors:
641657
from .s3_vectors.config import S3VectorsIndexConfig
642658

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from importlib.metadata import version
2+
from typing import Annotated, Unpack
3+
4+
import click
5+
6+
from ....cli.cli import CommonTypedDict, cli, click_parameter_decorators_from_typed_dict, run
7+
from .. import DB
8+
from .config import DuckDBConfig, DuckDBIndexConfig
9+
10+
11+
class DuckDBTypedDict(CommonTypedDict):
12+
db_path: Annotated[
13+
str,
14+
click.option(
15+
"--db-path",
16+
type=click.Path(dir_okay=False),
17+
help="Path to a dedicated DuckDB benchmark database file.",
18+
required=True,
19+
),
20+
]
21+
threads: Annotated[
22+
int,
23+
click.option(
24+
"--threads",
25+
type=click.IntRange(min=1),
26+
default=1,
27+
help="Number of DuckDB threads used by each benchmark process.",
28+
show_default=True,
29+
),
30+
]
31+
32+
33+
@cli.command(name="duckdb")
34+
@click_parameter_decorators_from_typed_dict(DuckDBTypedDict)
35+
def DuckDB(**parameters: Unpack[DuckDBTypedDict]) -> None:
36+
run(
37+
db=DB.DuckDB,
38+
db_config=DuckDBConfig(
39+
db_label=parameters["db_label"],
40+
version=version("duckdb"),
41+
db_path=parameters["db_path"],
42+
threads=parameters["threads"],
43+
),
44+
db_case_config=DuckDBIndexConfig(),
45+
**parameters,
46+
)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from pydantic import BaseModel, Field
2+
3+
from ..api import DBCaseConfig, DBConfig, IndexType, MetricType
4+
5+
6+
class DuckDBConfig(DBConfig):
7+
db_path: str
8+
threads: int = Field(default=1, ge=1)
9+
10+
def to_dict(self) -> dict:
11+
return {"db_path": self.db_path, "threads": self.threads}
12+
13+
14+
class DuckDBIndexConfig(BaseModel, DBCaseConfig):
15+
index: IndexType = IndexType.Flat
16+
metric_type: MetricType | None = None
17+
18+
def index_param(self) -> dict:
19+
return {}
20+
21+
def search_param(self) -> dict:
22+
return {}

0 commit comments

Comments
 (0)