Merge pull request #590 from icearia0219/codex/fix-vector-metric-consistency
Honor configured vector metric during search
This commit is contained in:
commit
d2b2acef2c
8 changed files with 85 additions and 5 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue