mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
f9b2a075a9
Two strategies to improve content hit rate when the right document is found: 1. Small notes (<=2000 chars): return full content_snippet as matched_chunk so the answer is always present for correctly-retrieved small notes 2. Large notes: return top-3 chunks by similarity joined with \n---\n instead of just the single best chunk (~2700 chars vs ~900 chars) Also raises CONTENT_DISPLAY_LIMIT from 250 to 2000 for richer FTS results. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
147 lines
4.5 KiB
Python
147 lines
4.5 KiB
Python
"""Tests for vector search pagination score ordering.
|
|
|
|
Verifies that page 1 results always have scores >= page 2 results,
|
|
which requires a sufficiently large candidate_limit multiplier.
|
|
"""
|
|
|
|
from contextlib import asynccontextmanager
|
|
from dataclasses import dataclass
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from basic_memory.repository.search_repository_base import SearchRepositoryBase
|
|
|
|
|
|
@dataclass
|
|
class FakeRow:
|
|
"""Minimal stand-in for SearchIndexRow in pagination 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 pagination logic."""
|
|
|
|
def __init__(self):
|
|
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
|
|
|
|
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, **kwargs):
|
|
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))
|
|
|
|
|
|
@asynccontextmanager
|
|
async def fake_scoped_session(session_maker):
|
|
yield AsyncMock()
|
|
|
|
|
|
def _make_descending_vector_rows(count: int) -> list[dict]:
|
|
"""Build vector rows with scores descending from ~1.0 to ~0.5."""
|
|
rows = []
|
|
for i in range(count):
|
|
# Similarity decreases linearly: 0.95, 0.94, 0.93, ...
|
|
similarity = 0.95 - (i * 0.01)
|
|
distance = (1.0 / similarity) - 1.0
|
|
rows.append(
|
|
{
|
|
"chunk_key": f"entity:{i}:0",
|
|
"best_distance": distance,
|
|
"chunk_text": f"chunk text {i}",
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_page1_scores_gte_page2_scores():
|
|
"""Page 1 minimum score must be >= page 2 maximum score."""
|
|
repo = ConcreteSearchRepo()
|
|
|
|
# 20 results with descending scores
|
|
fake_rows = _make_descending_vector_rows(20)
|
|
|
|
mock_embed = AsyncMock(return_value=[0.0] * 384)
|
|
repo._embedding_provider = type("EP", (), {"embed_query": mock_embed, "dimensions": 384})()
|
|
|
|
fake_index_rows = {i: FakeRow(id=i) for i in range(20)}
|
|
|
|
async def run_page(offset, 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=fake_index_rows,
|
|
),
|
|
):
|
|
return await repo._search_vector_only(
|
|
search_text="test",
|
|
permalink=None,
|
|
permalink_match=None,
|
|
title=None,
|
|
note_types=None,
|
|
after_date=None,
|
|
search_item_types=None,
|
|
metadata_filters=None,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
|
|
page1 = await run_page(offset=0, limit=10)
|
|
page2 = await run_page(offset=10, limit=10)
|
|
|
|
assert len(page1) == 10
|
|
assert len(page2) == 10
|
|
|
|
page1_min = min(r.score for r in page1)
|
|
page2_max = max(r.score for r in page2)
|
|
|
|
assert page1_min >= page2_max, (
|
|
f"Score inversion: page 1 min ({page1_min:.4f}) < page 2 max ({page2_max:.4f})"
|
|
)
|