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
4 changes: 3 additions & 1 deletion docs/ja/src/concepts/indexing/vector_indexing.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,9 @@ Stage 1 ではベクトルを int8 のみで保持します。グラフ検索は
ベクトルで再スコアする。
3. 新しいランキングを `top_k` に切り詰めて返す。

Stage 2 はフィールド単位で
Issue #932 以降、同じ sidecar 機構は共有 `RerankPipeline`(#650)を
通じて Flat / IVF でも機能します(以下の説明は Stage 2 の元ホストである
HNSW を例にしています)。Stage 2 はフィールド単位で
[`HnswOption.rerank_storage`](../../laurus-cli/schema_format.md#rerank-storage)
で opt-in します:

Expand Down
10 changes: 6 additions & 4 deletions docs/ja/src/laurus-cli/schema_format.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ base_weight = 1.0
| `distance` | `string` | `"Cosine"` | 距離メトリクス([距離メトリクス](#距離メトリクス)を参照) |
| `base_weight` | `float` | `1.0` | ハイブリッド検索のスコア融合における重み |
| `quantizer` | `object` | `"Scalar8Bit"` | 量子化方式([量子化](#量子化)を参照)。必須。デフォルトは Issue #481 Stage 1 で導入された int8 形式を保つ。 |
| `rerank_storage` | `string` | *(省略)* | [Rerank Storage](#rerank-storage) 用に予約。現状 sidecar を書き出すのは HNSW writer のみで、Flat / IVF はスキーマの対称性のためにフィールドを受け付けるが sidecar の書き出し・読み込みは行わない。 |
| `rerank_storage` | `string` | *(省略)* | Stage 2 rerank sidecar([Rerank Storage](#rerank-storage))。#932 以降、3 つのベクトルインデックスタイプすべてでサポート。`"F32"` でフィールド単位の f32 sidecar を有効化し、検索時に int8 候補を元のベクトルで再スコアできる。 |

#### Ivf

Expand All @@ -232,7 +232,7 @@ base_weight = 1.0
| `n_probe` | `integer` | `1` | クエリ時に検索するクラスタ数。大きいほど再現率が向上するが遅くなる |
| `base_weight` | `float` | `1.0` | ハイブリッド検索のスコア融合における重み |
| `quantizer` | `object` | `"Scalar8Bit"` | 量子化方式([量子化](#量子化)を参照)。必須。デフォルトは Issue #481 Stage 1 で導入された int8 形式を保つ。 |
| `rerank_storage` | `string` | *(省略)* | [Rerank Storage](#rerank-storage) 用に予約。現状 sidecar を書き出すのは HNSW writer のみで、Flat / IVF はスキーマの対称性のためにフィールドを受け付けるが sidecar の書き出し・読み込みは行わない。 |
| `rerank_storage` | `string` | *(省略)* | Stage 2 rerank sidecar([Rerank Storage](#rerank-storage))。#932 以降、3 つのベクトルインデックスタイプすべてでサポート。`"F32"` でフィールド単位の f32 sidecar を有効化し、検索時に int8 候補を元のベクトルで再スコアできる。 |

> **注意:** Hnsw および Flat とは異なり、Ivf の `dimension` フィールドは**必須**であり、デフォルト値はありません。

Expand Down Expand Up @@ -318,9 +318,11 @@ commit は大幅に高速化し、小さな per-commit segment も PQ を維持
## Rerank Storage

任意の Stage 2 sidecar(Issue #481)。元の完全精度ベクトルを int8
セグメントの隣に保持し、HNSW searcher が int8 で広めに候補を取得
セグメントの隣に保持し、searcher が int8 で広めに候補を取得
(高速)してから上位 `top_k * rerank_factor` 件を完全な f32 値で
再スコア(高精度)できるようにします。
再スコア(高精度)できるようにします。#932 以降、HNSW / Flat /
IVF の 3 タイプすべてでサポートされます(Flat / IVF の再スコアは
フィールド指定クエリに適用)。

sidecar はフィールド単位で `rerank_storage` で設定します:

Expand Down
2 changes: 1 addition & 1 deletion docs/ja/src/laurus-server/grpc_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ message AnalyzerDefinition {

`NONE`(量子化なし)は Issue #481 Stage 1 で廃止されました。proto enum 値 0(`QUANTIZATION_METHOD_NONE`)は wire 互換のため予約されていますが、サーバ側で受信すると `Default::default()`(`SCALAR_8BIT`)にフォールバックします。

**Rerank storage:** オプションの `rerank_storage` フィールド(enum `RerankStorageKind`: `UNSPECIFIED` = サイドカーなし、`F32`)は Stage-2 rerank サイドカー(Issue #481 / #793)を有効化します。HNSW フィールドで `F32` を設定すると、commit 時に完全精度の `.hnsw.f32` サイドカーを追加で書き出し、`rerank_factor` を指定した検索が int8 候補を元のベクトルで再スコアします。フィールドを省略(または `UNSPECIFIED`)すると Stage-1 の int8 のみのランキングになります。スキーマの round-trip 整合のため `FlatOption` / `IvfOption` にも保持されますが、これらのインデックスはまだサイドカーを出力しません
**Rerank storage:** オプションの `rerank_storage` フィールド(enum `RerankStorageKind`: `UNSPECIFIED` = サイドカーなし、`F32`)は Stage-2 rerank サイドカー(Issue #481 / #793)を有効化します。HNSW フィールドで `F32` を設定すると、commit 時に完全精度の `.hnsw.f32` サイドカーを追加で書き出し、`rerank_factor` を指定した検索が int8 候補を元のベクトルで再スコアします。フィールドを省略(または `UNSPECIFIED`)すると Stage-1 の int8 のみのランキングになります。#932 以降、サイドカーは 3 つのベクトルインデックスタイプ(HNSW / Flat / IVF)すべてで出力・利用されます(Flat / IVF の再スコアはフィールド指定クエリに適用)

**共有 PQ codebook:** `HnswOption` のオプションフィールド `pq_codebook_path`(Issue #631)は、`laurus train pq-codebook` CLI コマンドで一度だけ学習するストレージ相対の共有 PQ codebook ファイルを指定します。設定すると segment は commit / merge のたびに k-means を再学習する代わりに、学習済み codebook で encode されます。`PRODUCT_QUANTIZATION` quantizer との組み合わせでのみ意味を持ち、設定済みで未学習の場合、commit は学習コマンドを示すエラーで失敗します(per-segment 学習への無言のフォールバック無し)。未設定なら per-segment 学習のままです。

Expand Down
4 changes: 3 additions & 1 deletion docs/src/concepts/indexing/vector_indexing.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,9 @@ original full-precision vectors:
[LRS1 sidecar](#lrs1-rerank-sidecar) (`*.hnsw.f32`).
3. The new ranking is truncated to `top_k` and returned.

Stage 2 is opt-in per field via
Since #932 the same sidecar mechanism serves Flat and IVF too (the
shared `RerankPipeline`, #650); the description below uses HNSW, the
original Stage-2 host. Stage 2 is opt-in per field via
[`HnswOption.rerank_storage`](../../laurus-cli/schema_format.md#rerank-storage):

```rust
Expand Down
14 changes: 8 additions & 6 deletions docs/src/laurus-cli/schema_format.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ base_weight = 1.0
| `distance` | `string` | `"Cosine"` | Distance metric (see [Distance Metrics](#distance-metrics)) |
| `base_weight` | `float` | `1.0` | Scoring weight in hybrid search fusion |
| `quantizer` | `object` | `"Scalar8Bit"` | Quantization method (see [Quantization](#quantization)). Mandatory; default keeps the int8 format introduced in Issue #481 Stage 1. |
| `rerank_storage` | `string` | *(omit)* | Reserved for [Rerank Storage](#rerank-storage). Currently emitted only by the HNSW writer; Flat / IVF accept the field for schema symmetry but do not yet write or consume the sidecar. |
| `rerank_storage` | `string` | *(omit)* | Optional Stage 2 rerank sidecar (see [Rerank Storage](#rerank-storage)); supported by all three vector index types since #932. `"F32"` enables the per-field f32 sidecar so search can rescore int8 candidates against the original vectors. |

#### Ivf

Expand All @@ -232,7 +232,7 @@ base_weight = 1.0
| `n_probe` | `integer` | `1` | Number of clusters to search at query time. Higher = better recall, slower |
| `base_weight` | `float` | `1.0` | Scoring weight in hybrid search fusion |
| `quantizer` | `object` | `"Scalar8Bit"` | Quantization method (see [Quantization](#quantization)). Mandatory; default keeps the int8 format introduced in Issue #481 Stage 1. |
| `rerank_storage` | `string` | *(omit)* | Reserved for [Rerank Storage](#rerank-storage). Currently emitted only by the HNSW writer; Flat / IVF accept the field for schema symmetry but do not yet write or consume the sidecar. |
| `rerank_storage` | `string` | *(omit)* | Optional Stage 2 rerank sidecar (see [Rerank Storage](#rerank-storage)); supported by all three vector index types since #932. `"F32"` enables the per-field f32 sidecar so search can rescore int8 candidates against the original vectors. |

> **Note:** Unlike Hnsw and Flat, the `dimension` field in Ivf is **required** and has no default value.

Expand Down Expand Up @@ -317,10 +317,12 @@ with an error naming the `laurus train pq-codebook` command to run
## Rerank Storage

Optional Stage 2 sidecar (Issue #481) that keeps the original
full-precision vectors alongside the int8 segment so the HNSW
searcher can do a wide candidate fetch over int8 (cheap) and then
rescore the top `top_k * rerank_factor` candidates against the
exact f32 values (accurate).
full-precision vectors alongside the int8 segment so the searcher
can do a wide candidate fetch over int8 (cheap) and then rescore
the top `top_k * rerank_factor` candidates against the exact f32
values (accurate). Supported by all three vector index types —
HNSW, Flat, and IVF (#932); on Flat/IVF the rescoring applies to
field-routed queries.

The sidecar is configured per field with `rerank_storage`:

Expand Down
2 changes: 1 addition & 1 deletion docs/src/laurus-server/grpc_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ The `embedder` field in vector options specifies the name of an embedder defined

`NONE` (no quantization) was removed in Issue #481 Stage 1. The proto enum value `0` (`QUANTIZATION_METHOD_NONE`) is kept as a wire-compat reservation; if the server receives it, it falls back to `SCALAR_8BIT` via `Default::default()`.

**Rerank storage:** the optional `rerank_storage` field (enum `RerankStorageKind`: `UNSPECIFIED` = no sidecar, `F32`) enables the Stage-2 rerank sidecar (Issue #481 / #793). When set to `F32` on an HNSW field, commit writes an extra full-precision `.hnsw.f32` sidecar so searches that set `rerank_factor` rescore int8 candidates against the original vectors. Omitting the field (or `UNSPECIFIED`) keeps Stage-1 int8-only ranking. The field is also carried on `FlatOption` / `IvfOption` for schema round-tripping, but those indexes do not emit a sidecar yet.
**Rerank storage:** the optional `rerank_storage` field (enum `RerankStorageKind`: `UNSPECIFIED` = no sidecar, `F32`) enables the Stage-2 rerank sidecar (Issue #481 / #793). When set to `F32` on an HNSW field, commit writes an extra full-precision `.hnsw.f32` sidecar so searches that set `rerank_factor` rescore int8 candidates against the original vectors. Omitting the field (or `UNSPECIFIED`) keeps Stage-1 int8-only ranking. Since #932 the sidecar is emitted and consumed by all three vector index types (HNSW / Flat / IVF); on Flat/IVF the rescoring applies to field-routed queries.

**Shared PQ codebook:** the optional `pq_codebook_path` field on `HnswOption` (Issue #631) names a storage-relative shared PQ codebook file, trained once via the `laurus train pq-codebook` CLI command. Segments are then encoded against the pre-trained codebook instead of re-training k-means on every commit and merge. Only meaningful with a `PRODUCT_QUANTIZATION` quantizer; when set but not yet trained, commits fail with an error naming the training command (no silent fallback to per-segment training). Unset keeps per-segment training.

Expand Down
26 changes: 26 additions & 0 deletions laurus/src/vector/index/flat/reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ pub struct FlatVectorIndexReader {
/// once at load so `doc_ids_for_field` returns a refcount-shared
/// slice without re-cloning `vector_ids`. #405.
vector_ids_by_field: std::collections::HashMap<String, Arc<[u64]>>,
/// Stage 2 rerank sidecar pool (Issue #481, extended to Flat by
/// #650 PR-2 / #932). `Some` only when the `.f32` sidecar exists and
/// the loading mode is Eager; absence keeps Stage 1 behavior.
rerank_storage: Option<Arc<crate::vector::index::rerank_storage::RerankStoragePool>>,
}

/// Group `vector_ids` by field name into refcount-shared slices.
Expand Down Expand Up @@ -263,6 +267,20 @@ impl FlatVectorIndexReader {
};

let vector_ids_by_field = build_vector_ids_by_field(&vector_ids, &field_dict);

// Stage 2 rerank sidecar (Issue #481, extended to Flat by #650
// PR-2 / #932): loaded eagerly when present, mirroring HNSW. The
// pool's positions pair with `vector_ids` (the record order the
// writer also used for the sidecar payload) — an identity mapping.
// Lazy mode skips the sidecar to honor its memory-savings promise.
let rerank_storage = crate::vector::index::rerank_sidecar::load_rerank_sidecar(
storage.as_ref(),
&file_name,
dimension,
&vector_ids,
&field_dict,
)?;

Ok(Self {
vectors,
vector_ids,
Expand All @@ -271,9 +289,17 @@ impl FlatVectorIndexReader {
distance_metric,
deletion_bitmap: None,
vector_ids_by_field,
rerank_storage,
})
}

/// Borrow the optional Stage 2 rerank storage pool (#932).
pub fn rerank_storage(
&self,
) -> Option<&Arc<crate::vector::index::rerank_storage::RerankStoragePool>> {
self.rerank_storage.as_ref()
}

pub fn set_deletion_bitmap(&mut self, bitmap: Arc<DeletionBitmap>) {
self.deletion_bitmap = Some(bitmap);
}
Expand Down
77 changes: 68 additions & 9 deletions laurus/src/vector/index/flat/searcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,13 @@ impl VectorIndexSearcher for FlatVectorSearcher {
// filter. The store's post-filter still runs but becomes a no-op for
// the already-filtered results, so recall is unchanged.

// Issue #481 Stage 2 (rerank) -- API surface only in Stage 1.
if request.params.rerank_factor.is_some() {
return Err(crate::error::LaurusError::NotImplemented(
"Two-stage rerank (Issue #481 Stage 2) is not yet implemented. \
Pass rerank_factor = None for the Stage 1 quantized search."
.to_string(),
));
}
// Issue #481 Stage 2 rerank (extended to Flat by #650 PR-2 /
// #932): honored on the field-filtered path below when the reader
// has the `.f32` sidecar loaded. On the unfiltered path (or when
// the sidecar is absent) `rerank_factor` silently falls back to
// Stage 1 ranking — the same convention as HNSW, where a missing
// prerequisite (here: a single field to key the sidecar position
// index) cannot be recovered at query time.

let start = Timer::now();
let mut results = VectorIndexQueryResults::new();
Expand Down Expand Up @@ -137,14 +136,64 @@ impl VectorIndexSearcher for FlatVectorSearcher {
// would make top-k membership arbitrary; distance stays precise.
candidates.sort_unstable_by(|a, b| a.2.total_cmp(&b.2).then(a.0.cmp(&b.0)));

// Stage 2 (Issue #481 / #932): run the shared rerank pipeline
// over the sorted quantized candidates. When it applies, the
// survivors carry exact f32 distances — stamped below as the
// fan-out's `score_basis` (#927).
let mut rerank_applied = false;
if let (Some(factor), Some(pool)) = (
request.params.rerank_factor,
flat_reader.and_then(|r| r.rerank_storage()),
) {
use crate::vector::search::rerank::{
F32SidecarStage, RerankCandidates, RerankPipeline,
};
let pipeline = RerankPipeline::new(
vec![Box::new(F32SidecarStage::new(
std::sync::Arc::clone(pool),
field_name,
metric,
))],
vec![factor],
);
let mut rc = RerankCandidates::with_capacity(candidates.len());
for (doc_id, _, distance, _) in &candidates {
rc.push(*doc_id, *distance);
}
rerank_applied = pipeline.run(&request.query, &mut rc, request.params.top_k)?;
if rerank_applied {
// Rebuild on the exact basis; vectors are re-fetched in
// the result loop only when `include_vectors` asks.
candidates = rc
.doc_ids
.iter()
.zip(&rc.distances)
.map(|(&doc_id, &distance)| {
(
doc_id,
metric.distance_to_similarity(distance),
distance,
Vector::new(Vec::new()),
)
})
.collect();
}
}

let top_k = request.params.top_k.min(candidates.len());
for (doc_id, similarity, distance, vector) in candidates.into_iter().take(top_k) {
if similarity < request.params.min_similarity {
break;
}

let vector_output = if request.params.include_vectors {
Some(vector)
if rerank_applied {
// The rescored tuple carries a placeholder; fetch
// the real vector only for the final results.
self.index_reader.get_vector(doc_id, field_name)?
} else {
Some(vector)
}
} else {
None
};
Expand All @@ -159,6 +208,16 @@ impl VectorIndexSearcher for FlatVectorSearcher {
vector: vector_output,
});
}

// Issue #927: exact-f32 scores must be flagged so the
// multi-segment fan-out keeps them instead of overwriting
// with its dequantized rescore.
if rerank_applied {
results.query_metadata.insert(
crate::vector::search::searcher::SCORE_BASIS_METADATA_KEY.to_string(),
crate::vector::search::searcher::SCORE_BASIS_F32_RERANK.to_string(),
);
}
} else {
// Unfiltered path: each doc may belong to a different field, so the
// field name must travel with each candidate.
Expand Down
25 changes: 25 additions & 0 deletions laurus/src/vector/index/flat/writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,31 @@ impl VectorIndexWriter for FlatIndexWriter {
// power loss could surface a published-but-hollow segment file.
output.close()?;
storage.rename_file(&tmp_name, &file_name)?;

// Stage 2 (Issue #481, extended to Flat by #650 PR-2 / #932): emit
// the optional LRS1 rerank sidecar alongside the main int8 segment.
// The payload follows `self.vectors` — the exact record emission
// order above — so the reader's (sidecar position) -> (record
// position) mapping is the identity, mirroring HNSW.
if let Some(rerank_kind) = self.index_config.rerank_storage {
let sidecar_name = format!("{}.f32", file_name);
let sidecar_tmp = format!("{}.f32.tmp", file_name);
let mut sidecar_out = storage.create_output(&sidecar_tmp)?;
let mut payload: Vec<f32> =
Vec::with_capacity(self.vectors.len() * self.index_config.dimension);
for (_, _, v) in &self.vectors {
payload.extend_from_slice(&v.data);
}
crate::vector::index::rerank_sidecar::write_sidecar(
&mut sidecar_out,
rerank_kind,
self.index_config.dimension as u32,
&payload,
)?;
sidecar_out.flush()?;
drop(sidecar_out);
storage.rename_file(&sidecar_tmp, &sidecar_name)?;
}
Ok(())
}

Expand Down
Loading