Expose search.vector_nprobes

The IVF probe count was the one vector search parameter with no
setting, and it is what bounds recall on a large indexed corpus:
partitions grow with the corpus, so a fixed probe count covers less of
it, and vector_refine_factor can only re-score what the probes
returned. Defaults to 20, matching lance, so search is unchanged.

Also corrects the create-index docs, which predate the measurement:
optimize() covers new chunks, so a rebuild is about retraining
centroids rather than reaching unindexed rows.
This commit is contained in:
Yiorgis Gozadinos 2026-09-01 15:54:36 +03:00
parent f7e9535d7b
commit 2d8b2cb9ed
No known key found for this signature in database
6 changed files with 46 additions and 7 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Added
- `search.vector_nprobes` (default 20) sets the IVF partitions each vector query searches.
### Documentation
- `docs/configuration/storage.md` "Vector Indexing" carries the measured with/without IVF_PQ retrieval comparison; `docs/benchmarks.md` states the published numbers are measured without a vector index.

View file

@ -402,15 +402,16 @@ haiku-rag create-index [--db /path/to/your.lancedb]
- Creates an IVF_PQ index using the configured `search.vector_index_metric` (cosine/l2)
**When to use:**
- After ingesting documents (indexes are not created automatically)
- After adding significant new data to rebuild the index
- Use `haiku-rag info` to check index status and see how many chunks are indexed/unindexed
- On a collection over 100,000 chunks (below that, brute-force kNN is exact and fast enough)
- After substantial corpus growth, to retrain the centroids
- Use `haiku-rag info` to check index status and see how many chunks are indexed/unindexed, or `haiku-rag doctor` for the same as a health check
See [Vector Indexing](configuration/storage.md#vector-indexing) for the measured accuracy and build cost.
**Search behavior:**
- Without index: Brute-force kNN search (exact nearest neighbors, slower for large datasets)
- With index: Fast ANN (approximate nearest neighbors) using IVF_PQ
- With stale index: LanceDB combines indexed results (fast ANN) + brute-force kNN on unindexed rows
- Performance degrades as more unindexed data accumulates
- With index: ANN (approximate nearest neighbors) using IVF_PQ, tuned by `search.vector_nprobes`
- Between a write and the next `optimize()`: LanceDB combines ANN over indexed rows with brute-force kNN over the remainder
### Rebuild Database

View file

@ -300,6 +300,7 @@ Configure vector search settings:
search:
vector_index_metric: cosine # cosine or l2
vector_refine_factor: 30 # Re-ranking factor for accuracy
vector_nprobes: 20 # IVF partitions searched per query
```
For search behavior settings (`limit`, `max_context_chars`), see [Search and Question Answering](qa.md#search-settings).
@ -309,6 +310,8 @@ For search behavior settings (`limit`, `max_context_chars`), see [Search and Que
- `l2`: Euclidean distance
- **vector_refine_factor**: Improves accuracy when using a vector index by retrieving `refine_factor * limit` candidates (using approximate search) and re-ranking them with exact distances. Higher values increase accuracy but slow down queries. Default: 30
- **Only applies with a vector index** - has no effect on brute-force search, which already returns exact results
- **vector_nprobes**: How many IVF partitions each query searches. Higher values increase recall and latency. A larger corpus holds more partitions, so the same value covers a smaller fraction of it. Default: 20
- **Only applies with a vector index** - ignored by brute-force search
!!! note
Vector indexes are only necessary for large datasets with over 100,000 chunks. For smaller datasets, LanceDB's brute-force kNN search provides exact results with good performance. Only create an index if you notice search performance degradation on large datasets.
@ -321,7 +324,7 @@ Retrieval MAP with and without an index, measured on copies of the benchmark dat
| `orb_multimodal_nemotron` | 121,168 | 2048 | 0.9799 | 0.9800 | +0.0001 | 25.8 s | 3.38 GB |
| `frames` | 425,940 | 2560 | 0.5431 | 0.5387 | -0.0044 | 34.1 s | 4.02 GB |
An index costs no accuracy at 70k and 121k chunks and 0.0044 MAP at 426k. A larger corpus holds more IVF partitions, so the default number of probes covers a smaller fraction of the space, and `vector_refine_factor` can only re-score what those probes returned. Build cost is near-flat in row count because training samples the data rather than scanning it, and vector dimension drives it more than corpus size.
An index costs no accuracy at 70k and 121k chunks and 0.0044 MAP at 426k. A larger corpus holds more IVF partitions, so the default number of probes covers a smaller fraction of the space, and `vector_refine_factor` can only re-score what those probes returned. Raise `vector_nprobes` to trade latency for recall on a large corpus. Build cost is near-flat in row count because training samples the data rather than scanning it, and vector dimension drives it more than corpus size.
**Index creation:**

View file

@ -306,6 +306,7 @@ class SearchConfig(ConfigModel):
max_context_chars: int = Field(default=5000, gt=0)
vector_index_metric: Literal["cosine", "l2"] = "cosine"
vector_refine_factor: int = Field(default=30, gt=0)
vector_nprobes: int = Field(default=20, gt=0)
class OllamaConfig(ConfigModel):

View file

@ -295,6 +295,7 @@ class ChunkRepository:
.column("vector")
.distance_type(self.store._config.search.vector_index_metric)
.refine_factor(self.store._config.search.vector_refine_factor)
.nprobes(self.store._config.search.vector_nprobes)
)
# An image query has no text to match, so it stays vector-only.
if search_type != "vector" and query.strip():

View file

@ -863,3 +863,32 @@ async def test_process_search_results_rejects_unknown_score_column(temp_db_path)
with pytest.raises(ValueError, match="Unknown search result format"):
await client.chunk_repository._process_search_results(_Frame())
@pytest.mark.parametrize("search_type", ["vector", "hybrid"])
async def test_search_applies_configured_nprobes(
temp_db_path, monkeypatch, search_type
):
"""The configured probe count reaches the vector query."""
from lancedb.query import AsyncVectorQueryBase
probed: list[int] = []
original = AsyncVectorQueryBase.nprobes
def record(self, nprobes):
probed.append(nprobes)
return original(self, nprobes)
monkeypatch.setattr(AsyncVectorQueryBase, "nprobes", record)
config = get_config()
config.search.vector_nprobes = 7
async with HaikuRAG(db_path=temp_db_path, config=config, create=True) as client:
await _import_one(client)
await client.chunk_repository.search(
"gardens",
search_type=search_type,
query_vector=[0.1] * config.embeddings.model.vector_dim,
)
assert probed == [7]