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.
13 KiB
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:
- 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.
- BM25 Ranking: The (expanded) candidate competences are indexed with BM25. Each required competence is used as a query to rank the candidates.
- 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(defaultfalse): enables BM25 + RRF.use_auto_tagging(defaultfalse): enables LLM-based competence expansion before BM25 scoring. Strongly recommended whenuse_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. Whenfalse, 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) -> strmethod supporting structured JSON responses via Azure OpenAI chat completions API. - Add
chat_deploymentandllm_api_keyconstructor 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(): givenrequired: list[str]andexisting: list[str], returnslist[str]— the combined listexisting + additions, whereadditionsare the canonical required-competence names that the LLM identifies as already covered byexistingentries.- Structured JSON output schema enforced via the Azure OpenAI
response_formatparameter. - 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 tomax(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_bm25from 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.kconstant (default60, standard RRF value from Cormack et al. 2009).- Returns
dict[str, float]mapping candidate text → fused score in(0, 1](top-ranked candidate receives exactly1.0).
8. SimilarityEngine.compute_competence_similarity() extended
- Reads
use_bm25_searchanduse_auto_taggingflags. - Accepts an optional
global_index: Bm25Index | Noneparameter (passed in byMatcher). - When
use_bm25_search=True:- If
use_auto_tagging=True: callsAutoTagger.expand_competences()to build the expanded candidate list. - Delegates to
_bm25_rrf_similarity()with the (optionally expanded) candidate list and the global index.
- If
_bm25_rrf_similarity()usesglobal_index.rank(req)filtered to the candidate's competence set whenglobal_indexis provided, otherwise falls back tobm25_rank_competences(req, candidate).- Output shape is identical to existing strategies:
dict[required_competence → {score, best_match, rationale}] - Preserves the
Matcherand 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 ≈ 5–30 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 = Falseuse_auto_tagging: bool = False
10. AzureOpenAIConfig dataclass extended
New fields:
chat_deployment: str = ""- (API key via
AZURE_OPENAI_LLM_API_KEYenv 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_searchanduse_auto_taggingflags when constructingSimilarityEngine. - When
use_auto_tagging=True: constructsAutoTaggerwith the LLM-capableAzureOpenAIClientand passes it toSimilarityEngine.
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 forBm25Indexandbm25_rank_competences().tests/test_rrf.py: unit tests forreciprocal_rank_fusion().tests/test_auto_tagger.py: unit tests forAutoTagger.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 topyproject.tomldependencies.
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.