Spaces:
Build error
Build error
| """Relevance scorer benchmarks for Headroom SDK. | |
| This module contains performance benchmarks for relevance scorers: | |
| - BM25Scorer: Zero-dependency keyword matching | |
| - HybridScorer: BM25 + embedding fusion (with graceful fallback) | |
| Performance Targets: | |
| BM25Scorer: | |
| - Single item: < 0.1ms | |
| - Batch 100: < 1ms | |
| - Batch 1000: < 10ms | |
| HybridScorer (BM25 fallback): | |
| - Single item: < 0.2ms | |
| - Batch 100: < 2ms | |
| HybridScorer (with embeddings): | |
| - Single item: < 5ms | |
| - Batch 100: < 50ms | |
| Run with: | |
| pytest benchmarks/bench_relevance.py --benchmark-only -v | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import pytest | |
| def _check_embedding_available() -> bool: | |
| """Check if sentence-transformers is available for embedding tests.""" | |
| try: | |
| import sentence_transformers # noqa: F401 | |
| return True | |
| except ImportError: | |
| return False | |
| class TestBM25Benchmarks: | |
| """Benchmarks for BM25 keyword relevance scorer. | |
| BM25Scorer performs: | |
| - Text tokenization (regex-based) | |
| - IDF computation | |
| - BM25 score calculation | |
| - Long-token bonus (UUIDs, IDs) | |
| Expected performance: | |
| - O(n*m) where n=tokens in item, m=tokens in query | |
| - Single item: < 0.1ms | |
| - Batch operations are linear with items | |
| """ | |
| def scorer(self): | |
| """Create BM25 scorer instance.""" | |
| from headroom.relevance.bm25 import BM25Scorer | |
| return BM25Scorer() | |
| def test_single_item( | |
| self, | |
| benchmark, | |
| scorer, | |
| json_items_100, | |
| query_context_uuid, | |
| ): | |
| """Benchmark scoring a single item. | |
| Target: < 0.1ms | |
| Tests basic scoring overhead. | |
| """ | |
| item = json_items_100[0] | |
| result = benchmark(scorer.score, item, query_context_uuid) | |
| assert result.score >= 0.0 | |
| assert result.score <= 1.0 | |
| def test_batch_100( | |
| self, | |
| benchmark, | |
| scorer, | |
| json_items_100, | |
| query_context_uuid, | |
| ): | |
| """Benchmark scoring 100 items in batch. | |
| Target: < 1ms | |
| Tests typical batch size for SmartCrusher. | |
| """ | |
| results = benchmark(scorer.score_batch, json_items_100, query_context_uuid) | |
| assert len(results) == 100 | |
| assert all(0.0 <= r.score <= 1.0 for r in results) | |
| def test_batch_1000( | |
| self, | |
| benchmark, | |
| scorer, | |
| json_items_1000, | |
| query_context_uuid, | |
| ): | |
| """Benchmark scoring 1000 items in batch. | |
| Target: < 10ms | |
| Tests larger batch for stress testing. | |
| """ | |
| results = benchmark(scorer.score_batch, json_items_1000, query_context_uuid) | |
| assert len(results) == 1000 | |
| def test_uuid_matching( | |
| self, | |
| benchmark, | |
| scorer, | |
| json_items_100, | |
| query_context_uuid, | |
| ): | |
| """Benchmark UUID pattern matching. | |
| Target: < 1ms | |
| Tests regex efficiency for UUID detection. | |
| """ | |
| # Query contains UUID - tests that BM25 can handle long token patterns | |
| results = benchmark(scorer.score_batch, json_items_100, query_context_uuid) | |
| # Verify scoring completes - specific matches depend on generated data | |
| assert len(results) == 100 | |
| assert all(r.score >= 0.0 for r in results) | |
| def test_semantic_query( | |
| self, | |
| benchmark, | |
| scorer, | |
| json_items_100, | |
| query_context_semantic, | |
| ): | |
| """Benchmark semantic query (BM25 limitations). | |
| Target: < 1ms | |
| Tests keyword matching on semantic queries. | |
| """ | |
| # BM25 will only match literal terms | |
| results = benchmark(scorer.score_batch, json_items_100, query_context_semantic) | |
| assert len(results) == 100 | |
| def test_empty_context( | |
| self, | |
| benchmark, | |
| scorer, | |
| json_items_100, | |
| ): | |
| """Benchmark with empty query context. | |
| Target: < 0.5ms | |
| Tests early-exit optimization. | |
| """ | |
| results = benchmark(scorer.score_batch, json_items_100, "") | |
| # All scores should be 0 with no context | |
| assert all(r.score == 0.0 for r in results) | |
| def test_long_items( | |
| self, | |
| benchmark, | |
| scorer, | |
| log_entries_1000, | |
| query_context_semantic, | |
| ): | |
| """Benchmark scoring longer items (log entries). | |
| Target: < 15ms | |
| Tests performance with larger text per item. | |
| """ | |
| json_items = [json.dumps(entry) for entry in log_entries_1000] | |
| results = benchmark(scorer.score_batch, json_items, query_context_semantic) | |
| assert len(results) == 1000 | |
| class TestHybridBenchmarks: | |
| """Benchmarks for Hybrid BM25+Embedding scorer. | |
| HybridScorer performs: | |
| - BM25 scoring (always) | |
| - Embedding scoring (if available) | |
| - Adaptive alpha computation | |
| - Score fusion | |
| Without embeddings (fallback mode): | |
| - Single item: < 0.2ms | |
| - Batch 100: < 2ms | |
| With embeddings (full mode): | |
| - Single item: < 5ms (model inference) | |
| - Batch 100: < 50ms (batched inference) | |
| """ | |
| def scorer_fallback(self): | |
| """Create hybrid scorer without embeddings (BM25 fallback).""" | |
| from headroom.relevance.bm25 import BM25Scorer | |
| from headroom.relevance.hybrid import HybridScorer | |
| # Force BM25-only mode by not providing embedding scorer | |
| scorer = HybridScorer( | |
| alpha=0.5, | |
| adaptive=True, | |
| bm25_scorer=BM25Scorer(), | |
| embedding_scorer=None, | |
| ) | |
| # Ensure we're in fallback mode | |
| scorer._embedding_available = False | |
| return scorer | |
| def scorer_full(self): | |
| """Create hybrid scorer with embeddings (if available).""" | |
| from headroom.relevance.hybrid import HybridScorer | |
| scorer = HybridScorer(alpha=0.5, adaptive=True) | |
| return scorer | |
| def test_single_item_fallback( | |
| self, | |
| benchmark, | |
| scorer_fallback, | |
| json_items_100, | |
| query_context_uuid, | |
| ): | |
| """Benchmark single item scoring (BM25 fallback). | |
| Target: < 0.2ms | |
| Tests fallback mode overhead. | |
| """ | |
| item = json_items_100[0] | |
| result = benchmark(scorer_fallback.score, item, query_context_uuid) | |
| assert "BM25 only" in result.reason | |
| def test_batch_100_fallback( | |
| self, | |
| benchmark, | |
| scorer_fallback, | |
| json_items_100, | |
| query_context_uuid, | |
| ): | |
| """Benchmark batch scoring (BM25 fallback). | |
| Target: < 2ms | |
| Tests fallback batch performance. | |
| """ | |
| results = benchmark(scorer_fallback.score_batch, json_items_100, query_context_uuid) | |
| assert len(results) == 100 | |
| def test_adaptive_alpha_uuid( | |
| self, | |
| benchmark, | |
| scorer_fallback, | |
| json_items_100, | |
| query_context_uuid, | |
| ): | |
| """Benchmark adaptive alpha with UUID query. | |
| Target: < 2ms | |
| Tests alpha computation overhead. | |
| """ | |
| results = benchmark(scorer_fallback.score_batch, json_items_100, query_context_uuid) | |
| # UUID query should favor BM25 (but we're in fallback mode) | |
| assert len(results) == 100 | |
| def test_adaptive_alpha_semantic( | |
| self, | |
| benchmark, | |
| scorer_fallback, | |
| json_items_100, | |
| query_context_semantic, | |
| ): | |
| """Benchmark adaptive alpha with semantic query. | |
| Target: < 2ms | |
| Tests alpha computation for semantic queries. | |
| """ | |
| results = benchmark(scorer_fallback.score_batch, json_items_100, query_context_semantic) | |
| assert len(results) == 100 | |
| def test_single_item_full( | |
| self, | |
| benchmark, | |
| scorer_full, | |
| json_items_100, | |
| query_context_uuid, | |
| ): | |
| """Benchmark single item with embeddings. | |
| Target: < 5ms | |
| Tests full hybrid mode (requires sentence-transformers). | |
| """ | |
| if not scorer_full.has_embedding_support(): | |
| pytest.skip("Embeddings not available") | |
| item = json_items_100[0] | |
| result = benchmark(scorer_full.score, item, query_context_uuid) | |
| # Should show hybrid scoring | |
| assert "Hybrid" in result.reason | |
| def test_batch_100_full( | |
| self, | |
| benchmark, | |
| scorer_full, | |
| json_items_100, | |
| query_context_uuid, | |
| ): | |
| """Benchmark batch scoring with embeddings. | |
| Target: < 50ms | |
| Tests batched embedding inference. | |
| """ | |
| if not scorer_full.has_embedding_support(): | |
| pytest.skip("Embeddings not available") | |
| results = benchmark(scorer_full.score_batch, json_items_100, query_context_uuid) | |
| assert len(results) == 100 | |
| class TestScorerFactoryBenchmarks: | |
| """Benchmarks for scorer factory and initialization.""" | |
| def test_create_bm25_scorer(self, benchmark): | |
| """Benchmark BM25 scorer creation. | |
| Target: < 0.1ms | |
| Tests initialization overhead. | |
| """ | |
| from headroom.relevance import create_scorer | |
| scorer = benchmark(create_scorer, tier="bm25") | |
| assert scorer is not None | |
| def test_create_hybrid_scorer(self, benchmark): | |
| """Benchmark hybrid scorer creation. | |
| Target: < 1ms (without embedding model load) | |
| Tests initialization with fallback. | |
| """ | |
| from headroom.relevance import create_scorer | |
| scorer = benchmark(create_scorer, tier="hybrid") | |
| assert scorer is not None | |
| class TestRelevanceInSmartCrusher: | |
| """Benchmarks for relevance scoring within SmartCrusher context. | |
| Tests the realistic scenario where SmartCrusher uses relevance | |
| scoring to determine which items to preserve during compression. | |
| """ | |
| def crusher_with_bm25(self, smart_crusher_config): | |
| """SmartCrusher with BM25 relevance scorer.""" | |
| from headroom.config import RelevanceScorerConfig | |
| from headroom.transforms.smart_crusher import SmartCrusher | |
| return SmartCrusher( | |
| config=smart_crusher_config, | |
| relevance_config=RelevanceScorerConfig(tier="bm25"), | |
| ) | |
| def crusher_with_hybrid(self, smart_crusher_config): | |
| """SmartCrusher with hybrid relevance scorer.""" | |
| from headroom.config import RelevanceScorerConfig | |
| from headroom.transforms.smart_crusher import SmartCrusher | |
| return SmartCrusher( | |
| config=smart_crusher_config, | |
| relevance_config=RelevanceScorerConfig(tier="hybrid"), | |
| ) | |
| def test_crush_with_bm25_relevance( | |
| self, | |
| benchmark, | |
| crusher_with_bm25, | |
| mock_tokenizer, | |
| items_100, | |
| ): | |
| """Benchmark crushing with BM25 relevance scoring. | |
| Target: < 3ms | |
| Tests BM25 integration overhead. | |
| """ | |
| messages = [ | |
| {"role": "system", "content": "You are a helpful assistant."}, | |
| {"role": "user", "content": "Find user 550e8400-e29b-41d4-a716-446655440000"}, | |
| { | |
| "role": "tool", | |
| "tool_call_id": "call_1", | |
| "content": json.dumps(items_100), | |
| }, | |
| ] | |
| result = benchmark(crusher_with_bm25.apply, messages, mock_tokenizer) | |
| assert result.tokens_after < result.tokens_before | |
| def test_crush_with_hybrid_relevance( | |
| self, | |
| benchmark, | |
| crusher_with_hybrid, | |
| mock_tokenizer, | |
| items_100, | |
| ): | |
| """Benchmark crushing with hybrid relevance scoring. | |
| Target: < 60ms (with embeddings) or < 3ms (fallback) | |
| Tests hybrid integration. | |
| """ | |
| messages = [ | |
| {"role": "system", "content": "You are a helpful assistant."}, | |
| {"role": "user", "content": "Show me failed requests and errors"}, | |
| { | |
| "role": "tool", | |
| "tool_call_id": "call_1", | |
| "content": json.dumps(items_100), | |
| }, | |
| ] | |
| result = benchmark(crusher_with_hybrid.apply, messages, mock_tokenizer) | |
| assert result.tokens_after < result.tokens_before | |
| def test_crush_large_with_relevance( | |
| self, | |
| benchmark, | |
| crusher_with_bm25, | |
| mock_tokenizer, | |
| items_1000, | |
| ): | |
| """Benchmark crushing 1000 items with relevance. | |
| Target: < 15ms | |
| Tests scalability of relevance scoring. | |
| """ | |
| messages = [ | |
| {"role": "system", "content": "You are a helpful assistant."}, | |
| {"role": "user", "content": "Search for Alice and find any errors"}, | |
| { | |
| "role": "tool", | |
| "tool_call_id": "call_1", | |
| "content": json.dumps(items_1000), | |
| }, | |
| ] | |
| result = benchmark(crusher_with_bm25.apply, messages, mock_tokenizer) | |
| assert result.tokens_after < result.tokens_before | |
| def _check_embedding_available() -> bool: | |
| """Check if embedding scorer is available.""" | |
| try: | |
| from headroom.relevance.embedding import EmbeddingScorer | |
| return EmbeddingScorer.is_available() | |
| except ImportError: | |
| return False | |