Files
Drew Cain a6d0784335 fix(core): repair FTS half of hybrid search for natural-language queries
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>
2026-06-13 14:50:15 -05:00

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