mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
a6d0784335
Hybrid search was silently running vector-only on natural-language queries — the FTS branch contributed zero candidates. Two causes in the SQLite (and parallel Postgres) FTS query preparation: 1. Sentence punctuation forced phrase matching. A question like "When did Melanie paint a sunrise?" reached FTS5 as the exact phrase '"When did Melanie paint a sunrise?"*', which matches no document. The FTS5 tokenizer ignores this punctuation in the index, so stripping it from word edges loses nothing — but leaving it disabled the entire FTS contribution. _prepare_single_term now strips ?!.,;: from word edges of multi-word queries (interior characters — hyphens, slashes in permalinks/paths — untouched). 2. No relaxation when strict all-terms-AND matched nothing. Questions rarely have every word in one document, so even after (1) the strict AND returned zero rows. The hybrid path now retries once with an OR-joined, stopword-filtered, content-term query when the strict query is empty. bm25/ts_rank still rank multi-term matches first, and fusion with the vector branch keeps relaxed lexical candidates from dominating precision. The relaxation is gated behind a new allow_relaxed=False parameter on SearchRepositoryBase.search; only _search_hybrid opts in. Strict FTS behavior (search_type=text, title, permalink, link resolution) is unchanged — the service layer keeps its own conservative fallback. No config flag, default-safe. Discovered via the benchmark harness: two different fusion algorithms produced byte-identical rankings across 1,986 queries (impossible with two live sources), and instrumentation confirmed fts=0 on 40/40 sampled LoCoMo queries. Benchmark impact (corrected LoCoMo, 1,986 queries, same index, retrieval metrics — every category improves, no regression): recall@5 0.745 -> 0.823 (+7.9) MRR 0.618 -> 0.718 (+10.0) headline r5 0.734 -> 0.801, MRR 0.621 -> 0.706 Largest gains on open_domain (+0.10 r5) and adversarial (+0.12 r5); smallest on temporal (+0.003 r5 / +0.02 MRR). Tests: punctuation no longer phrase-quotes; relaxation builds the expected OR query and respects boolean/quoted/short-query intent; the hybrid opt-in surfaces a partial-overlap document while the default strict path still returns empty. Parallel coverage for Postgres. Full SQLite unit suite green (2968 passed); ty + ruff clean. Signed-off-by: Drew Cain <groksrc@gmail.com>
470 lines
17 KiB
Python
470 lines
17 KiB
Python
"""Tests for semantic_min_similarity threshold filtering in vector search."""
|
|
|
|
from contextlib import asynccontextmanager
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from typing import Any, Optional, cast
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from basic_memory.repository.embedding_provider import EmbeddingProvider
|
|
from basic_memory.repository.search_index_row import SearchIndexRow
|
|
from basic_memory.repository.search_repository_base import (
|
|
SMALL_NOTE_CONTENT_LIMIT,
|
|
TOP_CHUNKS_PER_RESULT,
|
|
SearchRepositoryBase,
|
|
)
|
|
from basic_memory.schemas.search import SearchItemType, SearchRetrievalMode
|
|
|
|
|
|
@dataclass
|
|
class FakeRow:
|
|
"""Minimal stand-in for SearchIndexRow in threshold tests."""
|
|
|
|
id: int
|
|
type: str = "entity"
|
|
score: float = 0.0
|
|
matched_chunk_text: str | None = None
|
|
content_snippet: str | None = None
|
|
|
|
|
|
class ConcreteSearchRepo(SearchRepositoryBase):
|
|
"""Minimal concrete subclass for testing base class threshold logic."""
|
|
|
|
def __init__(self):
|
|
# Skip super().__init__ — we only need the attributes under test
|
|
self._semantic_enabled = True
|
|
self._semantic_vector_k = 100
|
|
self._semantic_min_similarity = 0.0
|
|
self._embedding_provider = None
|
|
self._vector_dimensions = 384
|
|
self._vector_tables_initialized = True
|
|
self.session_maker = None
|
|
self.project_id = 1
|
|
|
|
# --- Abstract method stubs (not exercised by these tests) ---
|
|
|
|
async def init_search_index(self):
|
|
pass # pragma: no cover
|
|
|
|
def _prepare_search_term(self, term, is_prefix=True):
|
|
return term # pragma: no cover
|
|
|
|
async def search(
|
|
self,
|
|
search_text: Optional[str] = None,
|
|
permalink: Optional[str] = None,
|
|
permalink_match: Optional[str] = None,
|
|
title: Optional[str] = None,
|
|
note_types: Optional[list[str]] = None,
|
|
after_date: Optional[datetime] = None,
|
|
search_item_types: Optional[list[SearchItemType]] = None,
|
|
categories: Optional[list[str]] = None,
|
|
metadata_filters: Optional[dict[str, Any]] = None,
|
|
retrieval_mode: SearchRetrievalMode = SearchRetrievalMode.FTS,
|
|
min_similarity: Optional[float] = None,
|
|
limit: int = 10,
|
|
offset: int = 0,
|
|
allow_relaxed: bool = False,
|
|
) -> list[SearchIndexRow]:
|
|
return [] # pragma: no cover
|
|
|
|
async def _ensure_vector_tables(self):
|
|
pass # pragma: no cover
|
|
|
|
async def _run_vector_query(self, session, query_embedding, candidate_limit):
|
|
return [] # pragma: no cover
|
|
|
|
async def _write_embeddings(self, session, jobs, embeddings):
|
|
pass # pragma: no cover
|
|
|
|
async def _delete_entity_chunks(self, session, entity_id):
|
|
pass # pragma: no cover
|
|
|
|
async def _delete_stale_chunks(self, session, stale_ids, entity_id):
|
|
pass # pragma: no cover
|
|
|
|
async def _update_timestamp_sql(self):
|
|
return "CURRENT_TIMESTAMP" # pragma: no cover
|
|
|
|
def _distance_to_similarity(self, distance: float) -> float:
|
|
return 1.0 / (1.0 + max(distance, 0.0))
|
|
|
|
|
|
def _make_vector_rows(scores: list[float]) -> list[dict]:
|
|
"""Build fake vector query rows with controlled distances.
|
|
|
|
Distance = (1/score) - 1 inverts the similarity formula:
|
|
similarity = 1 / (1 + distance)
|
|
"""
|
|
rows = []
|
|
for i, score in enumerate(scores):
|
|
distance = (1.0 / score) - 1.0
|
|
rows.append(
|
|
{
|
|
"chunk_key": f"entity:{i}:0",
|
|
"best_distance": distance,
|
|
"chunk_text": f"chunk text for entity:{i}:0",
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
def _fake_embedding_provider(mock_embed: AsyncMock) -> EmbeddingProvider:
|
|
return cast(
|
|
EmbeddingProvider,
|
|
type("EP", (), {"embed_query": mock_embed, "dimensions": 384})(),
|
|
)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def fake_scoped_session(session_maker):
|
|
"""Fake scoped_session that yields a mock session object."""
|
|
yield AsyncMock()
|
|
|
|
|
|
COMMON_SEARCH_KWARGS: dict[str, Any] = dict(
|
|
search_text="test",
|
|
permalink=None,
|
|
permalink_match=None,
|
|
title=None,
|
|
note_types=None,
|
|
after_date=None,
|
|
search_item_types=None,
|
|
categories=None,
|
|
metadata_filters=None,
|
|
limit=10,
|
|
offset=0,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_threshold_zero_returns_all():
|
|
"""With threshold=0.0 (default), all results pass through."""
|
|
repo = ConcreteSearchRepo()
|
|
repo._semantic_min_similarity = 0.0
|
|
|
|
fake_rows = _make_vector_rows([0.9, 0.5, 0.3])
|
|
|
|
mock_embed = AsyncMock(return_value=[0.0] * 384)
|
|
repo._embedding_provider = _fake_embedding_provider(mock_embed)
|
|
|
|
with (
|
|
patch(
|
|
"basic_memory.repository.search_repository_base.db.scoped_session", fake_scoped_session
|
|
),
|
|
patch.object(repo, "_ensure_vector_tables", new_callable=AsyncMock),
|
|
patch.object(repo, "_prepare_vector_session", new_callable=AsyncMock),
|
|
patch.object(repo, "_run_vector_query", new_callable=AsyncMock, return_value=fake_rows),
|
|
patch.object(
|
|
repo,
|
|
"_fetch_search_index_rows_by_ids",
|
|
new_callable=AsyncMock,
|
|
return_value={("entity", i): FakeRow(id=i) for i in range(3)},
|
|
),
|
|
):
|
|
results = await repo._search_vector_only(**COMMON_SEARCH_KWARGS)
|
|
|
|
assert len(results) == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_threshold_filters_low_scores():
|
|
"""Results below the threshold are excluded."""
|
|
repo = ConcreteSearchRepo()
|
|
repo._semantic_min_similarity = 0.6
|
|
|
|
# Scores: 0.9 (pass), 0.5 (fail), 0.3 (fail)
|
|
fake_rows = _make_vector_rows([0.9, 0.5, 0.3])
|
|
|
|
mock_embed = AsyncMock(return_value=[0.0] * 384)
|
|
repo._embedding_provider = _fake_embedding_provider(mock_embed)
|
|
|
|
with (
|
|
patch(
|
|
"basic_memory.repository.search_repository_base.db.scoped_session", fake_scoped_session
|
|
),
|
|
patch.object(repo, "_ensure_vector_tables", new_callable=AsyncMock),
|
|
patch.object(repo, "_prepare_vector_session", new_callable=AsyncMock),
|
|
patch.object(repo, "_run_vector_query", new_callable=AsyncMock, return_value=fake_rows),
|
|
patch.object(
|
|
repo,
|
|
"_fetch_search_index_rows_by_ids",
|
|
new_callable=AsyncMock,
|
|
# Only entity_0 (score=0.9) passes the threshold; the fetch only gets id 0
|
|
return_value={("entity", 0): FakeRow(id=0)},
|
|
),
|
|
):
|
|
results = await repo._search_vector_only(**COMMON_SEARCH_KWARGS)
|
|
|
|
# Only the 0.9 result passes the 0.6 threshold
|
|
assert len(results) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_threshold_returns_empty_when_all_below():
|
|
"""All results below threshold → empty list, no DB fetch."""
|
|
repo = ConcreteSearchRepo()
|
|
repo._semantic_min_similarity = 0.8
|
|
|
|
# All scores below 0.8
|
|
fake_rows = _make_vector_rows([0.5, 0.4, 0.3])
|
|
|
|
mock_embed = AsyncMock(return_value=[0.0] * 384)
|
|
repo._embedding_provider = _fake_embedding_provider(mock_embed)
|
|
|
|
mock_fetch = AsyncMock()
|
|
|
|
with (
|
|
patch(
|
|
"basic_memory.repository.search_repository_base.db.scoped_session", fake_scoped_session
|
|
),
|
|
patch.object(repo, "_ensure_vector_tables", new_callable=AsyncMock),
|
|
patch.object(repo, "_prepare_vector_session", new_callable=AsyncMock),
|
|
patch.object(repo, "_run_vector_query", new_callable=AsyncMock, return_value=fake_rows),
|
|
patch.object(repo, "_fetch_search_index_rows_by_ids", mock_fetch),
|
|
):
|
|
results = await repo._search_vector_only(**COMMON_SEARCH_KWARGS)
|
|
|
|
assert results == []
|
|
# Should short-circuit before fetching search_index rows
|
|
mock_fetch.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_per_query_min_similarity_overrides_instance_default():
|
|
"""Per-query min_similarity takes precedence over instance-level default."""
|
|
repo = ConcreteSearchRepo()
|
|
# Instance default would filter out 0.5 and 0.3
|
|
repo._semantic_min_similarity = 0.6
|
|
|
|
# Scores: 0.9, 0.5, 0.3
|
|
fake_rows = _make_vector_rows([0.9, 0.5, 0.3])
|
|
|
|
mock_embed = AsyncMock(return_value=[0.0] * 384)
|
|
repo._embedding_provider = _fake_embedding_provider(mock_embed)
|
|
|
|
with (
|
|
patch(
|
|
"basic_memory.repository.search_repository_base.db.scoped_session", fake_scoped_session
|
|
),
|
|
patch.object(repo, "_ensure_vector_tables", new_callable=AsyncMock),
|
|
patch.object(repo, "_prepare_vector_session", new_callable=AsyncMock),
|
|
patch.object(repo, "_run_vector_query", new_callable=AsyncMock, return_value=fake_rows),
|
|
patch.object(
|
|
repo,
|
|
"_fetch_search_index_rows_by_ids",
|
|
new_callable=AsyncMock,
|
|
return_value={("entity", i): FakeRow(id=i) for i in range(3)},
|
|
),
|
|
):
|
|
# Override to 0.0 → all results pass through despite instance default of 0.6
|
|
results = await repo._search_vector_only(**COMMON_SEARCH_KWARGS, min_similarity=0.0)
|
|
|
|
assert len(results) == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_per_query_min_similarity_tightens_threshold():
|
|
"""Per-query min_similarity=0.8 filters more aggressively than instance default."""
|
|
repo = ConcreteSearchRepo()
|
|
# Instance default is permissive
|
|
repo._semantic_min_similarity = 0.0
|
|
|
|
# Scores: 0.9, 0.5, 0.3
|
|
fake_rows = _make_vector_rows([0.9, 0.5, 0.3])
|
|
|
|
mock_embed = AsyncMock(return_value=[0.0] * 384)
|
|
repo._embedding_provider = _fake_embedding_provider(mock_embed)
|
|
|
|
with (
|
|
patch(
|
|
"basic_memory.repository.search_repository_base.db.scoped_session", fake_scoped_session
|
|
),
|
|
patch.object(repo, "_ensure_vector_tables", new_callable=AsyncMock),
|
|
patch.object(repo, "_prepare_vector_session", new_callable=AsyncMock),
|
|
patch.object(repo, "_run_vector_query", new_callable=AsyncMock, return_value=fake_rows),
|
|
patch.object(
|
|
repo,
|
|
"_fetch_search_index_rows_by_ids",
|
|
new_callable=AsyncMock,
|
|
# Only id=0 (score=0.9) will be fetched after filtering
|
|
return_value={("entity", 0): FakeRow(id=0)},
|
|
),
|
|
):
|
|
# Override to 0.8 → only score=0.9 passes
|
|
results = await repo._search_vector_only(**COMMON_SEARCH_KWARGS, min_similarity=0.8)
|
|
|
|
assert len(results) == 1
|
|
assert results[0].id == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_matched_chunk_text_populated_on_vector_results():
|
|
"""Vector search results carry the matched chunk text from the best-matching chunk."""
|
|
repo = ConcreteSearchRepo()
|
|
repo._semantic_min_similarity = 0.0
|
|
|
|
fake_rows = _make_vector_rows([0.9, 0.7])
|
|
|
|
mock_embed = AsyncMock(return_value=[0.0] * 384)
|
|
repo._embedding_provider = _fake_embedding_provider(mock_embed)
|
|
|
|
with (
|
|
patch(
|
|
"basic_memory.repository.search_repository_base.db.scoped_session", fake_scoped_session
|
|
),
|
|
patch.object(repo, "_ensure_vector_tables", new_callable=AsyncMock),
|
|
patch.object(repo, "_prepare_vector_session", new_callable=AsyncMock),
|
|
patch.object(repo, "_run_vector_query", new_callable=AsyncMock, return_value=fake_rows),
|
|
patch.object(
|
|
repo,
|
|
"_fetch_search_index_rows_by_ids",
|
|
new_callable=AsyncMock,
|
|
return_value={("entity", i): FakeRow(id=i) for i in range(2)},
|
|
),
|
|
):
|
|
results = await repo._search_vector_only(**COMMON_SEARCH_KWARGS)
|
|
|
|
assert len(results) == 2
|
|
# Results are sorted by score descending, so id=0 (0.9) first, id=1 (0.7) second
|
|
# Each entity has only 1 chunk and no content_snippet, so chunk text is used directly
|
|
assert results[0].matched_chunk_text == "chunk text for entity:0:0"
|
|
assert results[1].matched_chunk_text == "chunk text for entity:1:0"
|
|
|
|
|
|
def _make_multi_chunk_vector_rows(si_id: int, scores: list[float]) -> list[dict]:
|
|
"""Build multiple fake vector chunks for a single search_index row.
|
|
|
|
Each chunk gets a unique chunk_index within the same si_id.
|
|
Distance = (1/score) - 1 inverts the similarity formula.
|
|
"""
|
|
rows = []
|
|
for chunk_idx, score in enumerate(scores):
|
|
distance = (1.0 / score) - 1.0
|
|
rows.append(
|
|
{
|
|
"chunk_key": f"entity:{si_id}:{chunk_idx}",
|
|
"best_distance": distance,
|
|
"chunk_text": f"chunk-{chunk_idx} (sim={score})",
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_top_n_chunks_joined_in_matched_chunk_text():
|
|
"""Large note with 7 chunks: top 5 by similarity are joined with separator."""
|
|
repo = ConcreteSearchRepo()
|
|
repo._semantic_min_similarity = 0.0
|
|
|
|
# 7 chunks for entity 0, with varying similarities
|
|
chunk_scores = [0.6, 0.9, 0.4, 0.8, 0.75, 0.3, 0.85]
|
|
fake_rows = _make_multi_chunk_vector_rows(si_id=0, scores=chunk_scores)
|
|
|
|
mock_embed = AsyncMock(return_value=[0.0] * 384)
|
|
repo._embedding_provider = _fake_embedding_provider(mock_embed)
|
|
|
|
# content_snippet exceeds SMALL_NOTE_CONTENT_LIMIT → top-N chunks path
|
|
large_content = "x" * (SMALL_NOTE_CONTENT_LIMIT + 1)
|
|
|
|
with (
|
|
patch(
|
|
"basic_memory.repository.search_repository_base.db.scoped_session", fake_scoped_session
|
|
),
|
|
patch.object(repo, "_ensure_vector_tables", new_callable=AsyncMock),
|
|
patch.object(repo, "_prepare_vector_session", new_callable=AsyncMock),
|
|
patch.object(repo, "_run_vector_query", new_callable=AsyncMock, return_value=fake_rows),
|
|
patch.object(
|
|
repo,
|
|
"_fetch_search_index_rows_by_ids",
|
|
new_callable=AsyncMock,
|
|
return_value={("entity", 0): FakeRow(id=0, content_snippet=large_content)},
|
|
),
|
|
):
|
|
results = await repo._search_vector_only(**COMMON_SEARCH_KWARGS)
|
|
|
|
assert len(results) == 1
|
|
text = results[0].matched_chunk_text
|
|
assert text is not None
|
|
|
|
# Top 5 chunks by similarity: 0.9, 0.85, 0.8, 0.75, 0.6 (0.4 and 0.3 excluded)
|
|
parts = text.split("\n---\n")
|
|
assert len(parts) == TOP_CHUNKS_PER_RESULT
|
|
assert parts[0] == "chunk-1 (sim=0.9)"
|
|
assert parts[1] == "chunk-6 (sim=0.85)"
|
|
assert parts[2] == "chunk-3 (sim=0.8)"
|
|
assert parts[3] == "chunk-4 (sim=0.75)"
|
|
assert parts[4] == "chunk-0 (sim=0.6)"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_small_note_returns_full_content_as_matched_chunk():
|
|
"""Small note (content_snippet under limit) returns full content instead of chunks."""
|
|
repo = ConcreteSearchRepo()
|
|
repo._semantic_min_similarity = 0.0
|
|
|
|
fake_rows = _make_vector_rows([0.9])
|
|
|
|
mock_embed = AsyncMock(return_value=[0.0] * 384)
|
|
repo._embedding_provider = _fake_embedding_provider(mock_embed)
|
|
|
|
small_content = "This is a short note with all the important details."
|
|
assert len(small_content) <= SMALL_NOTE_CONTENT_LIMIT
|
|
|
|
with (
|
|
patch(
|
|
"basic_memory.repository.search_repository_base.db.scoped_session", fake_scoped_session
|
|
),
|
|
patch.object(repo, "_ensure_vector_tables", new_callable=AsyncMock),
|
|
patch.object(repo, "_prepare_vector_session", new_callable=AsyncMock),
|
|
patch.object(repo, "_run_vector_query", new_callable=AsyncMock, return_value=fake_rows),
|
|
patch.object(
|
|
repo,
|
|
"_fetch_search_index_rows_by_ids",
|
|
new_callable=AsyncMock,
|
|
return_value={("entity", 0): FakeRow(id=0, content_snippet=small_content)},
|
|
),
|
|
):
|
|
results = await repo._search_vector_only(**COMMON_SEARCH_KWARGS)
|
|
|
|
assert len(results) == 1
|
|
# Full content returned instead of the chunk text
|
|
assert results[0].matched_chunk_text == small_content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_large_note_returns_chunks_not_full_content():
|
|
"""Large note (content_snippet over limit) returns top-N chunks, not full content."""
|
|
repo = ConcreteSearchRepo()
|
|
repo._semantic_min_similarity = 0.0
|
|
|
|
fake_rows = _make_vector_rows([0.9])
|
|
|
|
mock_embed = AsyncMock(return_value=[0.0] * 384)
|
|
repo._embedding_provider = _fake_embedding_provider(mock_embed)
|
|
|
|
large_content = "x" * (SMALL_NOTE_CONTENT_LIMIT + 500)
|
|
|
|
with (
|
|
patch(
|
|
"basic_memory.repository.search_repository_base.db.scoped_session", fake_scoped_session
|
|
),
|
|
patch.object(repo, "_ensure_vector_tables", new_callable=AsyncMock),
|
|
patch.object(repo, "_prepare_vector_session", new_callable=AsyncMock),
|
|
patch.object(repo, "_run_vector_query", new_callable=AsyncMock, return_value=fake_rows),
|
|
patch.object(
|
|
repo,
|
|
"_fetch_search_index_rows_by_ids",
|
|
new_callable=AsyncMock,
|
|
return_value={("entity", 0): FakeRow(id=0, content_snippet=large_content)},
|
|
),
|
|
):
|
|
results = await repo._search_vector_only(**COMMON_SEARCH_KWARGS)
|
|
|
|
assert len(results) == 1
|
|
# Should use chunk text, not the full content
|
|
assert results[0].matched_chunk_text == "chunk text for entity:0:0"
|
|
assert results[0].matched_chunk_text != large_content
|