mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
c44291830c
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
497 lines
18 KiB
Python
497 lines
18 KiB
Python
"""Performance benchmarks for local semantic search indexing and query modes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import os
|
|
import time
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from statistics import mean
|
|
|
|
import pytest
|
|
from sqlalchemy import text
|
|
|
|
from basic_memory import db
|
|
from basic_memory.config import DatabaseBackend
|
|
from basic_memory.repository.fastembed_provider import FastEmbedEmbeddingProvider
|
|
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
|
|
from basic_memory.schemas.search import SearchItemType, SearchQuery, SearchRetrievalMode
|
|
|
|
|
|
TOPIC_TERMS = {
|
|
"auth": ["authentication", "session", "token", "oauth", "refresh", "login"],
|
|
"database": ["database", "migration", "schema", "sqlite", "postgres", "index"],
|
|
"sync": ["sync", "filesystem", "watcher", "checksum", "reindex", "changes"],
|
|
"agent": ["agent", "memory", "context", "prompt", "retrieval", "tooling"],
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class QueryCase:
|
|
text: str
|
|
expected_topic: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class QualityQueryCase:
|
|
text: str
|
|
expected_topic: str
|
|
|
|
|
|
QUALITY_QUERY_SUITES: dict[str, list[QualityQueryCase]] = {
|
|
"lexical": [
|
|
QualityQueryCase(text="token refresh login", expected_topic="auth"),
|
|
QualityQueryCase(text="schema migration postgres index", expected_topic="database"),
|
|
QualityQueryCase(text="filesystem watcher checksum reindex", expected_topic="sync"),
|
|
QualityQueryCase(text="agent memory context retrieval", expected_topic="agent"),
|
|
],
|
|
"paraphrase": [
|
|
QualityQueryCase(
|
|
text="How do we keep sign-in state and rotate refresh credentials?",
|
|
expected_topic="auth",
|
|
),
|
|
QualityQueryCase(
|
|
text="What is our approach for evolving DB structure and migration strategy?",
|
|
expected_topic="database",
|
|
),
|
|
QualityQueryCase(
|
|
text="How do we detect note edits and trigger a reindex pass?",
|
|
expected_topic="sync",
|
|
),
|
|
QualityQueryCase(
|
|
text="How does the assistant preserve long-term context for tool use?",
|
|
expected_topic="agent",
|
|
),
|
|
],
|
|
}
|
|
|
|
|
|
def _skip_if_not_sqlite(app_config) -> None:
|
|
if app_config.database_backend != DatabaseBackend.SQLITE:
|
|
pytest.skip("These benchmarks target local SQLite semantic search.")
|
|
|
|
|
|
def _enable_semantic_for_benchmark(search_service, app_config) -> None:
|
|
repository = search_service.repository
|
|
if not isinstance(repository, SQLiteSearchRepository):
|
|
return
|
|
|
|
app_config.semantic_search_enabled = True
|
|
repository._semantic_enabled = True
|
|
if repository._embedding_provider is None:
|
|
repository._embedding_provider = FastEmbedEmbeddingProvider(
|
|
model_name=app_config.semantic_embedding_model,
|
|
batch_size=app_config.semantic_embedding_batch_size,
|
|
)
|
|
repository._vector_dimensions = repository._embedding_provider.dimensions
|
|
repository._vector_tables_initialized = False
|
|
|
|
|
|
def _build_benchmark_content(topic: str, terms: list[str], note_index: int) -> str:
|
|
repeated_phrase = " ".join(terms)
|
|
return f"""---
|
|
tags: [benchmark, {topic}]
|
|
status: active
|
|
---
|
|
# {topic.title()} Benchmark Note {note_index}
|
|
|
|
## Summary
|
|
This note covers {topic} workflows and practical implementation choices.
|
|
Primary concepts: {repeated_phrase}.
|
|
|
|
## Decisions
|
|
Decision details for {topic} note {note_index}.
|
|
{repeated_phrase}
|
|
{repeated_phrase}
|
|
|
|
## Deep Detail
|
|
Detailed examples for {topic} note {note_index} with operational context.
|
|
{repeated_phrase}
|
|
{repeated_phrase}
|
|
{repeated_phrase}
|
|
"""
|
|
|
|
|
|
def _percentile(values: list[float], percentile: float) -> float:
|
|
if not values:
|
|
return 0.0
|
|
sorted_values = sorted(values)
|
|
rank = math.ceil((percentile / 100.0) * len(sorted_values)) - 1
|
|
index = max(0, min(rank, len(sorted_values) - 1))
|
|
return sorted_values[index]
|
|
|
|
|
|
def _parse_threshold(env_var: str) -> float | None:
|
|
raw_value = os.getenv(env_var)
|
|
if raw_value is None or not raw_value.strip():
|
|
return None
|
|
try:
|
|
return float(raw_value)
|
|
except ValueError as exc: # pragma: no cover - config error path
|
|
raise ValueError(f"{env_var} must be a float, got {raw_value!r}") from exc
|
|
|
|
|
|
def _enforce_min_threshold(metric_name: str, actual: float, env_var: str) -> None:
|
|
threshold = _parse_threshold(env_var)
|
|
if threshold is None:
|
|
return
|
|
assert actual >= threshold, (
|
|
f"Benchmark guardrail failed for {metric_name}: {actual:.4f} < {threshold:.4f} ({env_var})"
|
|
)
|
|
|
|
|
|
def _enforce_max_threshold(metric_name: str, actual: float, env_var: str) -> None:
|
|
threshold = _parse_threshold(env_var)
|
|
if threshold is None:
|
|
return
|
|
assert actual <= threshold, (
|
|
f"Benchmark guardrail failed for {metric_name}: {actual:.4f} > {threshold:.4f} ({env_var})"
|
|
)
|
|
|
|
|
|
def _write_benchmark_artifact(name: str, metrics: dict[str, float | int | str]) -> None:
|
|
output_path = os.getenv("BASIC_MEMORY_BENCHMARK_OUTPUT")
|
|
if not output_path:
|
|
return
|
|
|
|
artifact_path = Path(output_path).expanduser()
|
|
artifact_path.parent.mkdir(parents=True, exist_ok=True)
|
|
payload = {
|
|
"benchmark": name,
|
|
"timestamp_utc": datetime.now(timezone.utc).isoformat(),
|
|
"metrics": metrics,
|
|
}
|
|
with artifact_path.open("a", encoding="utf-8") as artifact_file:
|
|
artifact_file.write(json.dumps(payload, sort_keys=True) + "\n")
|
|
|
|
|
|
def _print_index_metrics(
|
|
name: str, note_count: int, elapsed_seconds: float, db_size_bytes: int
|
|
) -> dict[str, float | int | str]:
|
|
notes_per_second = note_count / elapsed_seconds if elapsed_seconds else 0.0
|
|
sqlite_size_mb = db_size_bytes / (1024 * 1024)
|
|
metrics: dict[str, float | int | str] = {
|
|
"notes_indexed": note_count,
|
|
"elapsed_seconds": round(elapsed_seconds, 6),
|
|
"notes_per_sec": round(notes_per_second, 6),
|
|
"sqlite_size_bytes": db_size_bytes,
|
|
"sqlite_size_mb": round(sqlite_size_mb, 6),
|
|
}
|
|
print(f"\nBENCHMARK: {name}")
|
|
print(f"notes indexed: {note_count}")
|
|
print(f"elapsed (s): {elapsed_seconds:.4f}")
|
|
print(f"notes/sec: {notes_per_second:.2f}")
|
|
print(f"sqlite size (MB): {sqlite_size_mb:.2f}")
|
|
return metrics
|
|
|
|
|
|
def _print_query_metrics(name: str, latencies: list[float]) -> dict[str, float | int | str]:
|
|
latencies_ms = [latency * 1000 for latency in latencies]
|
|
avg_ms = mean(latencies_ms)
|
|
p50_ms = _percentile(latencies_ms, 50)
|
|
p95_ms = _percentile(latencies_ms, 95)
|
|
p99_ms = _percentile(latencies_ms, 99)
|
|
metrics: dict[str, float | int | str] = {
|
|
"queries_executed": len(latencies_ms),
|
|
"avg_ms": round(avg_ms, 6),
|
|
"p50_ms": round(p50_ms, 6),
|
|
"p95_ms": round(p95_ms, 6),
|
|
"p99_ms": round(p99_ms, 6),
|
|
}
|
|
print(f"\nBENCHMARK: {name}")
|
|
print(f"queries executed: {len(latencies_ms)}")
|
|
print(f"avg latency (ms): {avg_ms:.2f}")
|
|
print(f"p50 latency (ms): {p50_ms:.2f}")
|
|
print(f"p95 latency (ms): {p95_ms:.2f}")
|
|
print(f"p99 latency (ms): {p99_ms:.2f}")
|
|
return metrics
|
|
|
|
|
|
def _print_quality_metrics(
|
|
name: str,
|
|
*,
|
|
cases: int,
|
|
hit_rate_at_1: float,
|
|
recall_at_5: float,
|
|
mrr_at_10: float,
|
|
) -> dict[str, float | int | str]:
|
|
metrics: dict[str, float | int | str] = {
|
|
"cases": cases,
|
|
"hit_rate_at_1": round(hit_rate_at_1, 6),
|
|
"recall_at_5": round(recall_at_5, 6),
|
|
"mrr_at_10": round(mrr_at_10, 6),
|
|
}
|
|
print(f"\nBENCHMARK: {name}")
|
|
print(f"cases: {cases}")
|
|
print(f"hit@1: {hit_rate_at_1:.3f}")
|
|
print(f"recall@5: {recall_at_5:.3f}")
|
|
print(f"mrr@10: {mrr_at_10:.3f}")
|
|
return metrics
|
|
|
|
|
|
def _first_relevant_rank(results, expected_topic: str, k: int) -> int | None:
|
|
expected_prefix = f"bench/{expected_topic}-"
|
|
for rank, row in enumerate(results[:k], start=1):
|
|
if (row.permalink or "").startswith(expected_prefix):
|
|
return rank
|
|
return None
|
|
|
|
|
|
async def _seed_benchmark_notes(search_service, note_count: int):
|
|
entities = []
|
|
topic_names = list(TOPIC_TERMS.keys())
|
|
|
|
for note_index in range(note_count):
|
|
topic = topic_names[note_index % len(topic_names)]
|
|
terms = TOPIC_TERMS[topic]
|
|
permalink = f"bench/{topic}-{note_index:05d}"
|
|
entity = await search_service.entity_repository.create(
|
|
{
|
|
"title": f"{topic.title()} Benchmark Note {note_index}",
|
|
"note_type": "benchmark",
|
|
"entity_metadata": {"tags": ["benchmark", topic], "status": "active"},
|
|
"content_type": "text/markdown",
|
|
"permalink": permalink,
|
|
"file_path": f"{permalink}.md",
|
|
}
|
|
)
|
|
content = _build_benchmark_content(topic, terms, note_index)
|
|
await search_service.index_entity_data(entity, content=content)
|
|
if isinstance(search_service.repository, SQLiteSearchRepository):
|
|
if search_service.repository._semantic_enabled:
|
|
await search_service.sync_entity_vectors(entity.id)
|
|
entities.append(entity)
|
|
|
|
return entities
|
|
|
|
|
|
async def _sqlite_size_bytes(search_service) -> int:
|
|
async with db.scoped_session(search_service.repository.session_maker) as session:
|
|
page_count_result = await session.execute(text("PRAGMA page_count"))
|
|
page_size_result = await session.execute(text("PRAGMA page_size"))
|
|
page_count = int(page_count_result.scalar_one())
|
|
page_size = int(page_size_result.scalar_one())
|
|
return page_count * page_size
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.benchmark
|
|
async def test_benchmark_search_index_cold_start_300_notes(search_service, app_config):
|
|
"""Benchmark end-to-end indexing throughput for a cold local search index."""
|
|
_skip_if_not_sqlite(app_config)
|
|
_enable_semantic_for_benchmark(search_service, app_config)
|
|
|
|
note_count = 300
|
|
start = time.perf_counter()
|
|
entities = await _seed_benchmark_notes(search_service, note_count=note_count)
|
|
elapsed_seconds = time.perf_counter() - start
|
|
db_size_bytes = await _sqlite_size_bytes(search_service)
|
|
|
|
assert len(entities) == note_count
|
|
assert elapsed_seconds > 0
|
|
|
|
benchmark_name = "cold index (300 notes)"
|
|
metrics = _print_index_metrics(
|
|
name=benchmark_name,
|
|
note_count=note_count,
|
|
elapsed_seconds=elapsed_seconds,
|
|
db_size_bytes=db_size_bytes,
|
|
)
|
|
_write_benchmark_artifact(benchmark_name, metrics)
|
|
_enforce_min_threshold(
|
|
metric_name="cold.notes_per_sec",
|
|
actual=float(metrics["notes_per_sec"]),
|
|
env_var="BASIC_MEMORY_BENCH_MIN_COLD_NOTES_PER_SEC",
|
|
)
|
|
_enforce_max_threshold(
|
|
metric_name="cold.sqlite_size_mb",
|
|
actual=float(metrics["sqlite_size_mb"]),
|
|
env_var="BASIC_MEMORY_BENCH_MAX_COLD_SQLITE_SIZE_MB",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.benchmark
|
|
async def test_benchmark_search_query_latency_by_mode(search_service, app_config):
|
|
"""Benchmark search latency for fts/vector/hybrid retrieval modes."""
|
|
_skip_if_not_sqlite(app_config)
|
|
_enable_semantic_for_benchmark(search_service, app_config)
|
|
|
|
await _seed_benchmark_notes(search_service, note_count=240)
|
|
|
|
query_cases = [
|
|
QueryCase(text="session token login", expected_topic="auth"),
|
|
QueryCase(text="schema migration sqlite", expected_topic="database"),
|
|
QueryCase(text="filesystem watcher checksum", expected_topic="sync"),
|
|
QueryCase(text="agent memory retrieval", expected_topic="agent"),
|
|
]
|
|
passes_per_mode = 8
|
|
|
|
for mode in (
|
|
SearchRetrievalMode.FTS,
|
|
SearchRetrievalMode.VECTOR,
|
|
SearchRetrievalMode.HYBRID,
|
|
):
|
|
latencies: list[float] = []
|
|
for _ in range(passes_per_mode):
|
|
for case in query_cases:
|
|
start = time.perf_counter()
|
|
results = await search_service.search(
|
|
SearchQuery(
|
|
text=case.text,
|
|
retrieval_mode=mode,
|
|
entity_types=[SearchItemType.ENTITY],
|
|
),
|
|
limit=10,
|
|
)
|
|
latencies.append(time.perf_counter() - start)
|
|
|
|
assert results
|
|
assert any(
|
|
(row.permalink or "").startswith(f"bench/{case.expected_topic}-")
|
|
for row in results
|
|
)
|
|
|
|
benchmark_name = f"query latency ({mode.value})"
|
|
metrics = _print_query_metrics(name=benchmark_name, latencies=latencies)
|
|
_write_benchmark_artifact(benchmark_name, metrics)
|
|
_enforce_max_threshold(
|
|
metric_name=f"{mode.value}.p95_ms",
|
|
actual=float(metrics["p95_ms"]),
|
|
env_var=f"BASIC_MEMORY_BENCH_MAX_{mode.value.upper()}_P95_MS",
|
|
)
|
|
_enforce_max_threshold(
|
|
metric_name=f"{mode.value}.p99_ms",
|
|
actual=float(metrics["p99_ms"]),
|
|
env_var=f"BASIC_MEMORY_BENCH_MAX_{mode.value.upper()}_P99_MS",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.benchmark
|
|
@pytest.mark.slow
|
|
async def test_benchmark_search_incremental_reindex_80_of_800_notes(search_service, app_config):
|
|
"""Benchmark incremental re-index throughput for changed notes only."""
|
|
_skip_if_not_sqlite(app_config)
|
|
_enable_semantic_for_benchmark(search_service, app_config)
|
|
|
|
entities = await _seed_benchmark_notes(search_service, note_count=800)
|
|
changed_count = 80
|
|
|
|
start = time.perf_counter()
|
|
for note_index, entity in enumerate(entities[:changed_count]):
|
|
topic = "auth" if note_index % 2 == 0 else "sync"
|
|
terms = TOPIC_TERMS[topic]
|
|
updated_content = (
|
|
_build_benchmark_content(topic, terms, note_index)
|
|
+ f"\n\n## Incremental Marker\nincremental-marker-{note_index}\n"
|
|
)
|
|
await search_service.index_entity_data(entity, content=updated_content)
|
|
await search_service.sync_entity_vectors(entity.id)
|
|
elapsed_seconds = time.perf_counter() - start
|
|
db_size_bytes = await _sqlite_size_bytes(search_service)
|
|
|
|
verification = await search_service.search(
|
|
SearchQuery(
|
|
text="incremental-marker-5",
|
|
retrieval_mode=SearchRetrievalMode.HYBRID,
|
|
entity_types=[SearchItemType.ENTITY],
|
|
),
|
|
limit=20,
|
|
)
|
|
|
|
assert verification
|
|
assert elapsed_seconds > 0
|
|
|
|
benchmark_name = "incremental reindex (80 changed of 800)"
|
|
metrics = _print_index_metrics(
|
|
name=benchmark_name,
|
|
note_count=changed_count,
|
|
elapsed_seconds=elapsed_seconds,
|
|
db_size_bytes=db_size_bytes,
|
|
)
|
|
_write_benchmark_artifact(benchmark_name, metrics)
|
|
_enforce_min_threshold(
|
|
metric_name="incremental.notes_per_sec",
|
|
actual=float(metrics["notes_per_sec"]),
|
|
env_var="BASIC_MEMORY_BENCH_MIN_INCREMENTAL_NOTES_PER_SEC",
|
|
)
|
|
_enforce_max_threshold(
|
|
metric_name="incremental.sqlite_size_mb",
|
|
actual=float(metrics["sqlite_size_mb"]),
|
|
env_var="BASIC_MEMORY_BENCH_MAX_INCREMENTAL_SQLITE_SIZE_MB",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.benchmark
|
|
async def test_benchmark_search_quality_recall_by_mode(search_service, app_config):
|
|
"""Benchmark retrieval quality (hit/recall/MRR) for lexical and paraphrase query suites."""
|
|
_skip_if_not_sqlite(app_config)
|
|
_enable_semantic_for_benchmark(search_service, app_config)
|
|
|
|
await _seed_benchmark_notes(search_service, note_count=240)
|
|
|
|
for suite_name, cases in QUALITY_QUERY_SUITES.items():
|
|
for mode in (
|
|
SearchRetrievalMode.FTS,
|
|
SearchRetrievalMode.VECTOR,
|
|
SearchRetrievalMode.HYBRID,
|
|
):
|
|
hits_at_1 = 0
|
|
hits_at_5 = 0
|
|
reciprocal_rank_sum = 0.0
|
|
|
|
for case in cases:
|
|
results = await search_service.search(
|
|
SearchQuery(
|
|
text=case.text,
|
|
retrieval_mode=mode,
|
|
entity_types=[SearchItemType.ENTITY],
|
|
),
|
|
limit=10,
|
|
)
|
|
if not results:
|
|
continue
|
|
|
|
relevant_rank = _first_relevant_rank(results, case.expected_topic, k=10)
|
|
if relevant_rank is None:
|
|
continue
|
|
|
|
reciprocal_rank_sum += 1.0 / relevant_rank
|
|
if relevant_rank == 1:
|
|
hits_at_1 += 1
|
|
if relevant_rank <= 5:
|
|
hits_at_5 += 1
|
|
|
|
case_count = len(cases)
|
|
hit_rate_at_1 = hits_at_1 / case_count
|
|
recall_at_5 = hits_at_5 / case_count
|
|
mrr_at_10 = reciprocal_rank_sum / case_count
|
|
|
|
benchmark_name = f"quality recall ({suite_name}, {mode.value})"
|
|
metrics = _print_quality_metrics(
|
|
benchmark_name,
|
|
cases=case_count,
|
|
hit_rate_at_1=hit_rate_at_1,
|
|
recall_at_5=recall_at_5,
|
|
mrr_at_10=mrr_at_10,
|
|
)
|
|
_write_benchmark_artifact(benchmark_name, metrics)
|
|
|
|
suite_env = suite_name.upper()
|
|
mode_env = mode.value.upper()
|
|
_enforce_min_threshold(
|
|
metric_name=f"{suite_name}.{mode.value}.recall_at_5",
|
|
actual=float(metrics["recall_at_5"]),
|
|
env_var=f"BASIC_MEMORY_BENCH_MIN_{suite_env}_{mode_env}_RECALL_AT_5",
|
|
)
|
|
_enforce_min_threshold(
|
|
metric_name=f"{suite_name}.{mode.value}.mrr_at_10",
|
|
actual=float(metrics["mrr_at_10"]),
|
|
env_var=f"BASIC_MEMORY_BENCH_MIN_{suite_env}_{mode_env}_MRR_AT_10",
|
|
)
|