Local skill search returns irrelevant results for multi-word queries (BM25 signal discarded)
BUG: Local skill search returns irrelevant results for multi-word queries (BM25 signal discarded + all-token lexical boost)
Environment
- OpenSpace v2.0.0 (main, 2026-08-05)
- No embedding provider configured (no OPENAI_API_KEY / OPENROUTER_API_KEY)
Symptom
search_skills (MCP tool) / SkillSearchEngine with query_embedding=None returns registry-order (alphabetical) results for any multi-word query. Example with the standard bundled skills:
Q: "docx document" -> apple-notes, apple-reminders, findmy, imessage (docx skill missing!)
Q: "chrome cdp" -> apple-notes, apple-reminders, findmy, imessage
Q: "browser automation" -> works only because both tokens appear in one slugSingle-token queries (e.g. "docx") work. Skills are ingested correctly (name/description parsed fine); the problem is purely in the ranking pipeline.
Root cause — two independent bugs in openspace/cloud/search.py
1. BM25 phase result is discarded (SkillSearchEngine._bm25_phase / _score_phase)
_bm25_phase computes BM25 scores on temporary SkillCandidate objects and filters candidates, but the scores are never attached to the candidate dicts, and _score_phase computes
final_score = ranking_signal_score + lexical_boost # bm25_score not usedWhen no embedding provider is configured, ranking_signal_score = 0 for every candidate, so the BM25 ordering is completely thrown away and the final sort degenerates to candidate (registry) order.
2. _lexical_boost requires ALL query tokens to match
if slug_tokens and all(any(ct == qt for ct in slug_tokens) for qt in query_tokens):
boost += 1.4For a multi-token query like "docx document", the slug docx can never contain document, so boost stays 0 for every skill — even a perfect name match.
Minimal repro (no API keys needed)
from openspace.skill_engine.registry import SkillRegistry
from openspace.cloud.search import build_local_candidates, SkillSearchEngine
from pathlib import Path
reg = SkillRegistry(skill_dirs=[Path("openspace/skills")])
reg.discover()
cands = build_local_candidates(reg.list_skills(), None)
res = SkillSearchEngine().search("docx document", cands, query_embedding=None, limit=5)
print([r["name"] for r in res]) # -> ['apple-notes', 'apple-reminders', 'findmy', 'imessage', ...]Proposed fix (9 insertions, 1 deletion — verified)
In _bm25_phase, carry the BM25 score onto the candidate dicts:
bm25_scores = {sc.skill_id: sc.bm25_score for sc in ranked}
filtered = [c for c in candidates if c.get("skill_id") in ranked_ids]
for c in filtered:
c["_bm25_score"] = bm25_scores.get(c.get("skill_id"), 0.0)In _score_phase, include it in the final score:
bm25_score = candidate.get("_bm25_score") or 0.0
final_score = ranking_signal_score + lexical_boost + bm25_scoreOptionally relax _lexical_boost from all-token to partial-token matching.
Verification
With the fix above (no embedding provider):
Q: "create word docx document" -> docx (0.75)
Q: "chrome cdp browser automation" -> hermes-browser-toolset, browser-agent-benchmark, browser-automation, desktop-browser-operations
Q: "github pull request workflow" -> github-pr-workflowFull test suite: pytest -q --ignore=tests/benchmarks → 104 passed (tests/cloud + tests/skill_engine included).
Source: HKUDS/OpenSpace