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
2 changes: 1 addition & 1 deletion docs/docs/index/hnsw.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ see [C++ integration](../cpp_integration.md).
| `M`, `ef_construction` | Graph connectivity and construction search budget; Python defaults are 16 and 200. `M` must be at least 2 and is capped at 10,000; `ef_construction` is raised to at least `M`. The base layer keeps up to `2 * M` edges per node. |
| `metric`, `seed` | Python defaults are `"l2"` and 100; `"ip"` is also supported. `seed` was called `random_seed` before 0.8. |
| `max_elements` | Python reports the current capacity, which `add` grows geometrically (at least doubling) and `resize` can preallocate. C++ `add` never grows it. |
| `k`, `ef` | Positive result count and search window. Python requires `k <= num_stored_points`, so a trained index without points rejects every `k`; omitted/zero `ef` uses `max(k, 10)`, positive `ef` is raised to at least `k`, and `ef` above the stored point count is clamped to it. |
| `k`, `ef` | Positive result count and search window. Python requires `k <= num_stored_points`, so a trained index without points rejects every `k`; omitted, `None` or zero `ef` uses `max(k, 10)`, positive `ef` is raised to at least `k`, and `ef` above the stored point count is clamped to it. |

`seed` controls graph levels and the FHT/Kac rotation (using its low 32 bits).
With identical inputs and parameters, single-threaded construction is reproducible
Expand Down
47 changes: 28 additions & 19 deletions docs/docs/index/qg.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,22 @@ step. The degree-32 example requires at least 33 data points.
from rabitqlib import SymqgIndex

# data and queries are float32 arrays with shape (count, dim).
index = SymqgIndex(dim=data.shape[1], max_degree=32, metric="l2", quantization_bits=0)
index.build(data, ef_construction=200, num_threads=1, init="pipnn")
index = SymqgIndex(data.shape[1], max_degree=32, ef_construction=200, init="pipnn")
index.add(data) # builds the whole graph
index.save("qg_example.index")

loaded = SymqgIndex.load("qg_example.index")
ids, distances = loaded.search_batch(queries, k=10, ef=100, num_threads=1)
ids, distances = loaded.search(queries, k=10, ef=100)
```

`search()` accepts the same arguments and is equivalent to `search_batch()`.
SymphonyQG follows the same `train` → `add` → `search` workflow as the other
indexes, but has nothing to train: `train(x)` only checks `x` as `add` would,
and `is_trained` is always `True`. `add` builds the graph from every vector at once
and can be called only once, as in Faiss's NSG index; a second call, or `add` on
a loaded index, raises `RuntimeError`. To index more vectors, create a new index
and add all of them. Point IDs are the row numbers of the added data. Files do
not store the construction settings, so a loaded index reports
`ef_construction`, `init` and `seed` as `None`.

### C++

Expand Down Expand Up @@ -64,15 +71,16 @@ qg.save("qg_example.index");
| Parameter | Contract |
| --- | --- |
| `dim` | Original input dimension; Python supports 64–65,536 and pads internally. |
| `max_degree` | Required positive multiple of 32, strictly smaller than the point count. |
| `quantization_bits` | 0 for owned raw vectors (default), or 4/8 for quantized storage. |
| `metric` | Python `"l2"` or `"ip"`; defaults to `"l2"`. |
| `ef_construction` | Required positive construction search window. |
| `init` | Python `"pipnn"` (default) or `"random"`. |
| `k`, `ef` | Python requires `1 <= k <= num_points` and `ef >= k`. |
| `num_threads` | Python defaults to 1; 0 selects available logical CPUs. |

Both Python search names return `(ids, distances)` arrays. Distances are squared
| `nbits` / C++ `quantization_bits` | Python keyword: 32 (default) for owned raw vectors, or 4/8 for quantized storage. C++ uses 0 for raw vectors, as Python's `quantization_bits` did before 0.8. |
| `max_degree` | Python keyword, default 32: a positive multiple of 32, strictly smaller than the point count. It is a hard degree bound, unlike HNSW-RaBitQ's `M`. |
| `ef_construction` | Python keyword, default 200: positive construction search window used by `add`. |
| `init` | Python keyword: `"pipnn"` (default) or `"random"`. |
| `metric` | Python keyword: `"l2"` (default) or `"ip"`. |
| `seed` | Python keyword: rotation and graph-construction seed in `[0, 2**32 - 1]`. The default `None` draws one at random; `index.seed` reports it, and passing it back with `num_threads=1` rebuilds the same graph from the same data. |
| `k`, `ef` | Python requires `1 <= k <= num_points`. Omitted or zero `ef` uses `max(k, 10)`, and smaller values are raised to `k`, as for HNSW-RaBitQ. |
| `num_threads` | Python `add` defaults to 0 (every available logical CPU) and `search` to 1. |

Python search returns `(ids, distances)` arrays. Distances are squared
L2 or `1 - dot`, ordered from smallest to largest; quantized storage returns
estimates. SymphonyQG has no incremental add/remove API. See shared
[input, count and concurrency contracts](usage.md).
Expand Down Expand Up @@ -117,7 +125,7 @@ as the random-initialization seed.
### Input lifetime and rebuilds

The caller can release input vectors after the `QGBuilder` constructor returns.
Python retains the caller's array during `build`. Complete construction before
Python retains the caller's array during `add`. Complete construction before
querying or saving.

`builder.reset(data)` reinitializes vectors of the same shape and reuses workspace.
Expand All @@ -135,7 +143,7 @@ The graph seed controls the rotation. An FHT/Kac seed gives the same rotation on
every platform; a MatrixRotator seed reproduces the matrix only with the same
standard library and build, so rely on the saved index rather than the seed.
The builder seed controls random initialization and fallback edges. PiPNN uses
fixed internal seeds.
fixed internal seeds. Python's `seed` sets both the graph seed and the builder seed.

## Querying

Expand Down Expand Up @@ -172,10 +180,11 @@ do no work and may use null buffers. Inputs and outputs must not overlap.
Concurrent searches need separate outputs; do not modify the index or call
`set_ef()` while searches are running.

Python `search_batch(queries, k, ef, num_threads=1)` and `search()` accept
the same two-dimensional query array. Both return `(ids, distances)` arrays
of shape `(num_queries, k)`, including empty batches. Both APIs default to one worker; `num_threads=0`
selects the available logical CPU count, capped by the number of queries.
Python `search(queries, k, ef=None, num_threads=1)` accepts a two-dimensional
query array and returns `(ids, distances)` arrays of shape `(num_queries, k)`,
including empty batches. It defaults to one worker; `num_threads=0` selects the
available logical CPU count, capped by the number of queries. The 0.7 name
`search_batch` still works in 0.8 with a `DeprecationWarning`.
Queries use the original dimension, including when the internal rotation pads it.

Python searches use per-call `ef` values. For access rules, see the
Expand Down
18 changes: 9 additions & 9 deletions docs/docs/index/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ storage before native processing; inputs remain unchanged.
Data, centroids and queries must be finite. Coordinates are also limited to the
magnitude the clustering APIs accept, `sqrt(sqrt(FLT_MAX) / (64 * dim))` (about
`5e7` for `dim = 128`), so that squared distances and their products stay inside
float32. `train`, `set_centroids`, `add` and `search` (and SymphonyQG `build`)
raise `ValueError` (C++:
float32. `train`, `set_centroids`, `add` and `search` raise `ValueError` (C++:
`std::invalid_argument`) for NaN, infinite, or larger coordinates before changing
the index. The C++ `quantize_*` encoders in `core/encoder.hpp` assume their
callers have validated the input.
Expand All @@ -45,8 +44,9 @@ IVF-RaBitQ, HNSW-RaBitQ, and SymphonyQG expose these read-only Python properties

`0 <= num_points <= num_stored_points <= max_elements`. Before vectors are
added, both point counts are zero; configured capacity may be nonzero. For
IVF-RaBitQ and HNSW-RaBitQ, `len(index)` equals `num_points`, and `is_trained`
reports whether centroids are set (by `train`, `set_centroids`, or `load`).
all three indexes, `len(index)` equals `num_points`. `is_trained` reports
whether IVF-RaBitQ or HNSW-RaBitQ centroids are set (by `train`, `set_centroids`,
or `load`), and is always `True` for SymphonyQG.

Removal decreases only `num_points`; slots and IDs remain occupied. Append IDs
start at the previous `num_stored_points`. Use this stored count for ID bounds,
Expand All @@ -55,10 +55,10 @@ and `num_points` for a live count. Counts survive save/load.
IVF-RaBitQ grows automatically and has no spare logical capacity after an append.
HNSW-RaBitQ's Python `add` grows its capacity geometrically, and `resize` changes
only capacity, for example to preallocate it. SymphonyQG has no incremental
updates; all three counts are equal after build.
updates: `add` builds the graph once, and all three counts are equal afterwards.

`repr(index)` shows dimension, metric, quantization bits and live point count,
with the training state for IVF/HNSW and the build state for SymphonyQG. IVF/HNSW also show stored count, capacity and centroid count; IVF includes
plus the training state for IVF/HNSW. IVF/HNSW also show stored count, capacity and centroid count; IVF includes
the resolved initializer, and SymphonyQG includes its degree bound. The summary
reads only metadata and follows the same concurrency rules as property reads.

Expand Down Expand Up @@ -119,7 +119,7 @@ construction, updates, and file I/O. The following contract applies to
| Operation on the same index | Concurrent access |
| --- | --- |
| `search`, `search_batch`, `save`, `repr`, and property reads | May run together |
| `train`, `set_centroids`, `build`, `add`, `remove`, `reserve`, `resize`, and setting `cp`, where available | Require exclusive access |
| `train`, `set_centroids`, `build`, `add`, `remove`, `reserve`, `resize`, and setting `cp`, where available | Require exclusive access, except SymphonyQG's `train`, which only checks its input and may run with reads |

A conflicting call immediately raises
`RuntimeError("Index is busy: conflicting operation in progress")`.
Expand All @@ -139,8 +139,8 @@ index updates themselves and use separate search output buffers.

`num_threads=0` selects the detected available logical CPU count. Positive values
set an upper limit, capped at that count; small workloads may use fewer workers.
IVF-RaBitQ and HNSW-RaBitQ `train` and `add` default to `0`, as does clustering;
`search`, the deprecated `build`, and SymphonyQG methods default to one thread.
`train` and `add` on every index default to `0`, as does clustering; `search`
and the deprecated `build` default to one thread.
Multithreaded HNSW-RaBitQ `add` links points in a nondeterministic order, so pass
`num_threads=1` when a build must be reproducible.
On Linux, CPU detection accounts for affinity and OpenMP binding. Apply binding
Expand Down
51 changes: 41 additions & 10 deletions docs/docs/python_migration.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
# Migrating Python code from 0.7 to 0.8

In 0.8, `IvfIndex` and `HnswIndex` follow a `train` → `add` → `search` workflow,
as Faiss indexes do: the index learns its own centroids, so you no longer run
k-means and pass the centroids and assignments to `build`. Code written for 0.7
In 0.8, `IvfIndex`, `HnswIndex` and `SymqgIndex` follow a `train` → `add` →
`search` workflow, as Faiss indexes do. IVF-RaBitQ and HNSW-RaBitQ learn their
own centroids, so you no longer run k-means and pass the centroids and
assignments to `build`; SymphonyQG has nothing to train and builds its graph in
`add`. Code written for 0.7
still runs in 0.8. Each old form raises a `DeprecationWarning` at your call site
and will be removed in **0.9**. Search calls are unchanged.
and will be removed in **0.9**.

## Old and new calls
Unless an item below says otherwise, a 0.7 call that raises a deprecation
warning keeps its 0.7 behavior. Calls that are valid in both versions, such as
`add(x)` and `search`, follow the 0.8 rules.

## IVF-RaBitQ and HNSW-RaBitQ

### Old and new calls

| 0.7 | 0.8 |
| --- | --- |
Expand All @@ -29,11 +37,7 @@ See [IVF-RaBitQ training](index/ivf.md#training-centroids-in-python) for the
details and for `set_centroids`. The [clustering classes](clustering.md) still
work if you want to run k-means yourself.

## Behavior changes

Unless an item says otherwise, a 0.7 call that raises a deprecation warning
keeps its 0.7 behavior. Calls that are valid in both versions, such as
`add(x)` and `search`, follow the 0.8 rules below.
### Behavior changes

- **Thread defaults.** `train` and `add` now default to every available CPU
(`num_threads=0`); 0.7 `build` and `add` defaulted to one thread. This applies
Expand Down Expand Up @@ -66,6 +70,33 @@ keeps its 0.7 behavior. Calls that are valid in both versions, such as
`set_centroids` raise `RuntimeError` for it. Create the index in the 0.8 form
to train it.

## SymphonyQG

| 0.7 | 0.8 |
| --- | --- |
| `SymqgIndex(dim, max_degree, metric, quantization_bits)` | `SymqgIndex(dim, max_degree=..., metric=..., nbits=...)`; every argument after `dim` is keyword-only |
| `quantization_bits=0` (raw vectors), `4`, `8` | `nbits=32` (raw vectors, the default), `4`, `8` |
| `index.build(x, ef_construction, num_threads, init)` | `SymqgIndex(dim, ef_construction=..., init=..., seed=...)`<br>`index.add(x, num_threads=...)` |
| `index.search_batch(q, k, ef)` | `index.search(q, k, ef)`; `ef` is now optional |
| `index.quantization_bits` | `index.nbits` (32 instead of 0 for raw vectors) |
| `index.is_built` | `len(index) > 0` |

`train(x)` exists for a uniform workflow but only checks `x` as `add` would,
and `is_trained` is always `True`. `add` builds the graph from all vectors and can run only once;
a second `add`, or `add` on a loaded index, raises `RuntimeError`, as Faiss's
NSG index does. The deprecated `build` keeps its 0.7 behavior: it replaces any
existing graph and, unless you passed `seed`, draws a new random seed each time.

`search` now treats `ef` like HNSW-RaBitQ: an omitted or zero `ef` uses
`max(k, 10)`, and a smaller one is raised to `k`. In 0.7, both raised
`ValueError`. `add` uses every CPU by default, while the deprecated `build`
keeps one thread. The seed now also drives graph construction: without `seed`,
each index draws one random seed and reports it as `index.seed`; 0.7 drew
separate random seeds for the rotation and the graph. Before vectors are added,
`search` and `save` raise `RuntimeError("SymqgIndex has no vectors; ...")`.

## Files and C++

Saved files, the `max_elements` property, and `search` results for the same
index are unchanged. Files written by 0.7 load as trained indexes.

Expand Down
119 changes: 119 additions & 0 deletions python_bindings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,12 +247,131 @@ def is_built(self):
cls.is_built = property(is_built, doc=is_built.__doc__)


def _symqg_signature(
self,
dim,
*,
nbits=32,
max_degree=32,
ef_construction=200,
init="pipnn",
metric="l2",
seed=None,
): ...


def _symqg_legacy_signature(
self, dim, max_degree, metric="l2", quantization_bits=0
): ...


def _symqg_nbits(quantization_bits):
# 0.7 stored raw vectors as 0 bits; 0.8 calls that 32.
if isinstance(quantization_bits, bool) or quantization_bits not in (0, 4, 8):
raise ValueError("quantization_bits must be 0, 4, or 8")
return 32 if quantization_bits == 0 else quantization_bits


def _patch_symqg(cls):
native_init = cls.__init__
signature = inspect.signature(_symqg_signature)
legacy_signature = inspect.signature(_symqg_legacy_signature)

def __init__(self, *args, **kwargs):
if len(args) > 1:
arguments = _bind(
legacy_signature,
"SymqgIndex",
(self, *args),
kwargs,
" (read as the 0.7 form SymqgIndex(dim, max_degree, metric, "
"quantization_bits); the 0.8 form is SymqgIndex(dim, max_degree=...))",
)
_deprecated(
"SymqgIndex(dim, max_degree, metric, quantization_bits) is deprecated; "
"use SymqgIndex(dim, max_degree=..., metric=..., nbits=...)"
)
nbits = _symqg_nbits(arguments["quantization_bits"])
native_init(
self,
arguments["dim"],
nbits=nbits,
max_degree=arguments["max_degree"],
metric=arguments["metric"],
)
return
quantization_bits = kwargs.pop("quantization_bits", _MISSING)
if quantization_bits is not _MISSING:
if "nbits" in kwargs:
raise TypeError(
"SymqgIndex() got both nbits and its old form quantization_bits"
)
_deprecated(
"SymqgIndex(quantization_bits=...) is deprecated; use nbits=, with 32 "
"instead of 0 for raw vectors"
)
kwargs["nbits"] = _symqg_nbits(quantization_bits)
native_init(self, **_bind(signature, "SymqgIndex", (self, *args), kwargs))

def build(self, data, ef_construction, num_threads=1, init="pipnn"):
"""Build the graph from input vectors, replacing any existing graph.

Deprecated since 0.8 and removed in 0.9: pass ef_construction and init to
the constructor, then call add() once. The defaults match 0.7, including
a single thread.

Parameters
----------
data : array_like of float, shape (n, dim)
Vectors in the original, unpadded coordinates, with n > max_degree.
Converted to contiguous float32; a copy may be made.
ef_construction : int
Positive construction search breadth fitting uint32.
num_threads : int, optional
Worker count. Default is 1; 0 selects the available CPU count.
init : {'pipnn', 'random'}, optional
Graph initialization method. Default is 'pipnn'.
"""
_deprecated(
"SymqgIndex.build() is deprecated; pass ef_construction and init to "
"SymqgIndex() and call add()"
)
self._build(data, ef_construction, num_threads, init)

def search_batch(self, queries, k, ef, num_threads=1):
"""Deprecated since 0.8: the same as search(); use search()."""
_deprecated("SymqgIndex.search_batch() is deprecated; use search()")
return self.search(queries, k, ef, num_threads)

def quantization_bits(self):
"""Deprecated since 0.8: storage bits with 0 for raw vectors; use nbits."""
_deprecated(
"SymqgIndex.quantization_bits is deprecated; use nbits, which is 32 "
"instead of 0 for raw vectors"
)
return self._quantization_bits

def is_built(self):
"""Deprecated since 0.8: whether the index holds a graph; use len()."""
_deprecated("SymqgIndex.is_built is deprecated; use len()")
return self.num_stored_points != 0

__init__.__doc__ = cls.__doc__
__init__.__signature__ = signature
cls.__init__ = __init__
cls.build = build
cls.search_batch = search_batch
cls.quantization_bits = property(quantization_bits, doc=quantization_bits.__doc__)
cls.is_built = property(is_built, doc=is_built.__doc__)


_patch_ivf_init(IvfIndex)
_patch_hnsw_init(HnswIndex)
for _cls in (IvfIndex, HnswIndex):
_patch_add(_cls)
_patch_build(_cls)
del _cls
_patch_symqg(SymqgIndex)
# --- end of the 0.8 compatibility layer -------------------------------------


Expand Down
Loading
Loading