diff --git a/CHANGELOG.md b/CHANGELOG.md index 719fcbe6..8b5740b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ - Migration to 0.38.0 no longer fails with `UnicodeDecodeError` on a `docling_document` blob written as zstd. +- Vector search explicitly uses `search.vector_index_metric`, so flat and indexed + searches rank with the same configured distance metric. The unused `dot` option + is removed; `cosine` and `l2` remain. ## [0.79.0] - 2026-08-28 diff --git a/docs/cli.md b/docs/cli.md index 249a4060..e300a8ed 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -398,7 +398,7 @@ haiku-rag create-index [--db /path/to/your.lancedb] **Requirements:** - Minimum 256 chunks required for index creation (LanceDB training data requirement) -- Creates an IVF_PQ index using the configured `search.vector_index_metric` (cosine/l2/dot) +- 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) diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 8710d7fe..165fa13e 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -114,7 +114,7 @@ qa: search: limit: 5 # Default number of results to return max_context_chars: 5000 # Maximum characters in expanded context - vector_index_metric: cosine # cosine, l2, or dot + vector_index_metric: cosine # cosine or l2 vector_refine_factor: 30 doctor: diff --git a/docs/configuration/storage.md b/docs/configuration/storage.md index d5454433..e9e9df2b 100644 --- a/docs/configuration/storage.md +++ b/docs/configuration/storage.md @@ -300,7 +300,7 @@ Configure vector search settings: ```yaml search: - vector_index_metric: cosine # cosine, l2, or dot + vector_index_metric: cosine # cosine or l2 vector_refine_factor: 30 # Re-ranking factor for accuracy ``` @@ -309,7 +309,6 @@ For search behavior settings (`limit`, `max_context_chars`), see [Search and Que - **vector_index_metric**: Distance metric for vector similarity: - `cosine`: Cosine similarity (default, best for most embeddings) - `l2`: Euclidean distance - - `dot`: Dot product similarity - **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 diff --git a/haiku_rag_slim/haiku/rag/config/models.py b/haiku_rag_slim/haiku/rag/config/models.py index a8a938c5..2eefddfe 100644 --- a/haiku_rag_slim/haiku/rag/config/models.py +++ b/haiku_rag_slim/haiku/rag/config/models.py @@ -304,7 +304,7 @@ class ProcessingConfig(ConfigModel): class SearchConfig(ConfigModel): limit: int = Field(default=5, gt=0) max_context_chars: int = Field(default=5000, gt=0) - vector_index_metric: Literal["cosine", "l2", "dot"] = "cosine" + vector_index_metric: Literal["cosine", "l2"] = "cosine" vector_refine_factor: int = Field(default=30, gt=0) diff --git a/haiku_rag_slim/haiku/rag/store/repositories/chunk.py b/haiku_rag_slim/haiku/rag/store/repositories/chunk.py index c8638129..5ab1ba9c 100644 --- a/haiku_rag_slim/haiku/rag/store/repositories/chunk.py +++ b/haiku_rag_slim/haiku/rag/store/repositories/chunk.py @@ -254,6 +254,7 @@ class ChunkRepository: self.store.chunks_table.query() .nearest_to(query_embedding) .column("vector") + .distance_type(self.store._config.search.vector_index_metric) .refine_factor(self.store._config.search.vector_refine_factor) ) # An image query has no text to match, so it stays vector-only. diff --git a/tests/test_chunk.py b/tests/test_chunk.py index aa5f503a..242ba26b 100644 --- a/tests/test_chunk.py +++ b/tests/test_chunk.py @@ -570,6 +570,82 @@ async def test_chunk_search_with_precomputed_vector_skips_text_query(temp_db_pat assert any(c.document_id == doc.id for c, _ in results) +@pytest.mark.parametrize( + "metric,expected_ids", + [ + ("cosine", ["cosine-best", "l2-best"]), + ("l2", ["l2-best", "cosine-best"]), + ], +) +async def test_vector_metric_ranking_matches_before_and_after_index( + temp_db_path, metric, expected_ids +): + """Flat and indexed searches honor the configured metric. + + The two leading vectors are deliberately not unit-normalized: cosine and + L2 must rank them in opposite orders, so LanceDB's flat-search L2 default + cannot accidentally satisfy the cosine case. + """ + from datetime import timedelta + + from lancedb.index import IvfPq + + config = get_config().model_copy(deep=True) + config.embeddings.model.vector_dim = 8 + config.search.vector_index_metric = metric + + def row(chunk_id, vector, order): + return { + "id": chunk_id, + "document_id": "doc-1", + "content": chunk_id, + "content_fts": chunk_id, + "metadata": "{}", + "order": order, + "vector": vector, + } + + rows = [ + row("cosine-best", [10.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], 0), + row("l2-best", [1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], 1), + ] + # IVF-PQ needs 256 training rows. These are far from the query under both + # metrics and vary enough to train the quantizer without entering Top-K. + rows.extend( + row( + f"filler-{i}", + [0.0, *[100.0 + i + j for j in range(7)]], + i + 2, + ) + for i in range(254) + ) + + async with HaikuRAG(temp_db_path, config=config, create=True) as client: + await client.store.chunks_table.add(rows) + query_vector = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] + + async def top_ids(): + results = await client.chunk_repository.search( + "", limit=2, search_type="vector", query_vector=query_vector + ) + return [chunk.id for chunk, _score in results] + + flat_ids = await top_ids() + + await client.store.chunks_table.create_index( + "vector", + config=IvfPq(distance_type=metric, num_partitions=1, num_sub_vectors=1), + replace=True, + ) + await client.store.chunks_table.wait_for_index( + ["vector_idx"], timeout=timedelta(minutes=1) + ) + indexed_ids = await top_ids() + + assert flat_ids == expected_ids + assert indexed_ids == expected_ids + + async def test_get_chunk_ids_by_self_ref_grouped_without_documents(temp_db_path): async with HaikuRAG( db_path=temp_db_path, config=get_config(), create=True diff --git a/tests/test_config.py b/tests/test_config.py index 118a90c5..263fd099 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -670,6 +670,7 @@ def test_unknown_keys_are_rejected(data, bad_key): {"processing": {"converter": "docling-loca"}}, {"processing": {"chunker": "docling-remote"}}, {"processing": {"chunker_type": "semantic"}}, + {"search": {"vector_index_metric": "dot"}}, ], ) def test_finite_switches_reject_unknown_values(data):