Merge pull request #590 from icearia0219/codex/fix-vector-metric-consistency

Honor configured vector metric during search
This commit is contained in:
Yiorgis Gozadinos 2026-08-31 10:39:35 -05:00 committed by GitHub
commit d2b2acef2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 85 additions and 5 deletions

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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)

View file

@ -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.

View file

@ -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

View file

@ -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):