mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
8c81d3ce17
Signed-off-by: phernandez <paul@basicmachines.co>
158 lines
5.2 KiB
Python
158 lines
5.2 KiB
Python
"""Semantic search quality benchmarks across backend×provider combinations.
|
||
|
||
Runs identical query suites (lexical + paraphrase) against five configurations:
|
||
|
||
sqlite-fts SQLite FTS5, no embeddings
|
||
sqlite-fastembed SQLite + FastEmbed (384-d ONNX)
|
||
postgres-fts Postgres tsvector, no embeddings
|
||
postgres-fastembed Postgres + FastEmbed (384-d)
|
||
postgres-openai Postgres + OpenAI (1536-d, needs OPENAI_API_KEY)
|
||
|
||
Quality is measured via hit@1, recall@5, and MRR@10. A comparison table
|
||
is printed at the end, and JSON-lines artifacts are written when
|
||
``BASIC_MEMORY_BENCHMARK_OUTPUT`` is set.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
|
||
import pytest
|
||
|
||
from basic_memory.schemas.search import SearchItemType, SearchQuery, SearchRetrievalMode
|
||
|
||
from semantic.conftest import (
|
||
ALL_COMBOS,
|
||
SearchCombo,
|
||
create_search_service,
|
||
skip_if_needed,
|
||
_create_fastembed_provider,
|
||
_create_openai_provider,
|
||
)
|
||
from semantic.corpus import QUERY_SUITES, seed_benchmark_notes
|
||
from semantic.metrics import (
|
||
QualityMetrics,
|
||
first_relevant_rank,
|
||
format_comparison_table,
|
||
write_benchmark_artifact,
|
||
)
|
||
|
||
|
||
# --- Thresholds (conservative, tighten over time) ---
|
||
# Keys: (combo.name, suite_name, mode)
|
||
# Only combos/suites we have strong expectations for are listed.
|
||
|
||
RECALL_AT_5_THRESHOLDS: dict[tuple[str, str, str], float] = {
|
||
# FTS-only: realistic corpus makes pure keyword matching harder
|
||
("sqlite-fts", "lexical", "fts"): 0.25,
|
||
("postgres-fts", "lexical", "fts"): 0.25,
|
||
# FastEmbed hybrid should improve on FTS for both suites
|
||
("sqlite-fastembed", "lexical", "hybrid"): 0.37,
|
||
("sqlite-fastembed", "paraphrase", "hybrid"): 0.25,
|
||
("postgres-fastembed", "lexical", "hybrid"): 0.37,
|
||
("postgres-fastembed", "paraphrase", "hybrid"): 0.25,
|
||
# OpenAI hybrid should handle paraphrases better than FastEmbed.
|
||
("postgres-openai", "lexical", "hybrid"): 0.37,
|
||
("postgres-openai", "paraphrase", "hybrid"): 0.25,
|
||
}
|
||
|
||
|
||
def _resolve_provider(combo: SearchCombo):
|
||
"""Build the embedding provider for a combo, or None for FTS-only."""
|
||
if combo.provider_name == "fastembed":
|
||
return _create_fastembed_provider()
|
||
if combo.provider_name == "openai":
|
||
return _create_openai_provider()
|
||
return None
|
||
|
||
|
||
def _retrieval_modes(combo: SearchCombo) -> list[SearchRetrievalMode]:
|
||
"""Return the retrieval modes to test for a given combo.
|
||
|
||
FTS-only combos: [FTS]
|
||
Semantic combos: [FTS, VECTOR, HYBRID]
|
||
"""
|
||
if combo.provider_name is None:
|
||
return [SearchRetrievalMode.FTS]
|
||
return [SearchRetrievalMode.FTS, SearchRetrievalMode.VECTOR, SearchRetrievalMode.HYBRID]
|
||
|
||
|
||
# --- Parameterized test ---
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.semantic
|
||
@pytest.mark.benchmark
|
||
@pytest.mark.parametrize("combo", ALL_COMBOS, ids=[c.name for c in ALL_COMBOS])
|
||
async def test_semantic_quality(
|
||
combo: SearchCombo,
|
||
sqlite_engine_factory,
|
||
postgres_engine_factory,
|
||
tmp_path,
|
||
):
|
||
"""Benchmark search quality for a single backend×provider combo."""
|
||
skip_if_needed(combo)
|
||
|
||
# Pick the right engine factory
|
||
from basic_memory.config import DatabaseBackend
|
||
|
||
if combo.backend == DatabaseBackend.SQLITE:
|
||
engine_factory_result = sqlite_engine_factory
|
||
else:
|
||
if postgres_engine_factory is None:
|
||
pytest.skip("Postgres engine not available")
|
||
engine_factory_result = postgres_engine_factory
|
||
|
||
provider = _resolve_provider(combo)
|
||
search_service = await create_search_service(
|
||
engine_factory_result, combo, tmp_path, embedding_provider=provider
|
||
)
|
||
|
||
# Seed corpus
|
||
entities = await seed_benchmark_notes(search_service, note_count=240)
|
||
assert len(entities) == 240
|
||
|
||
# Collect metrics for each (suite, mode)
|
||
all_metrics: list[QualityMetrics] = []
|
||
|
||
for suite_name, cases in QUERY_SUITES.items():
|
||
for mode in _retrieval_modes(combo):
|
||
metrics = QualityMetrics(
|
||
combo=combo.name,
|
||
suite=suite_name,
|
||
mode=mode.value,
|
||
)
|
||
|
||
for case in cases:
|
||
t0 = time.perf_counter()
|
||
results = await search_service.search(
|
||
SearchQuery(
|
||
text=case.text,
|
||
retrieval_mode=mode,
|
||
entity_types=[SearchItemType.ENTITY],
|
||
),
|
||
limit=10,
|
||
)
|
||
latency = time.perf_counter() - t0
|
||
|
||
rank = first_relevant_rank(results, case.expected_topic, k=10) if results else None
|
||
metrics.record(case.text, case.expected_topic, rank, latency=latency)
|
||
|
||
all_metrics.append(metrics)
|
||
|
||
# Print comparison table
|
||
table = format_comparison_table(all_metrics)
|
||
print(f"\n{table}")
|
||
|
||
# Write JSON artifact
|
||
write_benchmark_artifact(all_metrics)
|
||
|
||
# Enforce thresholds
|
||
for m in all_metrics:
|
||
threshold_key = (m.combo, m.suite, m.mode)
|
||
threshold = RECALL_AT_5_THRESHOLDS.get(threshold_key)
|
||
if threshold is not None:
|
||
assert m.recall_at_5 >= threshold, (
|
||
f"recall@5 for {m.combo}/{m.suite}/{m.mode}: {m.recall_at_5:.3f} < {threshold:.3f}"
|
||
)
|