Skip to content

Commit efaeae9

Browse files
committed
feat(searcher): unify exploration control into single eps_or_ef parameter
- High-level C++ Searcher and Python bindings now accept a single eps_or_ef parameter - Dispatches to fixed-pool search_ef when eps_or_ef >= 1.0, and relative search_eps otherwise - Simplifies VIBE benchmarking adapter and grid-searches - Updates API docs, READMEs (C++, Python, Sphinx), and test suites
1 parent d8d1fd4 commit efaeae9

12 files changed

Lines changed: 334 additions & 356 deletions

File tree

‎cpp/deglib/include/deglib/search/searcher.h‎

Lines changed: 129 additions & 28 deletions
Large diffs are not rendered by default.

‎cpp/readme.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,24 @@ auto dynamic_graph = deglib::load_dynamic_graph("index.deg", /*chunk_size=*/1024
111111
auto mutable_graph = deglib::load_mutable_graph("index.deg", /*new_max_size=*/10000);
112112
```
113113

114+
### High-Performance Searcher (`eps_or_ef`)
115+
116+
`deglib::search::Searcher` provides a high-throughput query execution pipeline supporting entry vertex optimization, optional on-the-fly query quantization, exact candidate reranking, and multithreaded batch search with unified exploration parameter `eps_or_ef`:
117+
118+
```cpp
119+
#include <deglib/search/searcher.h>
120+
121+
// 1. Create searcher on top of a graph (optionally with quantizer & refiner)
122+
auto searcher = deglib::search::make_searcher(readonly_graph);
123+
searcher->optimize(/*n_clusters=*/128); // entry vertex medoids via k-means
124+
125+
// 2. Query with relative distance margin (eps_or_ef < 1.0)
126+
auto eps_results = searcher->search(std::span<const float>(query), /*k=*/10, /*eps_or_ef=*/0.1f);
127+
128+
// 3. Query with fixed candidate pool size (eps_or_ef >= 1.0, HNSW-style)
129+
auto ef_results = searcher->search(std::span<const float>(query), /*k=*/10, /*eps_or_ef=*/128.0f);
130+
```
131+
114132
---
115133
116134
## Prerequisites

‎cpp/test/src/unit/test_searcher.cpp‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,42 @@ TEST(SearcherTest, FlatBatchAndIntoSearch) {
233233
}
234234
}
235235

236+
TEST(SearcherTest, SearchEfSingleAndBatch) {
237+
const uint32_t dim = 4;
238+
const uint32_t count = 30;
239+
240+
std::vector<float> data(count * dim);
241+
for (size_t i = 0; i < data.size(); ++i) {
242+
data[i] = static_cast<float>(i * 0.5f + 0.1f);
243+
}
244+
245+
auto graph = deglib::builder::build_from_data(std::span<const float>(data), dim, {}, 8, deglib::distances::Metric::FP32_L2);
246+
auto searcher = deglib::search::make_searcher(graph.internal());
247+
248+
const uint32_t k = 4;
249+
const float ef = 32.0f;
250+
251+
// 1. Single search with eps_or_ef >= 1.0 (ef mode)
252+
auto q0 = std::span<const float>(data.data(), dim);
253+
auto single_res = searcher->search(q0, k, /*eps_or_ef=*/ef, /*rerank_factor=*/1.0f, /*return_distances=*/true);
254+
EXPECT_GE(single_res.size(), 1u);
255+
EXPECT_EQ(single_res.indices[0], 0u);
256+
EXPECT_NEAR(single_res.distances[0], 0.0f, 1e-5f);
257+
258+
// 2. Batch search with eps_or_ef >= 1.0 (ef mode) with flat result and 2 worker threads
259+
const size_t n_queries = 5;
260+
auto batch_res = searcher->search_batch(
261+
std::span<const float>(data.data(), n_queries * dim),
262+
n_queries, k, /*eps_or_ef=*/ef, /*rerank_factor=*/1.0f, /*threads=*/2, /*return_distances=*/true
263+
);
264+
EXPECT_EQ(batch_res.size(), n_queries);
265+
EXPECT_EQ(batch_res.k, k);
266+
for (size_t q = 0; q < n_queries; ++q) {
267+
auto q_indices = batch_res.get_indices(q);
268+
auto q_dists = batch_res.get_distances(q);
269+
EXPECT_EQ(q_indices[0], static_cast<uint32_t>(q));
270+
EXPECT_NEAR(q_dists[0], 0.0f, 1e-5f);
271+
}
272+
}
273+
274+

‎docs/api/search.rst‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,16 @@ High-Performance Searcher with Reranking
6767
refine_data=data,
6868
)
6969
70-
# Single query
71-
indices, distances = searcher.search(query, k=10, eps=0.1, rerank_factor=1.5, return_distances=True)
70+
# Single query using relative margin (eps_or_ef < 1.0)
71+
indices, distances = searcher.search(query, k=10, eps_or_ef=0.1, rerank_factor=1.5, return_distances=True)
7272
73-
# Batch query
73+
# Single query using fixed pool exploration (eps_or_ef >= 1.0, HNSW-style)
74+
indices_ef, distances_ef = searcher.search(query, k=10, eps_or_ef=128, rerank_factor=1.5, return_distances=True)
75+
76+
# Multithreaded batch query (supports eps_or_ef < 1.0 or >= 1.0)
7477
batch_queries = np.random.randn(100, dim).astype(np.float32)
7578
batch_indices, batch_distances = searcher.search(
76-
batch_queries, k=10, eps=0.1, rerank_factor=1.5, threads=8, return_distances=True
79+
batch_queries, k=10, eps_or_ef=128, rerank_factor=1.5, threads=8, return_distances=True
7780
)
7881
7982
Filtered Search

‎examples/vibe/BENCHMARK_PLAN.md‎

Lines changed: 0 additions & 252 deletions
This file was deleted.

‎examples/vibe/config.yml‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ float:
1111
run_groups:
1212
base:
1313
args:
14-
k: [48]
15-
opt_target: ['LowLID']
16-
prune_non_rng: [false]
14+
k: [16, 24, 30, 40, 48]
15+
opt_target: ['LowLID', 'StreamingData']
16+
prune_non_rng: [false, true]
1717
query_args:
18-
eps_or_ef: [0.0, 0.005, 0.01, 0.02, 0.04, 0.06, 0.08, 0.1, 0.12, 0.15, 0.2, 0.25, 0.3]
18+
eps_or_ef: [64, 96, 128, 160, 200, 250, 320, 400, 0.0, 0.005, 0.01, 0.02, 0.04, 0.06, 0.08, 0.1, 0.12, 0.15, 0.2, 0.25, 0.3]
1919
- name: deg-qg
2020
module: vibe.algorithms.deg
2121
constructor: QG
@@ -27,9 +27,9 @@ float:
2727
run_groups:
2828
linear_pool:
2929
args:
30-
k: [48]
31-
opt_target: ['LowLID']
32-
prune_non_rng: [false]
30+
k: [16, 24, 30, 40, 48]
31+
opt_target: ['LowLID', 'StreamingData']
32+
prune_non_rng: [false, true]
3333
query_args:
3434
rerank_size_factor: [1.0, 1.15, 1.35]
35-
eps_or_ef: [0.0, 0.005, 0.01, 0.02, 0.04, 0.06, 0.08, 0.1, 0.12, 0.15, 0.2, 0.25, 0.3]
35+
eps_or_ef: [64, 96, 128, 160, 200, 250, 320, 400, 0.0, 0.005, 0.01, 0.02, 0.04, 0.06, 0.08, 0.1, 0.12, 0.15, 0.2, 0.25, 0.3]

‎examples/vibe/module.py‎

Lines changed: 13 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,7 @@ def __init__(
100100
self.opt_target = opt_target
101101
self.prune_non_rng = bool(prune_non_rng)
102102
self.threads = int(threads)
103-
self.search_eps = 0.1
104-
self.ef = 0
103+
self.eps_or_ef = 0.1
105104
self.metric_enum = _METRIC_MAP[self.metric][0]
106105
self.opt_enum = deglib.builder.OptimizationTarget[self.opt_target]
107106
self.graph = None
@@ -166,14 +165,8 @@ def fit(self, X: np.ndarray, cache_dir: Path | None = None):
166165
print(f"Optimized Searcher for the provided graph and hardware in {time.time() - t_km:.2f}s", flush=True)
167166

168167
def set_query_arguments(self, eps_or_ef: float | int):
169-
"""Sets query-time parameter: values > 1.0 are treated as ef, <= 1.0 as eps."""
170-
val = float(eps_or_ef)
171-
if val > 1.0:
172-
self.ef = int(round(val))
173-
self.search_eps = 0.0
174-
else:
175-
self.search_eps = val
176-
self.ef = 0
168+
"""Sets query-time parameter: values >= 1.0 are treated as ef, < 1.0 as eps."""
169+
self.eps_or_ef = float(eps_or_ef)
177170

178171
def query(self, v: np.ndarray, n: int) -> np.ndarray:
179172
"""Single query search on 1 thread with Float32 via C++ searcher."""
@@ -182,17 +175,16 @@ def query(self, v: np.ndarray, n: int) -> np.ndarray:
182175
return self.searcher.search(
183176
np.ascontiguousarray(v, dtype=np.float32),
184177
k=n,
185-
ef=self.ef,
186-
eps=self.search_eps,
178+
eps_or_ef=self.eps_or_ef,
187179
threads=1,
188180
return_distances=False,
189181
unsorted=True,
190182
)
191183

192184
def __str__(self) -> str:
193-
if self.ef > 0:
194-
return f"DEG(k={self.k}, opt={self.opt_target}, prune_rng={self.prune_non_rng}, ef={self.ef})"
195-
return f"DEG(k={self.k}, opt={self.opt_target}, prune_rng={self.prune_non_rng}, eps={self.search_eps})"
185+
if self.eps_or_ef >= 1.0:
186+
return f"DEG(k={self.k}, opt={self.opt_target}, prune_rng={self.prune_non_rng}, ef={int(round(self.eps_or_ef))})"
187+
return f"DEG(k={self.k}, opt={self.opt_target}, prune_rng={self.prune_non_rng}, eps={self.eps_or_ef})"
196188

197189

198190
class QG(BaseANN):
@@ -303,15 +295,9 @@ def fit(self, X: np.ndarray, cache_dir: Path | None = None):
303295
print(f"Optimized Searcher for the provided graph and hardware in {time.time() - t_km:.2f}s", flush=True)
304296

305297
def set_query_arguments(self, eps_or_ef: float | int, rerank_size_factor: float = 1.0):
306-
"""Sets query-time parameters: values > 1.0 are treated as ef, <= 1.0 as eps."""
298+
"""Sets query-time parameters: values >= 1.0 are treated as ef, < 1.0 as eps."""
307299
self.rerank_size_factor = float(rerank_size_factor)
308-
val = float(eps_or_ef)
309-
if val > 1.0:
310-
self.ef = int(round(val))
311-
self.search_eps = 0.0
312-
else:
313-
self.search_eps = val
314-
self.ef = 0
300+
self.eps_or_ef = float(eps_or_ef)
315301

316302
def query(self, v: np.ndarray, n: int) -> np.ndarray:
317303
"""Single query search on 1 thread with INT8 search and FP16 reranking directly in C++."""
@@ -320,22 +306,21 @@ def query(self, v: np.ndarray, n: int) -> np.ndarray:
320306
return self.searcher.search(
321307
np.ascontiguousarray(v, dtype=np.float32),
322308
k=n,
323-
ef=self.ef,
324-
eps=self.search_eps,
309+
eps_or_ef=self.eps_or_ef,
325310
rerank_factor=self.rerank_size_factor,
326311
threads=1,
327312
return_distances=False,
328313
unsorted=True,
329314
)
330315

331316
def __str__(self) -> str:
332-
if getattr(self, "ef", 0) > 0:
317+
if self.eps_or_ef >= 1.0:
333318
return (
334319
f"DEG-QG(k={self.k}, opt={self.opt_target}, prune_rng={self.prune_non_rng}, "
335-
f"rerank_factor={self.rerank_size_factor}, ef={self.ef})"
320+
f"rerank_factor={self.rerank_size_factor}, ef={int(round(self.eps_or_ef))})"
336321
)
337322
return (
338323
f"DEG-QG(k={self.k}, opt={self.opt_target}, prune_rng={self.prune_non_rng}, "
339-
f"rerank_factor={self.rerank_size_factor}, eps={self.search_eps})"
324+
f"rerank_factor={self.rerank_size_factor}, eps={self.eps_or_ef})"
340325
)
341326

‎python/API.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,27 @@ class Filter:
275275
# unsorted: If True, skips sorting the resulting candidates
276276
# Returns 2D uint32 array [Q, k_top] of candidate IDs, or (indices, distances) if return_distances is True.
277277
rerank(space, queries, candidate_indices, base_vectors=None, k_top=0, num_threads=0, return_distances=False, unsorted=False)
278+
279+
280+
# High-performance searcher with optional on-the-fly quantization and exact reranking.
281+
class Searcher:
282+
# Constructor via create_searcher(graph, quantizer=None, refine_space=None, refine_data=None)
283+
284+
# Entry vertex optimization via k-means medoids
285+
optimize(n_clusters=128, n_iter=15, sample_size=0, seed=42, num_threads=0)
286+
287+
# Search for nearest neighbors of a single query (1D) or a batch of queries (2D).
288+
# query: 1D vector [D] or 2D batch [N, D]
289+
# k: Number of nearest neighbors to return
290+
# eps_or_ef: Exploration parameter. Values >= 1.0 are treated as fixed pool size (ef),
291+
# values < 1.0 are treated as relative distance margin (eps). Default: 0.1.
292+
# rerank_factor: Candidate expansion factor for exact reranking (retrieves ceil(k * rerank_factor) candidates)
293+
# threads: Number of worker threads for batch queries
294+
# return_distances: If True, returns (indices, distances) tuple
295+
# unsorted: If True, returns candidates in heap order instead of ascending distance order
296+
search(query, k, eps_or_ef=0.1, rerank_factor=1.0, threads=1, return_distances=False, unsorted=False) -> np.ndarray | tuple[np.ndarray, np.ndarray]
297+
298+
create_searcher(graph, quantizer=None, refine_space=None, refine_data=None) -> Searcher
278299
```
279300

280301
---

‎python/README.md‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -250,10 +250,21 @@ Controls the topology optimization strategy:
250250
- `OptimizationTarget.HighLID`: Optimized for datasets with high local intrinsic dimensionality (supports multithreaded building).
251251
- `OptimizationTarget.StreamingData`: Optimized for continuous dynamic additions and deletions.
252252

253-
### Search Parameter `eps`
254-
- The epsilon parameter expands the search priority queue during graph exploration.
255-
- Small values (e.g. `eps=0.001` or `eps=0.01`): Faster query execution.
256-
- Higher values (e.g. `eps=0.1` to `eps=0.3`): Higher recall rate.
253+
### Unified Search Parameter: `eps_or_ef`
254+
`Searcher` provides a unified exploration parameter `eps_or_ef`:
255+
- **Relative Distance Margin (`eps_or_ef < 1.0`)**: Standard DEG dynamic distance exploration bound $(1 + \epsilon) \cdot d_{\text{cur}}$.
256+
- Small values (e.g. `eps_or_ef=0.001` or `0.01`): Faster query execution.
257+
- Higher values (e.g. `eps_or_ef=0.1` to `0.3`): Higher recall rate.
258+
- **Fixed Candidate Pool (`eps_or_ef >= 1.0`)**: HNSW-style search with a fixed candidate pool size of `round(eps_or_ef)` during exploration.
259+
- Typical values: `64`, `96`, `128`, `200`, `400`.
260+
261+
```python
262+
# Mode 1: Relative distance margin (default: eps_or_ef=0.1)
263+
indices, distances = searcher.search(query, k=10, eps_or_ef=0.1)
264+
265+
# Mode 2: Fixed candidate pool (HNSW-style)
266+
indices, distances = searcher.search(query, k=10, eps_or_ef=128)
267+
```
257268

258269
### Supported Metrics & Data Types
259270
- `Metric.FP32_L2`: Euclidean distance (`np.float32`)

‎python/src/deg_cpp/deglib_cpp.cpp‎

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -819,7 +819,7 @@ class SearcherPy {
819819
searcher_->optimize(n_clusters, n_iter, sample_size, seed, num_threads);
820820
}
821821

822-
py::object search(py::array query, uint32_t k, float eps = 0.1f, float rerank_factor = 1.0f, bool return_distances = false, bool unsorted = false, uint32_t ef = 0) {
822+
py::object search(py::array query, uint32_t k, float eps_or_ef = 0.1f, float rerank_factor = 1.0f, bool return_distances = false, bool unsorted = false) {
823823
auto buf = query.request();
824824
if (buf.ndim != 1 && (buf.ndim != 2 || (buf.shape[0] != 1 && buf.shape[1] != 1))) {
825825
throw std::invalid_argument("search query must be 1D vector (or 1xDim / Dimx1 2D array)");
@@ -835,17 +835,18 @@ class SearcherPy {
835835
}
836836

837837
uint32_t count = 0;
838-
if (ef > 0) {
838+
if (eps_or_ef >= 1.0f) {
839+
const uint32_t ef = static_cast<uint32_t>(std::lround(eps_or_ef));
839840
if (buf.itemsize == 2 && (buf.format == "H" || buf.format == "h" || buf.format == "e")) {
840841
count = searcher_->search_ef_f16(static_cast<const uint16_t*>(buf.ptr), k, ef, rerank_factor, out_ptr, dist_ptr, unsorted);
841842
} else {
842843
count = searcher_->search_ef_f32(static_cast<const float*>(buf.ptr), k, ef, rerank_factor, out_ptr, dist_ptr, unsorted);
843844
}
844845
} else {
845846
if (buf.itemsize == 2 && (buf.format == "H" || buf.format == "h" || buf.format == "e")) {
846-
count = searcher_->search_f16(static_cast<const uint16_t*>(buf.ptr), k, eps, rerank_factor, out_ptr, dist_ptr, unsorted);
847+
count = searcher_->search_f16(static_cast<const uint16_t*>(buf.ptr), k, eps_or_ef, rerank_factor, out_ptr, dist_ptr, unsorted);
847848
} else {
848-
count = searcher_->search_f32(static_cast<const float*>(buf.ptr), k, eps, rerank_factor, out_ptr, dist_ptr, unsorted);
849+
count = searcher_->search_f32(static_cast<const float*>(buf.ptr), k, eps_or_ef, rerank_factor, out_ptr, dist_ptr, unsorted);
849850
}
850851
}
851852

@@ -855,7 +856,7 @@ class SearcherPy {
855856
return result;
856857
}
857858

858-
py::object search_batch(py::array queries, uint32_t k, float eps = 0.1f, float rerank_factor = 1.0f, size_t num_threads = 1, bool return_distances = false, bool unsorted = false, uint32_t ef = 0) {
859+
py::object search_batch(py::array queries, uint32_t k, float eps_or_ef = 0.1f, float rerank_factor = 1.0f, size_t num_threads = 1, bool return_distances = false, bool unsorted = false) {
859860
auto buf = queries.request();
860861
if (buf.ndim != 2) {
861862
throw std::invalid_argument("search_batch queries must be 2D array");
@@ -873,17 +874,18 @@ class SearcherPy {
873874

874875
{
875876
py::gil_scoped_release release;
876-
if (ef > 0) {
877+
if (eps_or_ef >= 1.0f) {
878+
const uint32_t ef = static_cast<uint32_t>(std::lround(eps_or_ef));
877879
if (buf.itemsize == 2 && (buf.format == "H" || buf.format == "h" || buf.format == "e")) {
878880
searcher_->search_batch_ef_f16(static_cast<const uint16_t*>(buf.ptr), n_queries, k, ef, rerank_factor, out_ptr, dist_ptr, num_threads, unsorted);
879881
} else {
880882
searcher_->search_batch_ef_f32(static_cast<const float*>(buf.ptr), n_queries, k, ef, rerank_factor, out_ptr, dist_ptr, num_threads, unsorted);
881883
}
882884
} else {
883885
if (buf.itemsize == 2 && (buf.format == "H" || buf.format == "h" || buf.format == "e")) {
884-
searcher_->search_batch_f16(static_cast<const uint16_t*>(buf.ptr), n_queries, k, eps, rerank_factor, out_ptr, dist_ptr, num_threads, unsorted);
886+
searcher_->search_batch_f16(static_cast<const uint16_t*>(buf.ptr), n_queries, k, eps_or_ef, rerank_factor, out_ptr, dist_ptr, num_threads, unsorted);
885887
} else {
886-
searcher_->search_batch_f32(static_cast<const float*>(buf.ptr), n_queries, k, eps, rerank_factor, out_ptr, dist_ptr, num_threads, unsorted);
888+
searcher_->search_batch_f32(static_cast<const float*>(buf.ptr), n_queries, k, eps_or_ef, rerank_factor, out_ptr, dist_ptr, num_threads, unsorted);
887889
}
888890
}
889891
}
@@ -1586,13 +1588,32 @@ PYBIND11_MODULE(deglib_cpp, m) {
15861588
)
15871589
.def(
15881590
"search", &SearcherPy::search,
1589-
py::arg("query"), py::arg("k"), py::arg("eps") = 0.1f, py::arg("rerank_factor") = 1.0f,
1590-
py::arg("return_distances") = false, py::arg("unsorted") = false, py::arg("ef") = 0
1591+
py::arg("query"), py::arg("k"), py::arg("eps_or_ef") = 0.1f, py::arg("rerank_factor") = 1.0f,
1592+
py::arg("return_distances") = false, py::arg("unsorted") = false,
1593+
"Search for nearest neighbors of a single query vector.\n\n"
1594+
"Args:\n"
1595+
" query: 1D query vector (or 1xDim 2D array).\n"
1596+
" k: Number of nearest neighbors to return.\n"
1597+
" eps_or_ef: Exploration parameter. Values >= 1.0 are treated as fixed pool size (ef),\n"
1598+
" values < 1.0 are treated as relative distance margin (eps).\n"
1599+
" rerank_factor: Expansion factor for exact candidate reranking (default: 1.0).\n"
1600+
" return_distances: If True, returns a tuple of (indices, distances).\n"
1601+
" unsorted: If True, returns candidates in heap order instead of ascending distance."
15911602
)
15921603
.def(
15931604
"search_batch", &SearcherPy::search_batch,
1594-
py::arg("queries"), py::arg("k"), py::arg("eps") = 0.1f, py::arg("rerank_factor") = 1.0f,
1595-
py::arg("num_threads") = 1, py::arg("return_distances") = false, py::arg("unsorted") = false, py::arg("ef") = 0
1605+
py::arg("queries"), py::arg("k"), py::arg("eps_or_ef") = 0.1f, py::arg("rerank_factor") = 1.0f,
1606+
py::arg("num_threads") = 1, py::arg("return_distances") = false, py::arg("unsorted") = false,
1607+
"Search for nearest neighbors for a batch of query vectors.\n\n"
1608+
"Args:\n"
1609+
" queries: 2D query array (shape N x Dim).\n"
1610+
" k: Number of nearest neighbors to return per query.\n"
1611+
" eps_or_ef: Exploration parameter. Values >= 1.0 are treated as fixed pool size (ef),\n"
1612+
" values < 1.0 are treated as relative distance margin (eps).\n"
1613+
" rerank_factor: Expansion factor for exact candidate reranking (default: 1.0).\n"
1614+
" num_threads: Number of worker threads for parallel search.\n"
1615+
" return_distances: If True, returns a tuple of (indices, distances).\n"
1616+
" unsorted: If True, returns candidates in heap order instead of ascending distance."
15961617
)
15971618
.def(
15981619
"optimize", &SearcherPy::optimize,

0 commit comments

Comments
 (0)