OpenSpace/openspace/skill_engine/skill_ranker.py
Fabio Scarsi 37083593d6 fix(search): content-addressed SkillRanker cache key
The embedding cache was keyed by skill_id alone, so any edit to a
SKILL.md body or description produced stale embeddings that
get_or_compute_embedding kept serving until a manual invalidate_cache
call or a file deletion. Previously this was mostly invisible because
select_skills_with_llm was the only caller exercising the cache; after
the preceding commit wires search_skills through the same path the
staleness becomes observable on every MCP query.

Use "{skill_id}:{sha256(embedding_text)[:16]}" as the cache key, so
any change to the text produced by _build_embedding_text (name +
description + body, truncated to SKILL_EMBEDDING_MAX_CHARS) causes
an automatic cache miss and a fresh embedding. Both
get_or_compute_embedding and _embedding_rank are updated.

Bounded growth: on each successful new compute, older entries with
the same "{skill_id}:" prefix are pruned in the same write. Net
result: at most one cached embedding per skill_id at any time, aside
from transient migration state.

Backward compatibility: existing pickle files keyed by skill_id alone
are migrated in place on first lookup (no API call needed); the old
key is dropped after migration.

invalidate_cache(skill_id) now removes every content-addressed entry
and any legacy entry for that skill_id, so historical versions do
not leak across evolutions.

Functional benchmark on a 28-skill local registry with
text-embedding-3-small via OpenRouter: top-1 match identity preserved
on all test queries, score drift below 0.001, warm latency
~260-400ms/query (unchanged from the previous commit).
2026-04-18 12:44:47 +02:00

478 lines
17 KiB
Python

"""SkillRanker — BM25 + embedding hybrid ranking for skills.
Provides a two-stage retrieval pipeline for skill selection:
Stage 1 (BM25): Fast lexical rough-rank over all skills
Stage 2 (Embedding): Semantic re-rank on BM25 candidates
Embedding strategy:
- Text = ``name + description + SKILL.md body`` (consistent with MCP
``search_skills`` and the clawhub cloud platform)
- Model: ``qwen/qwen3-embedding-8b`` via OpenRouter API
- Embeddings are cached in-memory keyed by ``skill_id`` and optionally
persisted to a pickle file for cross-session reuse
Reused by:
- ``SkillRegistry.select_skills_with_llm`` — pre-filter before LLM selection
- ``mcp_server.search_skills`` — BM25 stage of the MCP search tool
"""
from __future__ import annotations
import hashlib
import json
import math
import os
import pickle
import re
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from openspace.utils.logging import Logger
logger = Logger.get_logger(__name__)
# Embedding model — must match clawhub platform for vector-space compatibility
SKILL_EMBEDDING_MODEL = "openai/text-embedding-3-small"
SKILL_EMBEDDING_MAX_CHARS = 12_000
# Pre-filter threshold: when local skills exceed this count, BM25 pre-filter
# is activated before LLM selection. Below this, all skills go directly to LLM.
PREFILTER_THRESHOLD = 10
# How many candidates to keep after BM25 rough-rank (before embedding re-rank)
BM25_CANDIDATES_MULTIPLIER = 3 # top_k * 3
# Cache version — increment when format changes
_CACHE_VERSION = 1
@dataclass
class SkillCandidate:
"""Lightweight skill representation for ranking."""
skill_id: str
name: str
description: str
body: str = "" # SKILL.md body (frontmatter stripped)
source: str = "local" # "local" | "cloud"
# Internal ranking fields
embedding: Optional[List[float]] = None
embedding_text: str = "" # text used to compute embedding
score: float = 0.0
bm25_score: float = 0.0
vector_score: float = 0.0
# Pass-through metadata (for MCP search results)
metadata: Dict[str, Any] = field(default_factory=dict)
class SkillRanker:
"""Hybrid BM25 + embedding ranker for skills.
Usage::
ranker = SkillRanker()
candidates = [SkillCandidate(skill_id=..., name=..., description=..., body=...)]
ranked = ranker.hybrid_rank(query, candidates, top_k=10)
"""
def __init__(
self,
*,
cache_dir: Optional[Path] = None,
enable_cache: bool = True,
) -> None:
# Embedding cache: skill_id → List[float]
self._embedding_cache: Dict[str, List[float]] = {}
self._enable_cache = enable_cache
if cache_dir is None:
try:
from openspace.config.constants import PROJECT_ROOT
cache_dir = PROJECT_ROOT / ".openspace" / "skill_embedding_cache"
except Exception:
cache_dir = Path(".openspace") / "skill_embedding_cache"
self._cache_dir = Path(cache_dir)
if self._enable_cache:
self._load_cache()
def hybrid_rank(
self,
query: str,
candidates: List[SkillCandidate],
top_k: int = 10,
) -> List[SkillCandidate]:
"""BM25 rough-rank → embedding re-rank → return top_k.
Falls back gracefully:
- No BM25 lib → simple token overlap
- No embedding API key → BM25-only
- Both fail → return first top_k candidates
"""
if not candidates or not query.strip():
return candidates[:top_k]
# Stage 1: BM25 rough-rank
bm25_top = self._bm25_rank(query, candidates, top_k * BM25_CANDIDATES_MULTIPLIER)
if not bm25_top:
# BM25 found nothing — try embedding on all candidates
emb_results = self._embedding_rank(query, candidates, top_k)
return emb_results if emb_results else candidates[:top_k]
# Stage 2: Embedding re-rank on BM25 candidates
emb_results = self._embedding_rank(query, bm25_top, top_k)
if emb_results:
return emb_results
# Embedding unavailable — return BM25 results
logger.debug("Embedding unavailable, using BM25-only results")
return bm25_top[:top_k]
def bm25_only(
self,
query: str,
candidates: List[SkillCandidate],
top_k: int = 30,
) -> List[SkillCandidate]:
"""BM25-only ranking (for MCP search Phase 1)."""
return self._bm25_rank(query, candidates, top_k)
def embedding_only(
self,
query: str,
candidates: List[SkillCandidate],
top_k: int = 10,
) -> List[SkillCandidate]:
"""Embedding-only ranking."""
return self._embedding_rank(query, candidates, top_k)
@staticmethod
def _content_key(skill_id: str, embedding_text: str) -> str:
"""Compose a content-addressed cache key.
The cache stores embeddings under ``"{skill_id}:{sha256(text)[:16]}"``
so that any change to the text used for embedding (name, description,
or body) automatically invalidates the cached entry without relying
on an external trigger.
"""
digest = hashlib.sha256(embedding_text.encode("utf-8")).hexdigest()[:16]
return f"{skill_id}:{digest}"
def get_or_compute_embedding(
self, candidate: SkillCandidate,
) -> Optional[List[float]]:
"""Get embedding from cache or compute it.
Returns None if embedding cannot be generated.
"""
# Already has embedding (e.g. cloud pre-computed)
if candidate.embedding:
return candidate.embedding
text = self._build_embedding_text(candidate)
content_key = self._content_key(candidate.skill_id, text)
# Check content-addressed cache
cached = self._embedding_cache.get(content_key)
if cached:
candidate.embedding = cached
return cached
# Backward-compat: old-format entries were keyed by skill_id alone.
# If present, migrate to the new key format without hitting the API.
legacy = self._embedding_cache.get(candidate.skill_id)
if legacy:
candidate.embedding = legacy
self._embedding_cache[content_key] = legacy
self._embedding_cache.pop(candidate.skill_id, None)
self._save_cache()
return legacy
# Compute
emb = self._generate_embedding(text)
if emb:
candidate.embedding = emb
self._embedding_cache[content_key] = emb
# Bound cache growth: previous versions of this skill are now
# obsolete, drop them in the same write.
for stale in [
k for k in self._embedding_cache
if k != content_key and k.startswith(f"{candidate.skill_id}:")
]:
self._embedding_cache.pop(stale, None)
self._save_cache()
return emb
def invalidate_cache(self, skill_id: str) -> None:
"""Remove all cached embeddings for a skill (e.g. after evolution).
Removes every cache entry whose key matches either the exact
``skill_id`` (legacy format) or the ``"{skill_id}:*"`` content-addressed
prefix, covering any historical content version that might linger.
"""
keys_to_drop = [
k for k in self._embedding_cache
if k == skill_id or k.startswith(f"{skill_id}:")
]
for k in keys_to_drop:
self._embedding_cache.pop(k, None)
if keys_to_drop:
self._save_cache()
def clear_cache(self) -> None:
"""Clear all cached embeddings."""
self._embedding_cache.clear()
self._save_cache()
@staticmethod
def _tokenize(text: str) -> List[str]:
"""Tokenize text for BM25."""
tokens = re.split(r"[^\w]+", text.lower())
return [t for t in tokens if t]
def _bm25_rank(
self,
query: str,
candidates: List[SkillCandidate],
top_k: int,
) -> List[SkillCandidate]:
"""Rank candidates using BM25."""
if not candidates:
return []
try:
from rank_bm25 import BM25Okapi # type: ignore
except ImportError:
BM25Okapi = None
# Build corpus: name + description + truncated body for richer matching
corpus_tokens = []
for c in candidates:
text = f"{c.name} {c.description}"
if c.body:
text += f" {c.body[:2000]}" # include body for BM25 but cap length
corpus_tokens.append(self._tokenize(text))
query_tokens = self._tokenize(query)
if BM25Okapi and corpus_tokens:
bm25 = BM25Okapi(corpus_tokens)
scores = bm25.get_scores(query_tokens)
for c, s in zip(candidates, scores):
c.bm25_score = float(s)
else:
# Fallback: simple token overlap
q_set = set(query_tokens)
for c, toks in zip(candidates, corpus_tokens):
if not toks or not q_set:
c.bm25_score = 0.0
else:
overlap = q_set.intersection(toks)
c.bm25_score = len(overlap) / len(q_set)
# Sort and filter
ranked = sorted(candidates, key=lambda c: c.bm25_score, reverse=True)
# If all scores are 0 (no match), return all candidates (let embedding decide)
if all(c.bm25_score == 0.0 for c in ranked):
logger.debug("BM25 found no matches, passing all candidates to embedding stage")
return candidates[:top_k]
return ranked[:top_k]
@staticmethod
def _get_openai_api_key() -> Optional[str]:
"""Resolve OpenAI-compatible API key for embedding requests."""
from openspace.cloud.embedding import resolve_embedding_api
api_key, _ = resolve_embedding_api()
return api_key
@staticmethod
def _build_embedding_text(candidate: SkillCandidate) -> str:
"""Build text for embedding, consistent with MCP search_skills."""
if candidate.embedding_text:
return candidate.embedding_text
header = "\n".join(filter(None, [candidate.name, candidate.description]))
raw = "\n\n".join(filter(None, [header, candidate.body]))
if len(raw) > SKILL_EMBEDDING_MAX_CHARS:
raw = raw[:SKILL_EMBEDDING_MAX_CHARS]
candidate.embedding_text = raw
return raw
def _embedding_rank(
self,
query: str,
candidates: List[SkillCandidate],
top_k: int,
) -> List[SkillCandidate]:
"""Rank candidates using embedding cosine similarity."""
api_key = self._get_openai_api_key()
if not api_key:
return []
# Generate query embedding
query_emb = self._generate_embedding(query, api_key=api_key)
if not query_emb:
return []
# Ensure all candidates have embeddings (content-addressed cache
# with backward-compat fallback for legacy skill_id-only keys).
cache_dirty = False
for c in candidates:
if c.embedding:
continue
text = self._build_embedding_text(c)
content_key = self._content_key(c.skill_id, text)
cached = self._embedding_cache.get(content_key)
if cached:
c.embedding = cached
continue
legacy = self._embedding_cache.get(c.skill_id)
if legacy:
c.embedding = legacy
self._embedding_cache[content_key] = legacy
self._embedding_cache.pop(c.skill_id, None)
cache_dirty = True
continue
emb = self._generate_embedding(text, api_key=api_key)
if emb:
c.embedding = emb
self._embedding_cache[content_key] = emb
# Bound cache growth: previous versions of this skill are
# obsolete, drop them.
for stale in [
k for k in self._embedding_cache
if k != content_key and k.startswith(f"{c.skill_id}:")
]:
self._embedding_cache.pop(stale, None)
cache_dirty = True
# Save newly computed / migrated embeddings
if cache_dirty:
self._save_cache()
# Score
for c in candidates:
if c.embedding:
c.vector_score = _cosine_similarity(query_emb, c.embedding)
else:
c.vector_score = 0.0
c.score = c.vector_score
ranked = sorted(candidates, key=lambda c: c.score, reverse=True)
return ranked[:top_k]
@staticmethod
def _generate_embedding(
text: str,
api_key: Optional[str] = None,
) -> Optional[List[float]]:
"""Generate embedding via OpenAI-compatible API (text-embedding-3-small).
Delegates credential / base-URL resolution to
:func:`openspace.cloud.embedding.resolve_embedding_api`.
"""
from openspace.cloud.embedding import resolve_embedding_api
resolved_key, base_url = resolve_embedding_api()
if not api_key:
api_key = resolved_key
if not api_key:
return None
import urllib.request
body = json.dumps({
"model": SKILL_EMBEDDING_MODEL,
"input": text,
}).encode("utf-8")
req = urllib.request.Request(
f"{base_url}/embeddings",
data=body,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
},
method="POST",
)
import time
last_err = None
for attempt in range(3):
try:
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8"))
return data.get("data", [{}])[0].get("embedding")
except Exception as e:
last_err = e
if attempt < 2:
delay = 2 * (attempt + 1)
logger.debug("Embedding request failed (attempt %d/3), retrying in %ds: %s", attempt + 1, delay, e)
time.sleep(delay)
logger.warning("Skill embedding generation failed after 3 attempts: %s", last_err)
return None
def _cache_file(self) -> Path:
return self._cache_dir / f"skill_embeddings_v{_CACHE_VERSION}.pkl"
def _load_cache(self) -> None:
"""Load embedding cache from disk."""
path = self._cache_file()
if not path.exists():
return
try:
with open(path, "rb") as f:
data = pickle.load(f)
if isinstance(data, dict) and data.get("version") == _CACHE_VERSION:
self._embedding_cache = data.get("embeddings", {})
logger.debug(f"Loaded {len(self._embedding_cache)} skill embeddings from cache")
except Exception as e:
logger.warning(f"Failed to load skill embedding cache: {e}")
self._embedding_cache = {}
def _save_cache(self) -> None:
"""Persist embedding cache to disk."""
if not self._enable_cache or not self._embedding_cache:
return
try:
self._cache_dir.mkdir(parents=True, exist_ok=True)
data = {
"version": _CACHE_VERSION,
"model": SKILL_EMBEDDING_MODEL,
"last_updated": datetime.now().isoformat(),
"embeddings": self._embedding_cache,
}
with open(self._cache_file(), "wb") as f:
pickle.dump(data, f, protocol=pickle.HIGHEST_PROTOCOL)
except Exception as e:
logger.warning(f"Failed to save skill embedding cache: {e}")
def _cosine_similarity(a: List[float], b: List[float]) -> float:
"""Compute cosine similarity between two vectors."""
if len(a) != len(b) or not a:
return 0.0
dot = sum(x * y for x, y in zip(a, b))
norm_a = math.sqrt(sum(x * x for x in a))
norm_b = math.sqrt(sum(x * x for x in b))
if norm_a == 0 or norm_b == 0:
return 0.0
return dot / (norm_a * norm_b)
def build_skill_embedding_text(
name: str,
description: str,
readme_body: str,
max_chars: int = SKILL_EMBEDDING_MAX_CHARS,
) -> str:
"""Build text for skill embedding: ``name + description + SKILL.md body``.
Unified strategy matching MCP search_skills and clawhub platform.
"""
header = "\n".join(filter(None, [name, description]))
raw = "\n\n".join(filter(None, [header, readme_body]))
if len(raw) <= max_chars:
return raw
return raw[:max_chars]