Honor configured vector metric during search
This commit is contained in:
parent
b87ae16910
commit
b93270adce
8 changed files with 85 additions and 5 deletions
|
|
@ -6,6 +6,9 @@
|
||||||
|
|
||||||
- Migration to 0.38.0 no longer fails with `UnicodeDecodeError` on a
|
- Migration to 0.38.0 no longer fails with `UnicodeDecodeError` on a
|
||||||
`docling_document` blob written as zstd.
|
`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
|
## [0.79.0] - 2026-08-28
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -391,7 +391,7 @@ haiku-rag create-index [--db /path/to/your.lancedb]
|
||||||
|
|
||||||
**Requirements:**
|
**Requirements:**
|
||||||
- Minimum 256 chunks required for index creation (LanceDB training data requirement)
|
- 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:**
|
**When to use:**
|
||||||
- After ingesting documents (indexes are not created automatically)
|
- After ingesting documents (indexes are not created automatically)
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ qa:
|
||||||
search:
|
search:
|
||||||
limit: 5 # Default number of results to return
|
limit: 5 # Default number of results to return
|
||||||
max_context_chars: 5000 # Maximum characters in expanded context
|
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
|
vector_refine_factor: 30
|
||||||
|
|
||||||
doctor:
|
doctor:
|
||||||
|
|
|
||||||
|
|
@ -300,7 +300,7 @@ Configure vector search settings:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
search:
|
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
|
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:
|
- **vector_index_metric**: Distance metric for vector similarity:
|
||||||
- `cosine`: Cosine similarity (default, best for most embeddings)
|
- `cosine`: Cosine similarity (default, best for most embeddings)
|
||||||
- `l2`: Euclidean distance
|
- `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
|
- **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
|
- **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):
|
class SearchConfig(ConfigModel):
|
||||||
limit: int = Field(default=5, gt=0)
|
limit: int = Field(default=5, gt=0)
|
||||||
max_context_chars: int = Field(default=5000, 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)
|
vector_refine_factor: int = Field(default=30, gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,7 @@ class ChunkRepository:
|
||||||
self.store.chunks_table.query()
|
self.store.chunks_table.query()
|
||||||
.nearest_to(query_embedding)
|
.nearest_to(query_embedding)
|
||||||
.column("vector")
|
.column("vector")
|
||||||
|
.distance_type(self.store._config.search.vector_index_metric)
|
||||||
.refine_factor(self.store._config.search.vector_refine_factor)
|
.refine_factor(self.store._config.search.vector_refine_factor)
|
||||||
)
|
)
|
||||||
# An image query has no text to match, so it stays vector-only.
|
# 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)
|
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 def test_get_chunk_ids_by_self_ref_grouped_without_documents(temp_db_path):
|
||||||
async with HaikuRAG(
|
async with HaikuRAG(
|
||||||
db_path=temp_db_path, config=get_config(), create=True
|
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": {"converter": "docling-loca"}},
|
||||||
{"processing": {"chunker": "docling-remote"}},
|
{"processing": {"chunker": "docling-remote"}},
|
||||||
{"processing": {"chunker_type": "semantic"}},
|
{"processing": {"chunker_type": "semantic"}},
|
||||||
|
{"search": {"vector_index_metric": "dot"}},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_finite_switches_reject_unknown_values(data):
|
def test_finite_switches_reject_unknown_values(data):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue