Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ Required:

- Follow `.clang-format`, `.clang-tidy`, `.editorconfig`, and surrounding naming conventions:
`PascalCase` types, `snake_case` functions/variables/members, and `kPascalCase` constants.
- Across indexes, use the [index naming glossary](docs/development.md#index-variable-names)
for shared concepts. Distinguish capacity, stored/live counts, internal IDs/external labels,
original/padded dimensions, and storage sizes/offsets. Storage offsets default to bytes;
keep `*_offset_` names without a redundant `_bytes` suffix. Naming-only work must preserve
public/protected names, Python keywords, types, defaults, and serialized layouts unless
a compatibility change is explicitly approved.
- Public API misuse must produce descriptive exceptions rather than `exit()`, `abort()`, or
unconditional diagnostics. Assertions are for internal invariants. Keep library output opt-in.
- Make ownership and non-owning pointer lifetimes explicit. Wrap legacy owning factory pointers
Expand Down
38 changes: 38 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,44 @@ To format only lines changed in the staged files, use:
The complete-file formatter remains useful before the initial formatting pass
or after changing `.clang-format`; CI always checks complete files.

## Index variable names

Use the same name for the same role across IVF, HNSW and SymphonyQG. Choose the
role and unit first; similar-looking parameters can have different contracts.
These conventions apply to new internal variables and compatible renaming work.

| Concept | Name and meaning |
| --- | --- |
| Dimensions | `dim` is the original dimension at an index boundary; `padded_dim` is the rotated, padded dimension. A lower-level helper's `dim` describes its own input shape. |
| Counts | `num_points` for an input batch or a builder's point set; `num_queries` and `num_clusters` for those input counts. A partial encoding batch uses `batch_size`. |
| Index state | `max_elements_` for configured logical capacity; `num_stored_points_` for stored records, including removals. The public `num_points()` counts live records. QG has no removal and derives both counts from capacity only after construction completes. |
| Point identity | `internal_id` or `node_id` for graph slots; `label` for a caller-visible HNSW label; `cluster_id` for centroid routing. Never assume an external label equals an internal slot. |
| Search outputs | `k` for requested result count, `result_ids` for ID-only output arrays, `distances` for their scores. `results` may hold complete distance/ID records. |
| Search state | `candidate_set` for indexing traversal candidates and `search_pool` for querying traversal candidates, `result_pool` for retained results (HNSW retains `nearest_candidates` for query results and `top_candidates` for construction/pruning candidates), `query_wrapper` for query preprocessing objects, `visited` for visited state. |
| Search windows | `ef_search` for a per-call graph window, `default_ef_search_` for a stored default, and `ef_construction` for graph-building search breadth. IVF's `nprobe` counts clusters and remains distinct. |
| Distances | `query_centroid_ip` for a dot product, `residual_norm` for a residual norm, `estimated_distances` for estimated score arrays, `lower_bounds` for pruning bounds, and `dist_k` for the current result cutoff. Do not conflate squared L2 with L2 or an IP distance with a dot product. |
| Storage | Name byte sizes after the stored object: HNSW's `level0_record_bytes_` is one complete level-0 record; `upper_layer_links_bytes_` and `level0_links_bytes_` are one node's adjacency-list sizes at the respective levels, including their headers. QG uses `record_bytes_` for one complete record. Storage offsets use `*_offset_`: bytes are implicit, so do not add a `_bytes` suffix. PiPNN scratch uses `points_buffer_size`, `distances_buffer_size` and `worker_buffer_size`, with a declaration comment specifying float-element counts. |
| Threads and encoding | `num_threads` for a requested limit, `num_workers` for the resolved worker count requested from the runtime (OpenMP may use fewer threads), and `fast_quantization` for the fixed-scale encoding option. |

Keep short loop indices and conventional mathematical symbols where their role
is local and unambiguous. HNSW's `M` remains its algorithm-specific configuration;
its upper and base layers use `upper_layer_max_degree_` and
`base_layer_max_degree_`. QG's `max_degree_` is its fixed row degree, a multiple
of 32. These settings are not interchangeable.

Public names and Python keywords are compatibility contracts. For example,
`Cluster::num()`, the protected `Initializer::num_cluster_`, QG's
`degree_bound()`/`set_ef()`, Python's `ef`, and `QGKMeansParameters::ef_build`
retain their existing spellings. C++ parameter names may be clarified without
changing types, order or defaults. Keep declarations, definitions and comments
in agreement; do not rename methods or literals by textual substitution.

Storage-mode parameters also retain their contracts: IVF/HNSW use `nbits()`
with separate internal `ex_bits_`; IVF additionally accepts 32 for raw-vector
reranking. QG uses `quantization_bits()` with 0 for raw storage and 4/8 for packed
vectors. Naming work does not change these values, allocation sizes, numeric
operations, serialized field order, or ownership.

## Static analysis

clang-tidy performs semantic checks and is kept separate from clang-format.
Expand Down
86 changes: 45 additions & 41 deletions docs/docs/index/hnsw.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,40 +88,44 @@ input pointer and search candidate helpers are private implementation details;
see the [C++ migration guide](../modular_migration.md#public-and-implementation-interfaces).

```cpp
HierarchicalNSW::construct(size_t cluster_num,
const float* centroids,
size_t data_num,
const float* data,
PID* cluster_ids,
size_t num_threads = 0,
bool faster = false);
void HierarchicalNSW::construct(
size_t num_clusters,
const float* centroids,
size_t num_points,
const float* data,
PID* cluster_ids,
size_t num_threads = 0,
bool fast_quantization = false
);
```

- **data**: Vectors in the original input coordinates.
- **data_num**: The number of data vectors.
- **centroids**: Centroids computed by [RaBitQKMeans](../clustering.md#rabitqkmeans); the examples use `cluster_num = 16`.
- **cluster_ids**: Array of length `data_num`; every entry must be in the range `[0, cluster_num)`.
- **num_points**: The number of data vectors.
- **centroids**: Centroids computed by [RaBitQKMeans](../clustering.md#rabitqkmeans); the examples use `num_clusters = 16`.
- **cluster_ids**: Array of length `num_points`; every entry must be in the range `[0, num_clusters)`.
- **num_threads**: Number of threads to use (default: 0, which auto-selects).
- **faster**: Use calibrated scaling instead of per-vector scale search; see [encoding paths](../rabitq/quantizer.md#implementations).
- **fast_quantization**: Use calibrated scaling instead of per-vector scale search; see [encoding paths](../rabitq/quantizer.md#implementations).

## Querying

```cpp
std::vector<std::vector<std::pair<float, PID>>> HierarchicalNSW::search(const float* queries,
size_t query_num,
size_t TOPK,
size_t efSearch,
size_t thread_num);
std::vector<std::vector<std::pair<float, PID>>> HierarchicalNSW::search(
const float* queries,
size_t num_queries,
size_t k,
size_t ef_search,
size_t num_threads
);
```

- **queries**: Query vectors in the original input coordinates.
- **query_num**: The number of query vectors.
- **TOPK**: The number of nearest neighbors to search.
- **efSearch**: The size of the candidate set for searching HNSW-RaBitQ base layer.
- **thread_num**: Number of threads to use. Each query is processed by one thread.
- **num_queries**: The number of query vectors.
- **k**: The number of nearest neighbors to search.
- **ef_search**: The size of the candidate set for searching HNSW-RaBitQ base layer.
- **num_threads**: Number of threads to use. Each query is processed by one thread.

The search window belongs to each call, so concurrent C++ searches can use
different `efSearch` values and separate outputs while the index is unchanged.
different `ef_search` values and separate outputs while the index is unchanged.
Python follows the shared [concurrency contract](usage.md#threading-and-file-paths).

## Updating an index
Expand All @@ -134,21 +138,21 @@ the index file format; files with removals have
```c++
std::vector<PID> HierarchicalNSW::add(
const float* data,
size_t n,
size_t num_points,
const PID* cluster_ids = nullptr,
bool faster = false,
bool fast_quantization = false,
size_t num_threads = 1
);

void HierarchicalNSW::resize(size_t new_max_elements);
```

- **data**, **n**: `n` new vectors in the original coordinates. Vector `i` receives
- **data**, **num_points**: `num_points` new vectors in the original coordinates. Vector `i` receives
the label `num_stored_points() + i`, so labels stay dense, and `add` returns them.
- **cluster_ids**: The cluster of each new vector, in `[0, num_clusters)`. When it
is `nullptr`, each vector goes to its nearest centroid, chosen the same way a
query is routed.
- **faster**, **num_threads**: Same meaning as in `construct`, but `add` defaults
- **fast_quantization**, **num_threads**: Same meaning as in `construct`, but `add` defaults
to one thread; pass `0` to auto-select. A point keeps the label
`num_stored_points() + i` whatever order the threads finish in, because the slots are
reserved as one block before the inserts start.
Expand Down Expand Up @@ -203,10 +207,10 @@ of a rebuild.
### Removing points

```c++
size_t HierarchicalNSW::remove(const PID* labels, size_t n);
size_t HierarchicalNSW::remove(const PID* labels, size_t num_points);
```

- **labels**, **n**: Labels of the points to remove, as returned by `add` and
- **labels**, **num_points**: `num_points` labels of the points to remove, as returned by `add` and
`search`. Every label must be in the index; nothing is removed if one is not.
`remove` returns how many points were newly removed, so repeating a label or a
call is safe.
Expand All @@ -227,15 +231,15 @@ exclusive access as other updates.

#### Choosing `ef` after removals

Removed points still take slots in the `efSearch` candidate set, so a search with
`efSearch` close to `k` finds fewer live points as removals accumulate. C++ search
uses `max(efSearch, k)`. Python uses `max(k, 10)` when `ef` is omitted or zero;
Removed points still take slots in the `ef_search` candidate set, so a search with
`ef_search` close to `k` finds fewer live points as removals accumulate. C++ search
uses `max(ef_search, k)`. Python uses `max(k, 10)` when `ef` is omitted or zero;
an explicit positive `ef` is also raised to at least `k`.
To keep about the same number of live candidates as before the removals, scale
the effective window by the share of live points:

```text
max(efSearch, k) * num_stored_points() / (num_stored_points() - removed points)
max(ef_search, k) * num_stored_points() / (num_stored_points() - removed points)
```

Callers that track how many points they removed can apply this directly.
Expand Down Expand Up @@ -298,9 +302,9 @@ In the upper layers of HNSW-RaBitQ, we compute the 1-bit estimated distance (usi

In the base layer, we apply an adaptive re-ranking strategy:

- **candidate_set**: A distance-sorted exploration window with capacity
`max(efSearch, TOPK)`, containing both expanded and unexpanded candidates.
- **boundedKNN**: Current best TOPK candidates.
- **search_pool**: A distance-sorted exploration window with capacity
`max(ef_search, k)`, containing both expanded and unexpanded candidates.
- **nearest_candidates**: Current best k candidates.

Repeat while the exploration window has an unexpanded candidate:

Expand All @@ -309,21 +313,21 @@ Repeat while the exploration window has an unexpanded candidate:
2. Visit all unvisited neighbors of `e`.
3. For each neighbor:
- Compute the 1-bit lower-bound distance (along with 1-bit estimated distance) using `BinData`.
- For a nonremoved neighbor, if `boundedKNN` has fewer than TOPK elements
- For a nonremoved neighbor, if `nearest_candidates` has fewer than k elements
or the neighbor's lower bound is smaller than the worst result's estimated distance:
1. Refine the distance estimate using `ExData` when extra bits are present.
2. Update `boundedKNN`.
- Insert the neighbor into `candidate_set` only if the window has space or
2. Update `nearest_candidates`.
- Insert the neighbor into `search_pool` only if the window has space or
its (possibly refined) estimated distance is no greater than the worst
distance in the window. Inserting into a full window displaces its last entry.

The search stops when no unexpanded candidates remain in the window, even though
the window still contains entries. `efSearch` controls this exploration budget
separately from the TOPK result count; increasing it allows a wider search at
the window still contains entries. `ef_search` controls this exploration budget
separately from the k result count; increasing it allows a wider search at
additional cost, rather than requiring traversal of the entire graph.

A point hidden by `remove` never enters `boundedKNN`, so search never returns it,
but it can still enter `candidate_set` under the same window admission rule,
A point hidden by `remove` never enters `nearest_candidates`, so search never returns it,
but it can still enter `search_pool` under the same window admission rule,
allowing search to walk through it. A removed entry point keeps the estimate
already computed for it (the full-bits one when nbits > 1). A removed neighbor
is not refined and uses its 1-bit estimate for window admission.
Expand Down
44 changes: 22 additions & 22 deletions docs/docs/index/ivf.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,9 @@ using index_type = rabitqlib::ivf::IVF;

size_t num_points = data.rows();
size_t dim = data.cols();
size_t k = centroids.rows();
size_t num_clusters = centroids.rows();

index_type ivf(num_points, dim, k, total_bits);
index_type ivf(num_points, dim, num_clusters, total_bits);
```

### Centroid routing
Expand All @@ -131,7 +131,7 @@ print(index.initializer) # resolved routing type
```

```c++
index_type ivf(num_points, dim, k, total_bits, rabitqlib::METRIC_L2,
index_type ivf(num_points, dim, num_clusters, total_bits, rabitqlib::METRIC_L2,
rabitqlib::RotatorType::FhtKacRotator,
rabitqlib::ivf::InitializerType::FlatRaBitQ);
auto routing = ivf.initializer_type();
Expand Down Expand Up @@ -174,18 +174,18 @@ Finally, call the construct API:

```c++
void IVF::construct(
const float* data,
const float* centroids,
const PID* cluster_ids,
bool faster = false,
const float* data,
const float* centroids,
const PID* cluster_ids,
bool fast_quantization = false,
size_t num_threads = std::numeric_limits<size_t>::max()
);
```

- **data**: Vectors in the original input coordinates.
- **centroids**: One centroid per configured cluster, in the original input coordinates.
- **cluster_ids**: One ID per input vector, in `[0, num_clusters())`.
- **faster**: Use a calibrated scaling factor instead of per-vector scale search; defaults to `false`. See [encoding paths](../rabitq/quantizer.md#implementations).
- **fast_quantization**: Use a calibrated scaling factor instead of per-vector scale search; defaults to `false`. See [encoding paths](../rabitq/quantizer.md#implementations).
- **num_threads**: Maximum number of OpenMP threads used to quantize clusters.

For example:
Expand All @@ -207,19 +207,19 @@ Search a constructed or loaded index with:

```c++
void IVF::search(
const float* __restrict__ query,
size_t k,
size_t nprobe,
PID* __restrict__ results,
float* dists = nullptr
const float* query,
size_t k,
size_t nprobe,
PID* result_ids,
float* distances = nullptr
) const;
```

- **query**: Query vector.
- **k**: Top-k.
- **nprobe**: The number of closest clusters to search.
- **results**: Result buffer, size of k.
- **dists**: Optional distance buffer, size of `k`.
- **result_ids**: Output ID buffer, size of `k`.
- **distances**: Optional distance buffer, size of `k`.

FastScan precision is selected automatically: HACC for 4–9 quantized bits, and
standard FastScan for 1–3 bits or raw vectors (`32`). HACC reduces lookup-table
Expand Down Expand Up @@ -271,25 +271,25 @@ or loaded index without the original dataset. Updates preserve the file format.
```c++
void IVF::add(
const float* data,
size_t n,
size_t num_points,
const PID* cluster_ids = nullptr,
bool faster = false,
bool fast_quantization = false,
size_t num_threads = std::numeric_limits<size_t>::max()
);

size_t IVF::remove(const PID* ids_to_remove, size_t n);
size_t IVF::remove(const PID* ids_to_remove, size_t num_points);

void IVF::reserve(PID cluster_id, size_t num_points);
```

- **data**, **n**: `n` new vectors, quantized like `construct` does. Vector `i`
- **data**, **num_points**: `num_points` new vectors, quantized like `construct` does. Vector `i`
receives the PID `num_stored_points() + i`, using the count before the call. Existing
IDs stay unchanged, and removed IDs are never reused.
- **cluster_ids**: The cluster of each new vector, in `[0, cluster_num)`. When
- **cluster_ids**: The cluster of each new vector, in `[0, num_clusters)`. When
it is `nullptr`, vectors use the same centroid routing as queries. Routing uses
the initializer selected at construction or restored from the index file.
- **faster**, **num_threads**: Same as in `construct`.
- **ids_to_remove**: PIDs to remove. Every PID must be below `num_stored_points()`; nothing is
- **fast_quantization**, **num_threads**: Same as in `construct`.
- **ids_to_remove**: `num_points` PIDs to remove. Every PID must be below `num_stored_points()`; nothing is
removed if one is not. `remove` returns how many were newly removed and can be
repeated safely.
- **cluster_id**, **num_points**: `reserve` preallocates room for cluster `cluster_id`
Expand Down
Loading
Loading