Files
Orchestrator/bahn/teamlandkarte-mcp/openspec/changes/add-bm25-rrf-competence-matching/proposal.md
T
ankn a5f8fb49ab Migrate all repos into monorepo context folders
Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
      Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
      Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)

Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
2026-06-30 20:39:52 +02:00

13 KiB
Raw Blame History

Change Proposal: BM25 + RRF Competence Matching

  • Change ID: add-bm25-rrf-competence-matching
  • Status: Proposed
  • Target: teamlandkarte-mcp
  • Author: Thomas Handke
  • Date: 2026-02-26

Summary

Add an optional BM25-based competence matching strategy as an alternative to the existing embedding-based similarity approach. When enabled, the following pipeline replaces embedding-based competence similarity:

  1. Auto-Tagging (LLM): Before BM25 scoring, an LLM compares the required competences against each candidate's existing competences and suggests canonical additions (synonyms, abbreviations, cross-language equivalents). The expanded competence list is ephemeral — it is used only for the current scoring call and is never persisted.
  2. BM25 Ranking: The (expanded) candidate competences are indexed with BM25. Each required competence is used as a query to rank the candidates.
  3. RRF Fusion: Reciprocal Rank Fusion normalizes the BM25 ranked list into a score in [0, 1] per required competence.

The feature is controlled by two new booleans in config.toml:

  • use_bm25_search (default false): enables BM25 + RRF.
  • use_auto_tagging (default false): enables LLM-based competence expansion before BM25 scoring. Strongly recommended when use_bm25_search = true.

Why

The current embedding-based (per_skill / aggregate) strategy has a known weakness: it assigns non-trivial scores to candidates whose competences are only semantically adjacent to the required skills but not actually matching.

Example (observed in production): Searching for Python + Machine Learning, a candidate with only JavaScript, TypeScript, Node.js, Vue.js, Web Architekturen received a score of 0.392 because the embedding model considers all programming languages semantically related.

BM25 (Best Match 25) is a classical lexical ranking algorithm that scores documents by term overlap between query and document. It is immune to the semantic drift problem because it only counts tokens that literally occur in the candidate text. BM25 will assign 0 to "JavaScript" when the query is "Python".

When BM25 outperforms embeddings

Scenario Embeddings BM25
Query "Python", candidate has "Python" High ✓ High ✓
Query "Python", candidate has "JavaScript" Medium ✗ Zero ✓
Query "Machine Learning", candidate has "ML" High ✓ Low ✗
Query "Softwarearchitektur", candidate has "software architecture" High ✓ Low ✗
Query "React", candidate has "React.js" High ✓ Medium ~

BM25 excels at exact or near-exact skill names. Embeddings excel at synonyms and cross-language variants (e.g., German/English). The optional use_auto_tagging feature bridges BM25's synonym and cross-language gap by having an LLM pre-expand the candidate competence list — making the combination of BM25 + Auto-Tagging competitive with embeddings while still eliminating false positives from semantic drift.

Why RRF for fusion

Reciprocal Rank Fusion merges multiple ranked lists into one without requiring score normalization. For each required competence query, we obtain a ranked list of candidate competences. RRF then assigns each candidate a fused score based on its (1-based) rank in one or more lists.

RRF fused score formula (Cormack et al. 2009):

rrf_score(candidate) = Σ_i  1 / (k + rank_i(candidate))

RRF is robust to score scale differences and has been widely validated in information retrieval research as a high-quality fusion method.

What Changes

1. New config parameter matching.similarity.use_bm25_search (boolean, default false)

  • When false (default): existing embedding-based strategy is used unchanged.
  • When true: BM25 + RRF replaces embedding-based competence similarity. Role similarity is always embedding-based (unchanged).

2. New config parameter matching.similarity.use_auto_tagging (boolean, default false)

  • Only applies when use_bm25_search = true. When false, BM25 runs on the raw candidate competence list (no LLM expansion).
  • When true: before each BM25 scoring call, an LLM expands the candidate's competence list with canonical forms of any required competences that are already covered by the candidate's existing entries (via synonym, abbreviation, or cross-language equivalence).
  • The expanded list is ephemeral — it is not written back to the database and does not affect any other operation.

3. New config parameter azure_openai.chat_deployment (string)

  • Azure deployment name of the chat model used for auto-tagging (e.g. "gpt-4.1").
  • Required only when use_auto_tagging = true.
  • API key is read from environment variable AZURE_OPENAI_LLM_API_KEY.

4. AzureOpenAIClient extended: LLM chat capability restored

  • Add chat_completion(system: str, user: str) -> str method supporting structured JSON responses via Azure OpenAI chat completions API.
  • Add chat_deployment and llm_api_key constructor parameters.
  • This restores LLM capability removed in a previous change, now scoped exclusively to auto-tagging.

5. New module src/teamlandkarte_mcp/matching/auto_tagger.py

Implements:

  • AutoTagger: class that wraps the LLM client and executes the tagging prompt.
  • expand_competences(): given required: list[str] and existing: list[str], returns list[str] — the combined list existing + additions, where additions are the canonical required-competence names that the LLM identifies as already covered by existing entries.
  • Structured JSON output schema enforced via the Azure OpenAI response_format parameter.
  • Google Docstrings on all public symbols.

6. New module src/teamlandkarte_mcp/matching/bm25.py

Implements:

  • Bm25Index: builds an in-memory BM25 index over a corpus of competence strings. Designed to be instantiated once over the global candidate pool (see item 8a below) so IDF values are meaningful. Raw scores are clamped to max(0.0, raw) — terms with negative IDF produce 0.0 rather than a spurious tiny positive value.
  • bm25_rank_competences(): for a single required competence query, returns a ranked list of (candidate_text, score) tuples. Convenience wrapper for standalone/test use.
  • Pure Python, no external runtime dependencies beyond rank_bm25 from PyPI.

7. New module src/teamlandkarte_mcp/matching/rrf.py

Implements:

  • reciprocal_rank_fusion(): merges one or more ranked lists into a final score dict.
  • k constant (default 60, standard RRF value from Cormack et al. 2009).
  • Returns dict[str, float] mapping candidate text → fused score in (0, 1] (top-ranked candidate receives exactly 1.0).

8. SimilarityEngine.compute_competence_similarity() extended

  • Reads use_bm25_search and use_auto_tagging flags.
  • Accepts an optional global_index: Bm25Index | None parameter (passed in by Matcher).
  • When use_bm25_search=True:
    1. If use_auto_tagging=True: calls AutoTagger.expand_competences() to build the expanded candidate list.
    2. Delegates to _bm25_rrf_similarity() with the (optionally expanded) candidate list and the global index.
  • _bm25_rrf_similarity() uses global_index.rank(req) filtered to the candidate's competence set when global_index is provided, otherwise falls back to bm25_rank_competences(req, candidate).
  • Output shape is identical to existing strategies: dict[required_competence → {score, best_match, rationale}]
  • Preserves the Matcher and scorer contracts unchanged.

8a. Matcher.match() builds a global Bm25Index before the per-candidate loop

When use_bm25_search=True and the filtered candidate pool is non-empty, Matcher.match() collects the deduplicated union of all filtered candidates' competences and builds one Bm25Index over that global corpus before the loop. The same index is passed to every compute_competence_similarity() call.

Why this matters: With a per-person corpus (N ≈ 530 entries), BM25 IDF is 0 or negative for every term — all raw scores collapse to ≤ 0, and max(0.0, raw) clamping makes every match appear as 0.0. A global corpus ensures N is large enough for IDF to carry real signal.

9. SimilarityConfig dataclass extended

New fields:

  • use_bm25_search: bool = False
  • use_auto_tagging: bool = False

10. AzureOpenAIConfig dataclass extended

New fields:

  • chat_deployment: str = ""
  • (API key via AZURE_OPENAI_LLM_API_KEY env var)

11. config.py parser extended

Reads matching.similarity.use_bm25_search, matching.similarity.use_auto_tagging, and azure_openai.chat_deployment from TOML. Reads AZURE_OPENAI_LLM_API_KEY from environment when use_auto_tagging = true.

12. config.toml.example updated

Documents all three new keys with inline comments.

13. Server initialization in mcp_server.py

  • Passes use_bm25_search and use_auto_tagging flags when constructing SimilarityEngine.
  • When use_auto_tagging=True: constructs AutoTagger with the LLM-capable AzureOpenAIClient and passes it to SimilarityEngine.

14. Documentation updated

  • docs/architecture.md: new section on BM25/RRF/Auto-Tagging strategy.
  • docs/semantic_similarity_tradeoffs.md: comparison table updated with BM25+AutoTag entry.
  • docs/assistant_system_prompt.md: notes on score interpretation with BM25 and auto-tagging.
  • README.md: all new config parameters documented.

15. Legacy/deprecated code removed

  • Remove debug_similarity_analysis.py
  • Remove debug_categorization.py
  • Remove SEMANTIC_SIMILARITY_ANALYSIS.md
  • Remove SCORE_BUG_FIX.md

16. Tests

  • tests/test_bm25.py: unit tests for Bm25Index and bm25_rank_competences().
  • tests/test_rrf.py: unit tests for reciprocal_rank_fusion().
  • tests/test_auto_tagger.py: unit tests for AutoTagger.expand_competences() (mocked LLM).
  • tests/test_similarity.py: extend with BM25/RRF and auto-tagging strategy tests.
  • tests/test_matcher_integration.py: integration test for the full pipeline (auto-tag → BM25 → RRF) in both matching directions.

Impact

Affected specs

  • matching-tools (competence scoring path)

Affected code

File Change
src/teamlandkarte_mcp/matching/similarity.py Add _bm25_rrf_similarity(), global_index param, auto-tag branch, read flags
src/teamlandkarte_mcp/matching/matcher.py Build global Bm25Index before per-candidate loop; pass as global_index
src/teamlandkarte_mcp/matching/bm25.py New
src/teamlandkarte_mcp/matching/rrf.py New
src/teamlandkarte_mcp/matching/auto_tagger.py New
src/teamlandkarte_mcp/azure/openai_client.py Restore chat_completion(); add chat_deployment + llm_api_key params
src/teamlandkarte_mcp/config.py SimilarityConfig.use_bm25_search, use_auto_tagging; AzureOpenAIConfig.chat_deployment
src/teamlandkarte_mcp/mcp_server.py Pass flags + AutoTagger to SimilarityEngine
config.toml.example Document all new keys
docs/architecture.md BM25/RRF/Auto-Tagging section
docs/semantic_similarity_tradeoffs.md Updated comparison with BM25+AutoTag entry
docs/assistant_system_prompt.md Score notes for BM25 and auto-tagging modes
README.md All new config parameters
tests/test_bm25.py New
tests/test_rrf.py New
tests/test_auto_tagger.py New
tests/test_similarity.py Extended
tests/test_matcher_integration.py Extended
debug_similarity_analysis.py Removed
debug_categorization.py Removed
SEMANTIC_SIMILARITY_ANALYSIS.md Removed
SCORE_BUG_FIX.md Removed

Breaking changes

None. The use_bm25_search = false and use_auto_tagging = false defaults preserve all existing behavior. The overall_score computation (weighted mean of role_score and competence_score) is unchanged.

New environment variables

Variable Required when Description
AZURE_OPENAI_LLM_API_KEY use_auto_tagging = true Azure OpenAI API key for the chat model used in auto-tagging. The server raises a configuration error at startup if this key is absent and use_auto_tagging = true.

New dependencies

  • rank-bm25 (PyPI): pure-Python BM25 implementation, no native code, no external services. Adds to pyproject.toml dependencies.

Alternatives Considered

A. Hard-code exact-match bonus in per_skill

Simpler but not principled: would require special-casing the scorer instead of a clean strategy abstraction.

B. Hybrid embedding + BM25 in a single _bm25_rrf_similarity() call

Combining both signals via RRF is architecturally clean and naturally extensible. However, to keep this change focused and reviewable, the initial implementation uses BM25-only RRF. A hybrid mode can be added as a follow-up change.

C. Replace embeddings entirely

Not desirable. Embeddings are essential for role inference and handle multilingual / synonymous skill names (e.g., "Softwarearchitektur" ↔ "Software Architecture").

Open Questions

None. The design is sufficiently clear to proceed.