Skip to content

Commit 4bb18e2

Browse files
committed
feat: introduce configurable hybrid search alpha and reranker top-k multiplier with telemetry and quality evaluation tools
1 parent 47e8f17 commit 4bb18e2

9 files changed

Lines changed: 393 additions & 9 deletions

File tree

aimodels.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,5 @@ eval_models:
2020
# Configuration
2121
config:
2222
sufficiency_threshold: 0.8 # Configurable threshold for local-first answering
23+
hybrid_alpha: 0.2
24+
reranker_top_k_multiplier: 5

src/knowcode/config.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,14 @@ class AppConfig:
3333
"eval_models",
3434
"config",
3535
}
36-
KNOWN_CONFIG_KEYS = {"sufficiency_threshold"}
36+
KNOWN_CONFIG_KEYS = {"sufficiency_threshold", "hybrid_alpha", "reranker_top_k_multiplier"}
3737

3838
models: list[ModelConfig] = field(default_factory=list)
3939
embedding_models: list[ModelConfig] = field(default_factory=list)
4040
reranking_models: list[ModelConfig] = field(default_factory=list)
4141
sufficiency_threshold: float = 0.8 # For local-first answering
42+
hybrid_alpha: float = 0.2
43+
reranker_top_k_multiplier: int = 5
4244

4345
@classmethod
4446
def load(cls, config_path: Optional[str] = None, strict: bool = False) -> "AppConfig":
@@ -87,6 +89,8 @@ def default(cls) -> "AppConfig":
8789
)
8890
],
8991
sufficiency_threshold=0.8,
92+
hybrid_alpha=0.2,
93+
reranker_top_k_multiplier=5,
9094
)
9195

9296
@classmethod
@@ -153,6 +157,14 @@ def _load_from_yaml(cls, path: Path, strict: bool = False) -> "AppConfig":
153157
if not isinstance(sufficiency_threshold, (int, float)):
154158
raise ValueError("'config.sufficiency_threshold' must be a number.")
155159

160+
hybrid_alpha = config_section.get("hybrid_alpha", 0.5)
161+
if not isinstance(hybrid_alpha, (int, float)):
162+
raise ValueError("'config.hybrid_alpha' must be a number.")
163+
164+
reranker_top_k_multiplier = config_section.get("reranker_top_k_multiplier", 5)
165+
if not isinstance(reranker_top_k_multiplier, int):
166+
raise ValueError("'config.reranker_top_k_multiplier' must be an integer.")
167+
156168
if not models:
157169
models = cls.default().models
158170

@@ -161,6 +173,8 @@ def _load_from_yaml(cls, path: Path, strict: bool = False) -> "AppConfig":
161173
embedding_models=embedding_models,
162174
reranking_models=reranking_models,
163175
sufficiency_threshold=sufficiency_threshold,
176+
hybrid_alpha=hybrid_alpha,
177+
reranker_top_k_multiplier=reranker_top_k_multiplier,
164178
)
165179
except Exception as e:
166180
message = f"Failed to load config from {path}: {e}"

src/knowcode/retrieval/hybrid_index.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,10 @@ def search(
4747
# 1. BM25 Search
4848
query_tokens = tokenize_code(query)
4949
# We get more results for fusion
50-
sparse_results = self.chunk_repo.search_by_tokens(query_tokens, limit=limit * 2)
50+
sparse_results = self.chunk_repo.search_by_tokens(query_tokens, limit=limit * 3)
5151

5252
# 2. Vector Search
53-
dense_results = self.vector_store.search(query_embedding, limit=limit * 2)
53+
dense_results = self.vector_store.search(query_embedding, limit=limit * 3)
5454

5555
# 3. Combine scores (RRF)
5656
combined_scores: dict[str, float] = {}

src/knowcode/retrieval/reranker.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,10 @@ def __init__(
4444
try:
4545
from knowcode.llm.voyageai_client import get_voyageai_client
4646
self.voyage_client = get_voyageai_client(api_key_env)
47-
except Exception:
48-
pass # Fall back to signal-based
47+
except ImportError as e:
48+
print(f" ⚠️ VoyageAI client not available (missing package): {e}")
49+
except ValueError as e:
50+
print(f" ⚠️ VoyageAI client not available (configuration error): {e}")
4951

5052
def rerank(
5153
self,
@@ -75,15 +77,45 @@ def rerank(
7577

7678
# Try VoyageAI cross-encoder reranking
7779
if self.voyage_client:
80+
start_time = time.time()
7881
try:
79-
return self._rerank_with_voyageai(query, chunks, top_k)
82+
result = self._rerank_with_voyageai(query, chunks, top_k)
83+
latency = time.time() - start_time
84+
self._log_telemetry("voyageai", latency, len(chunks))
85+
return result
86+
except ValueError as e:
87+
print(f" ⚠️ VoyageAI reranking failed (invalid input): {e}. Using signal-based fallback.")
88+
except ConnectionError as e:
89+
print(f" ⚠️ VoyageAI reranking failed (network error): {e}. Using signal-based fallback.")
8090
except Exception as e:
91+
import logging
92+
logging.error(f"VoyageAI reranking API failed: {e}")
8193
print(f" ⚠️ VoyageAI reranking failed: {e}. Using signal-based fallback.")
8294

8395
# Fallback to signal-based reranking
84-
return self._rerank_with_signals(
96+
start_time = time.time()
97+
result = self._rerank_with_signals(
8598
query, chunks, boost_recent, boost_documented, top_k
8699
)
100+
latency = time.time() - start_time
101+
self._log_telemetry("signal_based", latency, len(chunks))
102+
return result
103+
104+
def _log_telemetry(self, method: str, latency: float, num_chunks: int) -> None:
105+
"""Log reranking latency telemetry."""
106+
from knowcode.telemetry import log_event
107+
try:
108+
log_event(
109+
".", # We don't have store_path here, telemetry.py handles fallback to current dir
110+
{
111+
"event_type": "reranker_latency",
112+
"method": method,
113+
"latency_seconds": latency,
114+
"num_chunks": num_chunks,
115+
}
116+
)
117+
except Exception:
118+
pass
87119

88120
def _rerank_with_voyageai(
89121
self,

src/knowcode/retrieval/search_engine.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def __init__(
5353
use_voyageai=use_voyageai_reranking,
5454
config=config,
5555
)
56+
self.multiplier = config.reranker_top_k_multiplier if config else 5
5657

5758
def search_scored(
5859
self,
@@ -74,7 +75,7 @@ def search_scored(
7475
Ranked list of ScoredChunk objects.
7576
"""
7677
query_embedding = self.embedding_provider.embed_single(query)
77-
results = self.hybrid_index.search(query, query_embedding, limit=limit * 2)
78+
results = self.hybrid_index.search(query, query_embedding, limit=limit * self.multiplier)
7879
reranked = self.reranker.rerank(query, results, top_k=limit)
7980
primary = [ScoredChunk(chunk=c, score=s, source="retrieved") for c, s in reranked]
8081

src/knowcode/service.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,11 @@ def get_search_engine(
169169
from knowcode.retrieval.search_engine import SearchEngine
170170

171171
indexer = self.get_indexer(index_path)
172-
hybrid_index = HybridIndex(indexer.chunk_repo, indexer.vector_store)
172+
hybrid_index = HybridIndex(
173+
indexer.chunk_repo,
174+
indexer.vector_store,
175+
alpha=self.app_config.hybrid_alpha
176+
)
173177

174178
self._search_engine = SearchEngine(
175179
indexer.chunk_repo,

0 commit comments

Comments
 (0)