From 1fc99c9b72bc8db68b0ca5d505b3f4af7f1c68ff Mon Sep 17 00:00:00 2001 From: tellet-q Date: Fri, 10 Jul 2026 13:05:31 +0200 Subject: [PATCH 1/2] feat: timed field-index creation; `bfb create-field-index` / `drop-field-index` Co-Authored-By: Claude Opus 4.8 (1M context) --- DEVELOPMENT.md | 2 +- README.md | 39 +++++++ src/args/mod.rs | 37 +++++++ src/collection/from_args.rs | 190 ++++++++++++++-------------------- src/collection/from_config.rs | 35 +++++-- src/collection/mod.rs | 136 ++++++++++++++++++++++-- src/main.rs | 102 +++++++++++++++++- src/results.rs | 49 +++++++-- 8 files changed, 446 insertions(+), 144 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index cac6169..f6452cd 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -6,7 +6,7 @@ src/ ├── args/ # clap Args struct + CLI value types (consistency, ordering) ├── config/ # YAML configs: upload (collection/vector/payload), search, schema reference ├── client.rs # Qdrant client construction + multi-client retry -├── collection/ # collection (re)creation: from CLI flags / from YAML config +├── collection/ # collection (re)creation, index wait, timed field-index creation ├── generators/ # data generation: points (legacy & config), queries, random primitives ├── search/ # search benchmarking processors (flag-driven & config-driven) ├── upload.rs # upload pipeline (parallelism, batching, progress) diff --git a/README.md b/README.md index a3332eb..cc8b8bd 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,40 @@ Read over Qdrant's **REST** API, on the port derived from `--uri` (6334 → 6333 A failure here warns rather than failing a run that already has its headline number; `--skip-server-stats` turns it off. +### `create-field-index` — time payload field-index construction + +Field indices are normally created together with the collection, while it is +still empty, so building them costs nothing and measures nothing. To measure +index construction over real data, defer them and build them afterwards: + +```bash +bfb upload --file config.yaml -n 1M --skip-field-indices +bfb create-field-index --file config.yaml --json field-index.json +``` + +``` +--- Field index creation --- +color (keyword): 0.316 s (server 0.277 s) +price (integer): 0.316 s (server 0.287 s) +Total: 0.632 s +``` + +Each index is created with `wait=true`, so the elapsed time reflects the build +rather than the request round-trip. Fields declared `index: false` are never +built — that is how unindexed filler payload is expressed. Per-field timings +land under `results.create_field_index` in `--json`. + +Indices are built one after another, so `Total` is the number to compare across +runs; a per-field time also carries whatever the fields before it left warm. To +attribute a cost to one field, build it alone, and use `drop-field-index` to re-measure +it on the same data: + +```bash +bfb create-field-index --file config.yaml --field color +bfb drop-field-index --file config.yaml --field color +bfb drop-field-index --file config.yaml # or every indexed field in the config +``` + ### `schema` — print the upload-config file schema Print an annotated YAML reference enumerating every option accepted by an @@ -237,6 +271,11 @@ bfb -n 1M --search --json results.json } ``` +`config.num_vectors` is the point (or query) count the run was *asked* for, from +`-n` or the YAML config — not what the collection turned out to hold. It is +omitted entirely for phase-only commands like `bfb create-field-index`, which +neither upload nor query. + Phases that did not run are omitted: `bfb search --file …` yields only `results.search`, and `precision` appears only when accuracy was measured (`--search-quality`, or a dataset query source with ground truth). Timings are diff --git a/src/args/mod.rs b/src/args/mod.rs index d98e1b9..367220f 100644 --- a/src/args/mod.rs +++ b/src/args/mod.rs @@ -449,11 +449,48 @@ pub enum Command { /// *how* the benchmark runs. Scroll(ScrollArgs), + /// Build payload field indices on an existing, populated collection, and + /// time each one. + /// + /// Upload with `--skip-field-indices` first, then run this to measure index + /// construction over real data rather than on an empty collection. + CreateFieldIndex(CreateFieldIndexArgs), + + /// Drop payload field indices, so they can be rebuilt and re-measured on + /// the same data without re-uploading. + DropFieldIndex(DropFieldIndexArgs), + /// Print the upload-config file schema: every option, its type, default, /// and allowed values, as an annotated YAML reference. Schema, } +#[derive(clap::Args, Debug, Clone)] +pub struct CreateFieldIndexArgs { + /// Path to the YAML collection-shape config file: every field it declares + /// with `index: true` is built. + #[clap(long)] + pub file: String, + + /// Build only this field's index. Repeatable. Timings for several fields + /// built together are order-dependent, so isolate a field by building it + /// alone (pair with `bfb drop-field-index` to repeat on the same data). + #[clap(long)] + pub field: Vec, +} + +#[derive(clap::Args, Debug, Clone)] +pub struct DropFieldIndexArgs { + /// Path to the YAML collection-shape config file: every field it declares + /// with `index: true` is dropped, unless narrowed with `--field`. + #[clap(long)] + pub file: String, + + /// Drop only this field's index. Repeatable. + #[clap(long)] + pub field: Vec, +} + #[derive(clap::Args, Debug, Clone)] pub struct SearchArgs { /// Path to the YAML search-request config file diff --git a/src/collection/from_args.rs b/src/collection/from_args.rs index ca5fa9f..b061729 100644 --- a/src/collection/from_args.rs +++ b/src/collection/from_args.rs @@ -22,6 +22,7 @@ use qdrant_client::qdrant::{ }; use tokio::time::sleep; +use super::{FieldIndexSpec, create_field_indices}; use crate::args::{Args, QuantizationArg}; use crate::client::random_client; use crate::generators::random::{ @@ -258,7 +259,7 @@ pub async fn recreate_collection(args: &Args, stopped: Arc) -> Resul sleep(Duration::from_secs(1)).await; if !args.skip_field_indices { - create_field_indices(args, &client).await?; + create_field_indices(&client, field_index_specs(args)).await?; } if let Some(shard_key) = &args.shard_key { @@ -279,153 +280,112 @@ pub async fn recreate_collection(args: &Args, stopped: Arc) -> Resul Ok(()) } -async fn create_field_indices(args: &Args, client: &qdrant_client::Qdrant) -> Result<()> { +/// Build the field-index requests implied by the CLI flags. +/// +/// Pure: performs no I/O, so a bad spec is reported rather than panicking +/// mid-creation. +fn field_index_specs(args: &Args) -> Vec { + let collection = args.collection_name.clone(); + let on_disk = args.on_disk_payload_index; + let tenant = args.tenants.unwrap_or_default(); + let mut specs = Vec::new(); + for (idx, _) in args.keywords.iter().enumerate() { - client - .create_field_index( - CreateFieldIndexCollectionBuilder::new( - args.collection_name.clone(), - format!("{}{}", payload_prefixes(idx), KEYWORD_PAYLOAD_KEY), - FieldType::Keyword, - ) + let field = format!("{}{}", payload_prefixes(idx), KEYWORD_PAYLOAD_KEY); + specs.push(FieldIndexSpec::new( + &field, + "keyword", + CreateFieldIndexCollectionBuilder::new(&collection, &field, FieldType::Keyword) .field_index_params( KeywordIndexParamsBuilder::default() - .on_disk(args.on_disk_payload_index) - .is_tenant(args.tenants.unwrap_or_default()), - ) - .wait(true), - ) - .await - .unwrap(); + .on_disk(on_disk) + .is_tenant(tenant), + ), + )); } for (idx, _) in args.float_payloads.iter().enumerate() { - client - .create_field_index( - CreateFieldIndexCollectionBuilder::new( - args.collection_name.clone(), - format!("{}{}", payload_prefixes(idx), FLOAT_PAYLOAD_KEY), - FieldType::Float, - ) + let field = format!("{}{}", payload_prefixes(idx), FLOAT_PAYLOAD_KEY); + specs.push(FieldIndexSpec::new( + &field, + "float", + CreateFieldIndexCollectionBuilder::new(&collection, &field, FieldType::Float) .field_index_params( FloatIndexParamsBuilder::default() - .on_disk(args.on_disk_payload_index) - .is_principal(args.tenants.unwrap_or_default()), - ) - .wait(true), - ) - .await - .unwrap(); + .on_disk(on_disk) + .is_principal(tenant), + ), + )); } for (idx, _) in args.int_payloads.iter().enumerate() { - client - .create_field_index( - CreateFieldIndexCollectionBuilder::new( - args.collection_name.clone(), - format!("{}{}", payload_prefixes(idx), INTEGERS_PAYLOAD_KEY), - FieldType::Integer, - ) + let field = format!("{}{}", payload_prefixes(idx), INTEGERS_PAYLOAD_KEY); + specs.push(FieldIndexSpec::new( + &field, + "integer", + CreateFieldIndexCollectionBuilder::new(&collection, &field, FieldType::Integer) .field_index_params( IntegerIndexParamsBuilder::new(true, args.int_payloads_range) - .on_disk(args.on_disk_payload_index) - .is_principal(args.tenants.unwrap_or_default()), - ) - .wait(true), - ) - .await - .unwrap(); + .on_disk(on_disk) + .is_principal(tenant), + ), + )); } if args.timestamp_payload { - client - .create_field_index( - CreateFieldIndexCollectionBuilder::new( - args.collection_name.clone(), - "timestamp", - FieldType::Datetime, - ) + specs.push(FieldIndexSpec::new( + "timestamp", + "datetime", + CreateFieldIndexCollectionBuilder::new(&collection, "timestamp", FieldType::Datetime) .field_index_params( DatetimeIndexParamsBuilder::default() - .on_disk(args.on_disk_payload_index) - .is_principal(args.tenants.unwrap_or_default()), - ) - .wait(true), - ) - .await - .unwrap(); + .on_disk(on_disk) + .is_principal(tenant), + ), + )); } if args.uuid_payloads { - client - .create_field_index( - CreateFieldIndexCollectionBuilder::new( - args.collection_name.clone(), - UUID_PAYLOAD_KEY, - FieldType::Uuid, - ) + specs.push(FieldIndexSpec::new( + UUID_PAYLOAD_KEY, + "uuid", + CreateFieldIndexCollectionBuilder::new(&collection, UUID_PAYLOAD_KEY, FieldType::Uuid) .field_index_params( UuidIndexParamsBuilder::default() - .is_tenant(args.tenants.unwrap_or_default()) - .on_disk(args.on_disk_payload_index), - ) - .wait(true), - ) - .await - .unwrap(); + .is_tenant(tenant) + .on_disk(on_disk), + ), + )); } if args.geo_payloads { - client - .create_field_index( - CreateFieldIndexCollectionBuilder::new( - args.collection_name.clone(), - GEO_PAYLOAD_KEY, - FieldType::Geo, - ) - .field_index_params( - GeoIndexParamsBuilder::new().on_disk(args.on_disk_payload_index), - ) - .wait(true), - ) - .await - .unwrap(); + specs.push(FieldIndexSpec::new( + GEO_PAYLOAD_KEY, + "geo", + CreateFieldIndexCollectionBuilder::new(&collection, GEO_PAYLOAD_KEY, FieldType::Geo) + .field_index_params(GeoIndexParamsBuilder::new().on_disk(on_disk)), + )); } if args.bool_payloads { - client - .create_field_index( - CreateFieldIndexCollectionBuilder::new( - args.collection_name.clone(), - BOOL_PAYLOAD_KEY, - FieldType::Bool, - ) - .field_index_params( - BoolIndexParamsBuilder::default().on_disk(args.on_disk_payload_index), - ) - .wait(true), - ) - .await - .unwrap(); + specs.push(FieldIndexSpec::new( + BOOL_PAYLOAD_KEY, + "bool", + CreateFieldIndexCollectionBuilder::new(&collection, BOOL_PAYLOAD_KEY, FieldType::Bool) + .field_index_params(BoolIndexParamsBuilder::default().on_disk(on_disk)), + )); } if args.text_payloads { - client - .create_field_index( - CreateFieldIndexCollectionBuilder::new( - args.collection_name.clone(), - TEXT_PAYLOAD_KEY, - FieldType::Text, - ) + specs.push(FieldIndexSpec::new( + TEXT_PAYLOAD_KEY, + "text", + CreateFieldIndexCollectionBuilder::new(&collection, TEXT_PAYLOAD_KEY, FieldType::Text) .field_index_params( - TextIndexParamsBuilder::new(TokenizerType::Word) - .on_disk(args.on_disk_payload_index), - ) - .wait(true), - ) - .await - .unwrap(); + TextIndexParamsBuilder::new(TokenizerType::Word).on_disk(on_disk), + ), + )); } - Ok(()) + specs } diff --git a/src/collection/from_config.rs b/src/collection/from_config.rs index a98c064..d5aa522 100644 --- a/src/collection/from_config.rs +++ b/src/collection/from_config.rs @@ -22,6 +22,7 @@ use qdrant_client::qdrant::{ }; use tokio::time::sleep; +use super::{FieldIndexSpec, create_field_indices}; use crate::args::Args; use crate::client::random_client; use crate::config::{ @@ -265,7 +266,7 @@ pub async fn recreate_collection_from_config( sleep(Duration::from_secs(1)).await; if !args.skip_field_indices { - create_field_indices_from_config(config, &client).await?; + create_field_indices(&client, field_index_specs_from_config(config)).await?; } if let Some(sharding) = &collection.sharding { @@ -285,10 +286,12 @@ pub async fn recreate_collection_from_config( Ok(()) } -async fn create_field_indices_from_config( - config: &UploadConfig, - client: &qdrant_client::Qdrant, -) -> Result<()> { +/// Build the field-index requests declared by the YAML config. +/// +/// Pure: performs no I/O, so the same specs can be applied when the collection +/// is created or later, on a populated collection (`bfb create-field-index`). +pub fn field_index_specs_from_config(config: &UploadConfig) -> Vec { + let mut specs = Vec::new(); for pc in &config.collection.fields { if !pc.index { continue; @@ -356,10 +359,28 @@ async fn create_field_indices_from_config( } }; - client.create_field_index(builder.wait(true)).await?; + specs.push(FieldIndexSpec::new( + &pc.name, + payload_type_name(pc.kind), + builder, + )); } - Ok(()) + specs +} + +/// Stable, human-readable name for a payload type, used in reports. +fn payload_type_name(kind: PayloadType) -> &'static str { + match kind { + PayloadType::Keyword => "keyword", + PayloadType::Integer => "integer", + PayloadType::Float => "float", + PayloadType::Bool => "bool", + PayloadType::Uuid => "uuid", + PayloadType::Geo => "geo", + PayloadType::Text => "text", + PayloadType::Datetime => "datetime", + } } #[cfg(test)] diff --git a/src/collection/mod.rs b/src/collection/mod.rs index 24121d5..3fc6c3e 100644 --- a/src/collection/mod.rs +++ b/src/collection/mod.rs @@ -1,43 +1,157 @@ -//! Collection lifecycle: (re)creation — from CLI flags or a YAML config — and -//! waiting for indexing to finish. +//! Collection lifecycle: (re)creation — from CLI flags or a YAML config — +//! waiting for indexing, and building payload field indices. mod from_args; mod from_config; pub use from_args::recreate_collection; -pub use from_config::recreate_collection_from_config; +pub use from_config::{field_index_specs_from_config, recreate_collection_from_config}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; +use std::time::{Duration, Instant}; -use anyhow::Result; -use qdrant_client::qdrant::CollectionStatus; +use anyhow::{Result, bail}; +use qdrant_client::qdrant::{ + CollectionInfo, CollectionStatus, CreateFieldIndexCollection, + CreateFieldIndexCollectionBuilder, DeleteFieldIndexCollectionBuilder, +}; use tokio::time::sleep; use crate::args::Args; use crate::client::random_client; +use crate::results::{CreateFieldIndexPhase, FieldIndexTiming}; + +/// One field index to create: the built request plus the labels used to report it. +pub struct FieldIndexSpec { + pub field: String, + /// Payload type name, e.g. `keyword`, `integer`. + pub kind: &'static str, + pub request: CreateFieldIndexCollection, +} + +impl FieldIndexSpec { + pub fn new( + field: &str, + kind: &'static str, + builder: CreateFieldIndexCollectionBuilder, + ) -> Self { + FieldIndexSpec { + field: field.to_string(), + kind, + // `wait(true)` is what makes the elapsed time meaningful: without it + // the server returns before the index is built. + request: builder.wait(true).build(), + } + } +} + +/// Create field indices and time each one. +/// +/// Called both while creating an empty collection (where the timings are +/// near-zero and ignored) and, via `bfb create-field-index`, on a populated +/// collection — which is the measurement the payload-indexing benchmarks want. +pub async fn create_field_indices( + client: &qdrant_client::Qdrant, + specs: Vec, +) -> Result { + let started = Instant::now(); + let mut fields = Vec::with_capacity(specs.len()); + + for spec in specs { + let field_started = Instant::now(); + let response = client.create_field_index(spec.request).await?; + fields.push(FieldIndexTiming { + field: spec.field, + kind: spec.kind.to_string(), + duration_secs: field_started.elapsed().as_secs_f64(), + server_secs: response.time, + }); + } + + Ok(CreateFieldIndexPhase { + duration_secs: started.elapsed().as_secs_f64(), + fields, + }) +} + +/// Drop the given field indices, so they can be rebuilt and re-measured on the +/// same data without re-uploading it. +pub async fn drop_field_indices( + client: &qdrant_client::Qdrant, + collection: &str, + fields: &[String], +) -> Result<()> { + for field in fields { + client + .delete_field_index( + DeleteFieldIndexCollectionBuilder::new(collection, field).wait(true), + ) + .await?; + println!("Dropped field index: {field}"); + } + Ok(()) +} /// Wait until the collection status is stably `Green`; returns the wait time /// in seconds. pub async fn wait_index(args: &Args, stopped: Arc) -> Result { let client = random_client(args)?; - let start = std::time::Instant::now(); + let start = Instant::now(); let mut seen = 0; + let mut last_report = Instant::now(); loop { if stopped.load(Ordering::Relaxed) { return Ok(0.0); } - sleep(Duration::from_secs(1)).await; let info = client.collection_info(&args.collection_name).await?; - if info.result.unwrap().status == CollectionStatus::Green as i32 { + let Some(result) = info.result else { + bail!( + "collection_info returned no result for {}", + args.collection_name + ); + }; + if result.status == CollectionStatus::Green as i32 { seen += 1; - if seen == 3 { + if seen == GREEN_CONFIRMATIONS { break; } } else { - seen = 1; + seen = 0; + if last_report.elapsed() >= INDEX_PROGRESS_EVERY { + report_index_progress(&result, start.elapsed()); + last_report = Instant::now(); + } } + sleep(Duration::from_secs(1)).await; } Ok(start.elapsed().as_secs_f64()) } + +/// Consecutive green polls required before the collection counts as indexed. The +/// optimizer is not scheduled synchronously with the request that triggers it, so a +/// single green reading can just mean it has not started yet. +const GREEN_CONFIRMATIONS: u32 = 3; + +/// How often to print a line while waiting, so a long rebuild does not look like a hang. +const INDEX_PROGRESS_EVERY: Duration = Duration::from_secs(5); + +fn report_index_progress(info: &CollectionInfo, elapsed: Duration) { + let status = match CollectionStatus::try_from(info.status) { + Ok(CollectionStatus::Yellow) => "yellow", + Ok(CollectionStatus::Grey) => "grey", + Ok(CollectionStatus::Red) => "red", + _ => "unknown", + }; + let indexed = info.indexed_vectors_count.unwrap_or(0); + let total = info.points_count.unwrap_or(0); + let pct = if total > 0 { + format!(" ({:.1}%)", 100.0 * indexed as f64 / total as f64) + } else { + String::new() + }; + println!( + " indexing: status={status}, {indexed}/{total} vectors{pct}, {:.0}s elapsed", + elapsed.as_secs_f64() + ); +} diff --git a/src/main.rs b/src/main.rs index bc460e3..5aff127 100644 --- a/src/main.rs +++ b/src/main.rs @@ -99,11 +99,91 @@ async fn run_scroll( let mut args = args; args.collection_name = config.collection.name.clone(); - let mut results = BenchmarkResults::new(&args, Some(scroll_args.file.clone())); + let mut results = BenchmarkResults::new( + &args, + Some(scroll_args.file.clone()), + Some(args.num_vectors_or_default()), + ); results.results.scroll = Some(query::scroll_with_config(&args, &config, stopped).await?); results.write_if_requested(&args) } +/// `bfb create-field-index`: build field indices on a populated collection. +async fn run_create_field_index( + args: Args, + create_args: args::CreateFieldIndexArgs, + stopped: Arc, +) -> Result<()> { + let config = config::load(&create_args.file)?; + + let mut args = args; + args.collection_name = config.collection.name.clone(); + let mut specs = collection::field_index_specs_from_config(&config); + + if !create_args.field.is_empty() { + let wanted = &create_args.field; + for name in wanted { + if !specs.iter().any(|spec| &spec.field == name) { + anyhow::bail!("--field {name:?} is not a declared, indexed field"); + } + } + specs.retain(|spec| wanted.contains(&spec.field)); + } + + if specs.is_empty() { + anyhow::bail!("no field indices to create: the config declares none with `index: true`"); + } + + let client = client::random_client(&args)?; + let phase = collection::create_field_indices(&client, specs).await?; + + println!("--- Field index creation ---"); + for field in &phase.fields { + println!( + "{} ({}): {:.3} s (server {:.3} s)", + field.field, field.kind, field.duration_secs, field.server_secs + ); + } + println!("Total: {:.3} s", phase.duration_secs); + + // Phase-only: neither uploads nor queries, so no requested point count. + let mut results = BenchmarkResults::new(&args, Some(create_args.file.clone()), None); + results.results.create_field_index = Some(phase); + + // `create_field_index(wait=true)` returns once the field index exists, but any + // optimizer work it schedules — notably the extra HNSW links for that field — + // runs afterwards. Waiting for it is what folds that cost into the index wait. + if !args.skip_wait_index { + results.results.index = Some(run_wait_index(&args, stopped).await?); + results.results.memory = fetch_memory(&args).await; + } + + results.write_if_requested(&args) +} + +/// `bfb drop-field-index`: remove field indices so they can be rebuilt and re-measured. +async fn run_drop_field_index(args: Args, drop_args: args::DropFieldIndexArgs) -> Result<()> { + let config = config::load(&drop_args.file)?; + + let mut args = args; + args.collection_name = config.collection.name.clone(); + let fields: Vec = if drop_args.field.is_empty() { + collection::field_index_specs_from_config(&config) + .into_iter() + .map(|spec| spec.field) + .collect() + } else { + drop_args.field.clone() + }; + + if fields.is_empty() { + anyhow::bail!("no field indices to drop"); + } + + let client = client::random_client(&args)?; + collection::drop_field_indices(&client, &args.collection_name, &fields).await +} + /// `bfb search --file config.yaml`: YAML-config-driven search. async fn run_search( args: Args, @@ -119,7 +199,11 @@ async fn run_search( println!("Ignoring `exact` flag because `search_quality` is also enabled!"); } - let mut results = BenchmarkResults::new(&args, Some(search_args.file.clone())); + let mut results = BenchmarkResults::new( + &args, + Some(search_args.file.clone()), + Some(args.num_vectors_or_default()), + ); results.results.search = Some(query::search_with_config(&args, &config, stopped).await?); results.write_if_requested(&args) } @@ -143,7 +227,11 @@ async fn run_upload( args.shard_key = Some(sharding.key.clone()); } - let mut results = BenchmarkResults::new(&args, Some(upload_args.file.clone())); + let mut results = BenchmarkResults::new( + &args, + Some(upload_args.file.clone()), + Some(args.num_vectors_or_default()), + ); if !args.skip_create && !args.skip_setup { collection::recreate_collection_from_config(&config, &args, stopped.clone()).await?; @@ -167,6 +255,12 @@ async fn run_benchmark(args: Args, stopped: Arc) -> Result<()> { Some(Command::Search(search_args)) => return run_search(args, search_args, stopped).await, Some(Command::Upload(upload_args)) => return run_upload(args, upload_args, stopped).await, Some(Command::Scroll(scroll_args)) => return run_scroll(args, scroll_args, stopped).await, + Some(Command::CreateFieldIndex(create_args)) => { + return run_create_field_index(args, create_args, stopped).await; + } + Some(Command::DropFieldIndex(drop_args)) => { + return run_drop_field_index(args, drop_args).await; + } // `Schema` is handled before the runtime starts; `None` falls through. Some(Command::Schema) | None => {} } @@ -175,7 +269,7 @@ async fn run_benchmark(args: Args, stopped: Arc) -> Result<()> { println!("Ignoring `exact` flag because `search_quality` is also enabled!"); } - let mut results = BenchmarkResults::new(&args, None); + let mut results = BenchmarkResults::new(&args, None, Some(args.num_vectors_or_default())); if !args.skip_create && !args.skip_setup { collection::recreate_collection(&args, stopped.clone()).await?; diff --git a/src/results.rs b/src/results.rs index a726179..1047f90 100644 --- a/src/results.rs +++ b/src/results.rs @@ -55,7 +55,11 @@ impl LegacySearcherResults { pub struct RunConfig { pub bfb_version: String, pub collection_name: String, - pub num_vectors: usize, + /// Points to upload (or queries to run). Absent for phase-only commands, + /// where no point count is requested and the CLI default would be a lie. + /// This is what was *asked for*, not what the collection ended up holding. + #[serde(skip_serializing_if = "Option::is_none")] + pub num_vectors: Option, pub batch_size: usize, pub parallel: usize, pub threads: usize, @@ -74,6 +78,8 @@ pub struct PhaseResults { #[serde(skip_serializing_if = "Option::is_none")] pub index: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub create_field_index: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub search: Option, #[serde(skip_serializing_if = "Option::is_none")] pub scroll: Option, @@ -82,6 +88,26 @@ pub struct PhaseResults { pub memory: Option, } +/// Post-upload field-index creation, timed per field. Optimizer work the build +/// schedules is reported under `index`, as for any other trigger. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct CreateFieldIndexPhase { + /// Time spent in the `create_field_index` calls themselves. + pub duration_secs: f64, + pub fields: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FieldIndexTiming { + pub field: String, + /// Payload type, e.g. `keyword`, `integer`. + pub kind: String, + /// Wall-clock time of the `wait=true` request. + pub duration_secs: f64, + /// Time the server reported for the operation. + pub server_secs: f64, +} + /// M2: upload wall time and throughput. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct UploadPhase { @@ -183,12 +209,14 @@ impl UploadPhase { } impl BenchmarkResults { - pub fn new(args: &Args, config_file: Option) -> Self { + /// `num_vectors` is the requested point/query count, or `None` for commands + /// that neither upload nor query (`create-field-index`, …). + pub fn new(args: &Args, config_file: Option, num_vectors: Option) -> Self { BenchmarkResults { config: RunConfig { bfb_version: env!("CARGO_PKG_VERSION").to_string(), collection_name: args.collection_name.clone(), - num_vectors: args.num_vectors_or_default(), + num_vectors, batch_size: args.batch_size, parallel: args.parallel, threads: args.threads, @@ -315,7 +343,7 @@ mod tests { config: RunConfig { bfb_version: "0.1.1".into(), collection_name: "benchmark".into(), - num_vectors: 100, + num_vectors: Some(100), batch_size: 8, parallel: 2, threads: 4, @@ -337,8 +365,7 @@ mod tests { upload: Some(UploadPhase::new(1.0, 100)), index: Some(IndexPhase { wait_secs: 0.5 }), search: Some(phase(1.0)), - scroll: None, - memory: None, + ..Default::default() }); doc.populate_legacy_fields(); @@ -388,6 +415,16 @@ mod tests { ); } + #[test] + fn phase_only_runs_omit_the_requested_point_count() { + // `bfb create-field-index` neither uploads nor queries, so echoing the + // CLI default (100k) would misdescribe the run. + let mut doc = doc(PhaseResults::default()); + doc.config.num_vectors = None; + let json = to_value(&doc); + assert!(json["config"].get("num_vectors").is_none()); + } + #[test] fn upload_only_run_emits_no_legacy_fields() { let mut doc = doc(PhaseResults { From 2e2e98affbb6645a18abbf62f6af7180cf203023 Mon Sep 17 00:00:00 2001 From: tellet-q <166374656+tellet-q@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:16:24 +0200 Subject: [PATCH 2/2] Fix comment Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/README.md b/README.md index cc8b8bd..428ed63 100644 --- a/README.md +++ b/README.md @@ -271,10 +271,7 @@ bfb -n 1M --search --json results.json } ``` -`config.num_vectors` is the point (or query) count the run was *asked* for, from -`-n` or the YAML config — not what the collection turned out to hold. It is -omitted entirely for phase-only commands like `bfb create-field-index`, which -neither upload nor query. +`config.num_vectors` is the point (or query) count the run was *asked* for, from `-n` (or the CLI default) — not what the collection turned out to hold. It is omitted entirely for phase-only commands like `bfb create-field-index`, which neither upload nor query. Phases that did not run are omitted: `bfb search --file …` yields only `results.search`, and `precision` appears only when accuracy was measured