#115·OpenSpace

Local skill search returns irrelevant results for multi-word queries (BM25 signal discarded)

Author: PerfectDraftCreated Aug 5, 2026Updated Aug 5, 2026

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 slug

Single-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

python
final_score = ranking_signal_score + lexical_boost   # bm25_score not used

When 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

python
if slug_tokens and all(any(ct == qt for ct in slug_tokens) for qt in query_tokens):
    boost += 1.4

For 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)

python
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:

python
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:

python
bm25_score = candidate.get("_bm25_score") or 0.0
final_score = ranking_signal_score + lexical_boost + bm25_score

Optionally 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-workflow

Full test suite: pytest -q --ignore=tests/benchmarks104 passed (tests/cloud + tests/skill_engine included).