Skip to content

Commit 3601cda

Browse files
committed
add auto_build_params heuristic; build/build_from_arrays default total_centroids=None
1 parent 5579923 commit 3601cda

4 files changed

Lines changed: 200 additions & 16 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "tachiom"
3-
version = "0.2.2"
3+
version = "0.2.3"
44
edition = "2024"
55

66
[lib]

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "maturin"
44

55
[project]
66
name = "tachiom"
7-
version = "0.2.2"
7+
version = "0.2.3"
88
description = "State-of-the-art index for late-interaction multivector retrieval"
99
readme = "README.md"
1010
requires-python = ">=3.9"

src/python.rs

Lines changed: 197 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,164 @@ use numpy::{
1818
};
1919
use pyo3::exceptions::{PyIOError, PyRuntimeError, PyValueError};
2020
use pyo3::prelude::*;
21-
use pyo3::types::PyType;
21+
use pyo3::types::{PyDict, PyType};
2222

23+
use std::collections::HashMap;
2324
use std::fs::File;
2425
use std::io::{BufReader, Read};
2526

2627
/// PQ subspace count. Hard-coded to match the rest of the codebase; the public
2728
/// `pq_subspaces` kwarg is validated against this value (warns if different).
2829
const M_FIXED: usize = 32;
2930

31+
/// Minimum points per centroid — mirrors the TAC allocation strategy constant.
32+
const MIN_PTS_PER_CENTROID: usize = 39;
33+
/// Safety factor applied to the minimum TAC budget when computing the floor.
34+
const TAC_FLOOR_FACTOR: f64 = 1.1;
35+
36+
// ============================================================================
37+
// Auto build-param heuristic
38+
// ============================================================================
39+
40+
/// Resolved TAC build parameters, returned by [`resolve_tac_params`].
41+
struct ResolvedTacParams {
42+
total_centroids: usize,
43+
micro_threshold: usize,
44+
small_threshold: usize,
45+
/// Saturation cap: adding more centroids beyond this is pointless.
46+
sat_cap: usize,
47+
/// Raw power-of-2 formula value, before floor / cap.
48+
formula: usize,
49+
/// True when an explicit `total_centroids` value was capped to `sat_cap`.
50+
was_capped: bool,
51+
}
52+
53+
/// Compute recommended TAC build parameters from the token-ID distribution.
54+
///
55+
/// All `_override` arguments mirror the user-facing kwargs: `None` → auto-compute.
56+
fn resolve_tac_params(
57+
token_ids: &[u32],
58+
total_centroids_override: Option<usize>,
59+
micro_override: Option<usize>,
60+
small_override: Option<usize>,
61+
) -> ResolvedTacParams {
62+
let n_tokens = token_ids.len().max(128);
63+
64+
// Nearest power-of-2 to n_tokens / 128.
65+
let exp = (n_tokens as f64 / 128.0).log2().round() as u32;
66+
let formula = 1usize << exp;
67+
68+
// Thresholds: nearest power-of-2 to n_tokens^(1/4), clamped to [32, 128].
69+
let micro_exp = (n_tokens as f64).powf(0.25).log2().round() as u32;
70+
let micro_auto = (1usize << micro_exp).clamp(32, 128);
71+
let small_auto = micro_auto * 2;
72+
let micro = micro_override.unwrap_or(micro_auto);
73+
let small = small_override.unwrap_or(small_auto);
74+
75+
// Token-type frequency histogram.
76+
let mut freq: HashMap<u32, usize> = HashMap::new();
77+
for &id in token_ids {
78+
*freq.entry(id).or_insert(0) += 1;
79+
}
80+
81+
let mut n_micro_t: usize = 0;
82+
let mut n_small_t: usize = 0;
83+
let mut n_active_t: usize = 0;
84+
let mut total_active_tokens: usize = 0;
85+
for &c in freq.values() {
86+
if c < micro {
87+
n_micro_t += 1;
88+
} else if c < small {
89+
n_small_t += 1;
90+
} else {
91+
n_active_t += 1;
92+
total_active_tokens += c;
93+
}
94+
}
95+
96+
let min_budget = n_micro_t + n_small_t * 2 + n_active_t * 4;
97+
let sat_cap = n_micro_t + n_small_t * 2 + total_active_tokens / MIN_PTS_PER_CENTROID;
98+
99+
// TAC floor: enough to run TAC with buffer, but never above sat_cap.
100+
let tac_floor = ((min_budget as f64 * TAC_FLOOR_FACTOR).ceil() as usize).min(sat_cap);
101+
102+
let (total_centroids, was_capped) = match total_centroids_override {
103+
Some(tc) if tc > sat_cap => (sat_cap, true),
104+
Some(tc) => (tc, false),
105+
None => (formula.max(tac_floor), false),
106+
};
107+
108+
ResolvedTacParams {
109+
total_centroids,
110+
micro_threshold: micro,
111+
small_threshold: small,
112+
sat_cap,
113+
formula,
114+
was_capped,
115+
}
116+
}
117+
118+
// ============================================================================
119+
// Python-exposed auto_build_params function
120+
// ============================================================================
121+
122+
/// Compute recommended TAC build parameters from a flat token-ID array.
123+
///
124+
/// Returns a dict with keys ``total_centroids``, ``tac_micro_threshold``,
125+
/// ``tac_small_threshold``, ``sat_cap``, and ``formula``.
126+
///
127+
/// Any kwarg set to a non-``None`` value overrides the heuristic for that
128+
/// parameter; ``None`` (default) triggers full auto-computation.
129+
///
130+
/// A ``UserWarning`` is emitted when an explicit ``total_centroids`` exceeds
131+
/// the saturation cap.
132+
#[pyfunction]
133+
#[pyo3(signature = (
134+
token_ids,
135+
*,
136+
total_centroids = None,
137+
tac_micro_threshold = None,
138+
tac_small_threshold = None,
139+
))]
140+
fn auto_build_params(
141+
py: Python<'_>,
142+
token_ids: PyReadonlyArray1<'_, u32>,
143+
total_centroids: Option<usize>,
144+
tac_micro_threshold: Option<usize>,
145+
tac_small_threshold: Option<usize>,
146+
) -> PyResult<Py<PyDict>> {
147+
let ids = token_ids
148+
.as_slice()
149+
.map_err(|_| PyValueError::new_err("token_ids must be C-contiguous"))?;
150+
151+
let p = resolve_tac_params(
152+
ids,
153+
total_centroids,
154+
tac_micro_threshold,
155+
tac_small_threshold,
156+
);
157+
158+
if p.was_capped {
159+
let msg = format!(
160+
"total_centroids={} exceeds the saturation point ({}). \
161+
Capping to {}. Extra centroids beyond this point would be empty.",
162+
total_centroids.unwrap(),
163+
p.sat_cap,
164+
p.sat_cap,
165+
);
166+
let warnings = py.import("warnings")?;
167+
warnings.call_method1("warn", (msg,))?;
168+
}
169+
170+
let dict = PyDict::new(py);
171+
dict.set_item("total_centroids", p.total_centroids)?;
172+
dict.set_item("tac_micro_threshold", p.micro_threshold)?;
173+
dict.set_item("tac_small_threshold", p.small_threshold)?;
174+
dict.set_item("sat_cap", p.sat_cap)?;
175+
dict.set_item("formula", p.formula)?;
176+
Ok(dict.into())
177+
}
178+
30179
// ============================================================================
31180
// Module
32181
// ============================================================================
@@ -35,6 +184,7 @@ const M_FIXED: usize = 32;
35184
fn tachiom(m: &Bound<'_, PyModule>) -> PyResult<()> {
36185
m.add_class::<PyTachiom>()?;
37186
m.add_class::<PyTac>()?;
187+
m.add_function(wrap_pyfunction!(auto_build_params, m)?)?;
38188
Ok(())
39189
}
40190

@@ -58,13 +208,13 @@ impl PyTachiom {
58208
token_ids_path,
59209
doclens_path,
60210
*,
61-
total_centroids = 4_194_304,
211+
total_centroids = None,
62212
tac_n_iter = 10,
63213
tac_micro_threshold = None,
64214
tac_small_threshold = None,
65215
pq_sample_size = 10_000_000,
66216
pq_n_iter = 10,
67-
normalize = false,
217+
normalize = true,
68218
pq_seed = 42,
69219
hnsw_m = 32,
70220
ef_construction = 1500,
@@ -77,7 +227,7 @@ impl PyTachiom {
77227
vectors_path: &str,
78228
token_ids_path: &str,
79229
doclens_path: &str,
80-
total_centroids: usize,
230+
total_centroids: Option<usize>,
81231
tac_n_iter: usize,
82232
tac_micro_threshold: Option<usize>,
83233
tac_small_threshold: Option<usize>,
@@ -92,12 +242,23 @@ impl PyTachiom {
92242
warn_pq_subspaces(py, pq_subspaces)?;
93243
let (dataset, token_ids) = load_input_dataset(vectors_path, token_ids_path, doclens_path)?;
94244

95-
let params = TachiomBuildParams {
96-
token_ids,
245+
let token_ids_u32: Vec<u32> = token_ids.iter().map(|&x| x as u32).collect();
246+
let resolved = resolve_tac_params(
247+
&token_ids_u32,
97248
total_centroids,
98-
tac_n_iter,
99249
tac_micro_threshold,
100250
tac_small_threshold,
251+
);
252+
if resolved.was_capped {
253+
warn_saturation_cap(py, total_centroids.unwrap(), resolved.sat_cap)?;
254+
}
255+
256+
let params = TachiomBuildParams {
257+
token_ids,
258+
total_centroids: resolved.total_centroids,
259+
tac_n_iter,
260+
tac_micro_threshold: Some(resolved.micro_threshold),
261+
tac_small_threshold: Some(resolved.small_threshold),
101262
pq_sample_size,
102263
pq_n_iter,
103264
normalize,
@@ -124,13 +285,13 @@ impl PyTachiom {
124285
token_ids,
125286
doclens,
126287
*,
127-
total_centroids = 4_194_304,
288+
total_centroids = None,
128289
tac_n_iter = 10,
129290
tac_micro_threshold = None,
130291
tac_small_threshold = None,
131292
pq_sample_size = 10_000_000,
132293
pq_n_iter = 10,
133-
normalize = false,
294+
normalize = true,
134295
pq_seed = 42,
135296
hnsw_m = 32,
136297
ef_construction = 1500,
@@ -143,7 +304,7 @@ impl PyTachiom {
143304
vectors: PyReadonlyArray2<'_, u16>,
144305
token_ids: PyReadonlyArray1<'_, u32>,
145306
doclens: PyReadonlyArray1<'_, i32>,
146-
total_centroids: usize,
307+
total_centroids: Option<usize>,
147308
tac_n_iter: usize,
148309
tac_micro_threshold: Option<usize>,
149310
tac_small_threshold: Option<usize>,
@@ -158,12 +319,25 @@ impl PyTachiom {
158319
warn_pq_subspaces(py, pq_subspaces)?;
159320
let (dataset, token_ids_vec) = dataset_from_arrays(&vectors, &token_ids, &doclens)?;
160321

161-
let params = TachiomBuildParams {
162-
token_ids: token_ids_vec,
322+
let ids_u32 = token_ids
323+
.as_slice()
324+
.map_err(|_| PyValueError::new_err("token_ids must be C-contiguous"))?;
325+
let resolved = resolve_tac_params(
326+
ids_u32,
163327
total_centroids,
164-
tac_n_iter,
165328
tac_micro_threshold,
166329
tac_small_threshold,
330+
);
331+
if resolved.was_capped {
332+
warn_saturation_cap(py, total_centroids.unwrap(), resolved.sat_cap)?;
333+
}
334+
335+
let params = TachiomBuildParams {
336+
token_ids: token_ids_vec,
337+
total_centroids: resolved.total_centroids,
338+
tac_n_iter,
339+
tac_micro_threshold: Some(resolved.micro_threshold),
340+
tac_small_threshold: Some(resolved.small_threshold),
167341
pq_sample_size,
168342
pq_n_iter,
169343
normalize,
@@ -704,6 +878,16 @@ impl PyTac {
704878
// Helpers
705879
// ============================================================================
706880

881+
fn warn_saturation_cap(py: Python<'_>, requested: usize, sat_cap: usize) -> PyResult<()> {
882+
let msg = format!(
883+
"total_centroids={requested} exceeds the saturation point ({sat_cap}). \
884+
Capping to {sat_cap}. Extra centroids beyond this point would be empty."
885+
);
886+
let warnings = py.import("warnings")?;
887+
warnings.call_method1("warn", (msg,))?;
888+
Ok(())
889+
}
890+
707891
fn warn_pq_subspaces(py: Python<'_>, pq_subspaces: usize) -> PyResult<()> {
708892
if pq_subspaces != M_FIXED {
709893
let msg = format!(

0 commit comments

Comments
 (0)