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.
This commit is contained in:
@@ -0,0 +1,666 @@
|
||||
# Design: BM25 + RRF Competence Matching
|
||||
|
||||
## Context
|
||||
|
||||
This design specifies the internal architecture for the BM25 + Reciprocal Rank Fusion
|
||||
(RRF) competence matching strategy with optional LLM-based Auto-Tagging, controlled by
|
||||
`matching.similarity.use_bm25_search` and `matching.similarity.use_auto_tagging`.
|
||||
|
||||
The goal is to provide a lexical alternative to embedding-based similarity that assigns
|
||||
near-zero scores when candidate competences do not literally overlap with the required
|
||||
competences, while still producing a normalized score compatible with the existing
|
||||
`Matcher` and `scorer` contracts.
|
||||
|
||||
Auto-Tagging optionally extends BM25 by having an LLM pre-expand each candidate's
|
||||
competence list with canonical forms of any required competences it already covers
|
||||
(synonyms, abbreviations, cross-language equivalents). This bridges the lexical gap for
|
||||
known skill aliases without persisting any data.
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions
|
||||
|
||||
### 1. BM25 is per-skill only, not aggregate
|
||||
|
||||
**Decision:** `_bm25_rrf_similarity()` operates per required competence (like the existing
|
||||
`per_skill` strategy), not as an aggregate over all required competences.
|
||||
|
||||
**Rationale:**
|
||||
- Aggregate-style BM25 would require concatenating all required competences into a single
|
||||
query document, which loses the per-skill granularity needed for `matched_competences`
|
||||
and `missing_competences` diagnostic output.
|
||||
- Per-skill BM25 + RRF preserves the same output shape as `_per_skill_similarity()`,
|
||||
making it a drop-in replacement.
|
||||
|
||||
---
|
||||
|
||||
### 2. BM25 index is built once globally over all filtered candidates
|
||||
|
||||
**Decision:** One `Bm25Index` is built over the **union of all filtered candidates'
|
||||
competences** before the per-candidate scoring loop in `Matcher.match()`, not fresh per
|
||||
`compute_competence_similarity()` call. The index is passed in as `global_index` and each
|
||||
per-candidate call filters BM25 results down to that candidate's own competence set.
|
||||
|
||||
**Rationale:**
|
||||
- **IDF pathology fix:** With a per-person corpus (N = that person's competence count),
|
||||
BM25 IDF is `log((N − df + 0.5) / (df + 0.5))`. For N = 5–30 and any term that appears
|
||||
in most or all documents, this is 0 or negative. `rank_bm25` therefore returns zero or
|
||||
negative raw scores for typical real-world skill lists — making every BM25 match appear
|
||||
as zero before `max(0.0, raw)` clamping. A global corpus of all candidates' competences
|
||||
ensures N is large enough for IDF to carry meaningful signal.
|
||||
- **Still cheap:** Building one index over the deduplicated union of all candidates'
|
||||
competences is O(M) where M is the total unique competence count — still milliseconds.
|
||||
- **Correctness over caching:** The global index is not cached across MCP tool calls; it is
|
||||
rebuilt fresh for each `match_candidates` invocation, so stale state is impossible.
|
||||
|
||||
---
|
||||
|
||||
### 3. RRF with single ranked list (BM25 only)
|
||||
|
||||
**Decision:** For the initial implementation, `reciprocal_rank_fusion()` fuses a single
|
||||
ranked list (BM25 ranks only), effectively normalizing BM25 scores via the RRF formula.
|
||||
|
||||
**Formula (single list):**
|
||||
|
||||
```
|
||||
rrf_score(candidate) = 1 / (k + rank(candidate))
|
||||
```
|
||||
|
||||
where `k = 60` (standard constant, Cormack et al. 2009), and `rank` is 1-based.
|
||||
|
||||
**Output normalization:**
|
||||
The raw RRF scores sum to a value > 1 across all candidates. To map to [0.0, 1.0]:
|
||||
|
||||
```
|
||||
normalized = rrf_score(best_candidate) / rrf_score_at_rank_1
|
||||
= 1 / (k + rank) * (k + 1)
|
||||
= (k + 1) / (k + rank)
|
||||
```
|
||||
|
||||
For `k=60`, rank 1 → 1.0, rank 2 → 61/62 ≈ 0.984, rank 10 → 61/70 ≈ 0.871.
|
||||
|
||||
This means BM25 scores do NOT inherently drop to near-zero for adjacent candidates.
|
||||
To address this, RRF scores are **zeroed** if the underlying BM25 score is 0.0, because
|
||||
a BM25 score of 0 means **no token overlap at all**. This is the critical property that
|
||||
fixes the false-positive problem.
|
||||
|
||||
**Implementation:**
|
||||
```python
|
||||
def reciprocal_rank_fusion(
|
||||
ranked_lists: list[list[tuple[str, float]]],
|
||||
*,
|
||||
k: int = 60,
|
||||
) -> dict[str, float]:
|
||||
...
|
||||
# Skip lists with no BM25 signal at all
|
||||
if all(score == 0.0 for _, score in ranked_list):
|
||||
continue
|
||||
|
||||
# IMPORTANT: Keep (candidate, 0.0) entries in the ranked list for transparency,
|
||||
# but treat them as "no signal" for fusion. I.e., candidates with BM25==0.0 MUST
|
||||
# NOT receive a positive fused score just because they have a (low) rank at the
|
||||
# end of the list.
|
||||
for rank, (candidate, score) in enumerate(ranked_list, start=1):
|
||||
if score == 0.0:
|
||||
continue
|
||||
fused[candidate] += 1 / (k + rank)
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. `best_match` reported per required competence
|
||||
|
||||
**Decision:** `best_match` in the output dict is the candidate with the highest BM25 score
|
||||
(rank 1 in the BM25 list). If no candidate has any token overlap (BM25 = 0 for all),
|
||||
`best_match = None` and `score = 0.0`.
|
||||
|
||||
---
|
||||
|
||||
### 5. Role similarity unchanged
|
||||
|
||||
**Decision:** `compute_role_similarity()` is always embedding-based, regardless of
|
||||
`use_bm25_search`.
|
||||
|
||||
**Rationale:**
|
||||
- Role names are short, single-phrase labels ("Backend Developer", "Data Engineer").
|
||||
- Embeddings handle multilingual and synonym variants better for roles.
|
||||
- BM25's exact-match strength is less valuable for role inference.
|
||||
|
||||
---
|
||||
|
||||
### 6. `rank_bm25` as the BM25 library
|
||||
|
||||
**Decision:** Use the `rank-bm25` PyPI package (`BM25Okapi` class).
|
||||
|
||||
**Rationale:**
|
||||
- Pure Python, no C extensions or native code.
|
||||
- Actively maintained, MIT licensed.
|
||||
- Implements BM25 Okapi (the standard variant) with configurable `k1` and `b` parameters.
|
||||
- Minimal footprint, no additional build steps.
|
||||
|
||||
**Tokenization:**
|
||||
Candidate and query texts are tokenized by lowercasing and splitting on non-word
|
||||
characters (whitespace, hyphens, slashes, parentheses, dots):
|
||||
|
||||
```python
|
||||
import re
|
||||
|
||||
def _tokenize(text: str) -> list[str]:
|
||||
return [t for t in re.split(r"[\W_]+", text.lower()) if t]
|
||||
```
|
||||
|
||||
This ensures "Progressive Web App (PWA)" tokenizes to
|
||||
`["progressive", "web", "app", "pwa"]`, and "CI/CD Pipeline" becomes
|
||||
`["ci", "cd", "pipeline"]`.
|
||||
|
||||
---
|
||||
|
||||
### 7. No changes to `Matcher`, `scorer`, or output format
|
||||
|
||||
**Decision:** `_bm25_rrf_similarity()` returns `dict[str, dict[str, object]]` in the
|
||||
identical shape to `_per_skill_similarity()`:
|
||||
|
||||
```python
|
||||
{
|
||||
"Python": {
|
||||
"score": 0.0,
|
||||
"best_match": None,
|
||||
"rationale": "BM25: no token overlap with any candidate competence.",
|
||||
},
|
||||
"Machine Learning": {
|
||||
"score": 0.0,
|
||||
"best_match": None,
|
||||
"rationale": "BM25: no token overlap with any candidate competence.",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The `Matcher.match()` loop, the `ScoredCapacity` model, the `compute_overall()` scorer,
|
||||
and all MCP tool output formatting remain unchanged.
|
||||
|
||||
---
|
||||
|
||||
### 8. LLM Auto-Tagging: ephemeral competence expansion
|
||||
|
||||
**Decision:** When `use_auto_tagging = true`, the `AutoTagger` issues a single LLM
|
||||
chat-completion call per candidate per scoring invocation to identify which required
|
||||
competences are already covered by the candidate's existing competences (via synonym,
|
||||
abbreviation, or cross-language equivalence). The response is a JSON list of canonical
|
||||
required-competence names to add to the candidate's working list. The expanded list is
|
||||
used only for that BM25 scoring call and is **never persisted**.
|
||||
|
||||
**Rationale:**
|
||||
- Auto-tagging directly addresses BM25's known limitation with lexical disjoint synonyms
|
||||
(e.g., "ML" for "Machine Learning", "Softwarearchitektur" for "Software Architecture").
|
||||
- Making the expansion ephemeral keeps the data model clean and audit-friendly.
|
||||
- Restricting additions to "names already covered by the candidate's existing entries"
|
||||
means the LLM cannot invent skills — it can only surface canonical equivalents.
|
||||
- LLM call cost is bounded: one call per candidate per `compute_competence_similarity()`
|
||||
invocation, with a compact prompt (required list + candidate list).
|
||||
|
||||
**LLM prompt schema:**
|
||||
```
|
||||
System: You are a skill-taxonomy assistant. Given a list of REQUIRED competences and a
|
||||
candidate's EXISTING competences, output ONLY the canonical names from REQUIRED
|
||||
that are already covered by one or more entries in EXISTING (via synonym,
|
||||
abbreviation, or cross-language equivalence). Do not invent new skills.
|
||||
|
||||
User: REQUIRED: ["Machine Learning", "Python", "CI/CD"]
|
||||
EXISTING: ["ML", "Python", "Jenkins", "Continuous Integration"]
|
||||
|
||||
Response (JSON): {"additions": ["Machine Learning", "CI/CD"]}
|
||||
```
|
||||
|
||||
**Azure OpenAI `response_format`:** `{"type": "json_object"}` is used to enforce
|
||||
structured JSON output, eliminating parsing failures.
|
||||
|
||||
**Error handling:** If the LLM call fails (timeout, API error, malformed JSON), the
|
||||
`AutoTagger` returns the original unmodified candidate list. BM25 scoring continues with
|
||||
no expansion — graceful degradation.
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
find_matching_capacities(...) / find_matching_tasks(...)
|
||||
│
|
||||
└─► Matcher.match(capacities, requirements)
|
||||
│
|
||||
├─ [use_bm25_search=True]
|
||||
│ Build ONE Bm25Index over all filtered candidates' competences (global corpus)
|
||||
│ → global_bm25_index (passed into every compute_competence_similarity call)
|
||||
│
|
||||
└─► for each candidate cap in filtered:
|
||||
SimilarityEngine.compute_competence_similarity(
|
||||
required=requirements.competences,
|
||||
candidate=cap.competences,
|
||||
global_index=global_bm25_index,
|
||||
)
|
||||
│
|
||||
├─ [use_bm25_search=False]
|
||||
│ _per_skill_similarity() / _aggregate_similarity()
|
||||
│ (embedding-based, unchanged)
|
||||
│
|
||||
└─ [use_bm25_search=True]
|
||||
│
|
||||
├─ [use_auto_tagging=True]
|
||||
│ AutoTagger.expand_competences(
|
||||
│ required=required,
|
||||
│ existing=candidate,
|
||||
│ )
|
||||
│ → expanded_candidate (ephemeral, not persisted)
|
||||
│
|
||||
└─► _bm25_rrf_similarity(required, expanded_candidate, global_index)
|
||||
│
|
||||
├─► global_index.rank(query=required_comp)
|
||||
│ → filter to candidate_set
|
||||
│ → list[(candidate_text, bm25_score)]
|
||||
│ (falls back to bm25_rank_competences() if no global_index)
|
||||
│
|
||||
└─► reciprocal_rank_fusion(ranked_lists)
|
||||
→ dict[required_comp → rrf_score]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module Structure
|
||||
|
||||
```
|
||||
src/teamlandkarte_mcp/matching/
|
||||
├── auto_tagger.py # AutoTagger class + expand_competences()
|
||||
├── bm25.py # Bm25Index dataclass + bm25_rank_competences()
|
||||
├── rrf.py # reciprocal_rank_fusion()
|
||||
├── matcher.py # unchanged
|
||||
├── scorer.py # unchanged
|
||||
└── similarity.py # extended: _bm25_rrf_similarity(), auto-tag branch, flag routing
|
||||
|
||||
src/teamlandkarte_mcp/azure/
|
||||
└── openai_client.py # extended: chat_completion(), chat_deployment, llm_api_key
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `bm25.py` — Public Interface
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class Bm25Index:
|
||||
"""In-memory BM25 index over a corpus of competence strings.
|
||||
|
||||
Intended to be built once over the union of all candidates' competences
|
||||
(the global corpus) so that IDF values are meaningful. Each per-candidate
|
||||
scoring call queries the global index and filters results to that candidate's
|
||||
own competence set.
|
||||
|
||||
Args:
|
||||
corpus: List of competence strings to index.
|
||||
|
||||
Notes:
|
||||
Tokenization lowercases and splits on non-word characters.
|
||||
An empty corpus produces an index that scores all queries as 0.0.
|
||||
Raw BM25 scores are clamped to ``max(0.0, raw)`` — ubiquitous terms
|
||||
whose IDF is negative simply produce 0.0.
|
||||
"""
|
||||
corpus: list[str]
|
||||
|
||||
def rank(self, query: str) -> list[tuple[str, float]]:
|
||||
"""Rank corpus documents for a single query string.
|
||||
|
||||
Args:
|
||||
query: Required competence text used as the BM25 query.
|
||||
|
||||
Returns:
|
||||
List of (candidate_text, bm25_score) sorted by score descending.
|
||||
Entries with score 0.0 are included (caller decides cutoff).
|
||||
Scores are clamped to max(0.0, raw); negative IDF yields 0.0.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
def bm25_rank_competences(
|
||||
required: str,
|
||||
candidates: list[str],
|
||||
) -> list[tuple[str, float]]:
|
||||
"""Rank candidate competences against a single required competence.
|
||||
|
||||
Convenience wrapper: builds a temporary ``Bm25Index`` and ranks the query.
|
||||
Suitable for standalone use and tests; in production the global index built
|
||||
by ``Matcher`` is preferred (see ``Bm25Index`` notes on IDF correctness).
|
||||
|
||||
Args:
|
||||
required: The required competence text (BM25 query).
|
||||
candidates: Candidate competence strings to rank.
|
||||
|
||||
Returns:
|
||||
Ranked list of (candidate_text, bm25_score), descending by score.
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `rrf.py` — Public Interface
|
||||
|
||||
```python
|
||||
def reciprocal_rank_fusion(
|
||||
ranked_lists: list[list[tuple[str, float]]],
|
||||
*,
|
||||
k: int = 60,
|
||||
) -> dict[str, float]:
|
||||
"""Fuse multiple ranked lists using Reciprocal Rank Fusion.
|
||||
|
||||
For each candidate present in any list, the fused score is:
|
||||
|
||||
score(c) = Σ_i 1 / (k + rank_i(c))
|
||||
|
||||
where rank_i(c) is the 1-based rank of candidate c in list i.
|
||||
|
||||
Candidates with zero BM25 score in ALL contributing lists receive
|
||||
a fused score of 0.0 (no token overlap signal).
|
||||
|
||||
Scores are normalized so the top-ranked candidate receives 1.0.
|
||||
|
||||
Args:
|
||||
ranked_lists: One or more ranked lists of (text, score) pairs.
|
||||
Each list MUST be sorted by score descending.
|
||||
Lists with all-zero scores are skipped (no signal).
|
||||
k: RRF smoothing constant (default 60, Cormack et al. 2009).
|
||||
Higher k flattens rank differences; lower k amplifies them.
|
||||
|
||||
Returns:
|
||||
Mapping of candidate text → normalized fused score in [0.0, 1.0].
|
||||
Returns empty dict if all input lists are empty or all-zero.
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `auto_tagger.py` — Public Interface
|
||||
|
||||
```python
|
||||
class AutoTagger:
|
||||
"""LLM-based competence expander for BM25 pre-processing.
|
||||
|
||||
Calls an Azure OpenAI chat model to identify which required competences
|
||||
are already covered by the candidate's existing competences (via synonym,
|
||||
abbreviation, or cross-language equivalence) and returns the canonical
|
||||
required-competence names as additions.
|
||||
|
||||
All LLM calls use ``response_format={"type": "json_object"}`` to ensure
|
||||
parseable output. On any LLM or parsing error, the original candidate list
|
||||
is returned unchanged (graceful degradation).
|
||||
|
||||
Args:
|
||||
client: An ``AzureOpenAIClient`` instance configured with a chat
|
||||
deployment (``chat_deployment`` and ``llm_api_key``).
|
||||
"""
|
||||
|
||||
def __init__(self, client: AzureOpenAIClient) -> None: ...
|
||||
|
||||
def expand_competences(
|
||||
self,
|
||||
required: list[str],
|
||||
existing: list[str],
|
||||
) -> list[str]:
|
||||
"""Expand a candidate's competence list with covered required terms.
|
||||
|
||||
Sends a prompt to the LLM asking: given ``required`` competences and
|
||||
the candidate's ``existing`` competences, which required competences
|
||||
are already implicitly covered by the existing entries?
|
||||
|
||||
The returned list is ``existing + additions`` where ``additions``
|
||||
are the canonical required-competence names identified by the LLM.
|
||||
|
||||
The result is **ephemeral**: it is used only for the current BM25
|
||||
scoring call and is never written back to the database.
|
||||
|
||||
Args:
|
||||
required: List of required competence names (BM25 query terms).
|
||||
existing: Candidate's current competence list.
|
||||
|
||||
Returns:
|
||||
Combined list ``existing + additions``. If the LLM call fails or
|
||||
returns no additions, returns ``existing`` unchanged.
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### `SimilarityConfig` (updated)
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class SimilarityConfig:
|
||||
"""Similarity engine configuration."""
|
||||
|
||||
embedding_model: str = "text-embedding-3-large"
|
||||
embedding_dimensions: int = 3072
|
||||
strategy: str = "per_skill"
|
||||
use_bm25_search: bool = False
|
||||
"""When True, BM25 + RRF replaces embedding-based competence similarity.
|
||||
Role similarity is always embedding-based.
|
||||
Default: False (preserves existing behavior).
|
||||
"""
|
||||
use_auto_tagging: bool = False
|
||||
"""When True (and use_bm25_search is also True), calls the LLM AutoTagger
|
||||
before each BM25 scoring call to expand the candidate's competence list
|
||||
with canonical equivalents of required competences it already covers.
|
||||
The expansion is ephemeral and never persisted.
|
||||
Default: False.
|
||||
"""
|
||||
```
|
||||
|
||||
### `AzureOpenAIConfig` (updated)
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class AzureOpenAIConfig:
|
||||
"""Azure OpenAI client configuration."""
|
||||
|
||||
# ... existing fields ...
|
||||
chat_deployment: str = ""
|
||||
"""Azure deployment name of the chat model used for auto-tagging
|
||||
(e.g. ``"gpt-4.1"``). Required only when ``use_auto_tagging = true``.
|
||||
"""
|
||||
llm_api_key: str = ""
|
||||
"""Azure OpenAI API key for the chat deployment. Read from the
|
||||
``AZURE_OPENAI_LLM_API_KEY`` environment variable.
|
||||
Required when ``use_auto_tagging = true``; raises ``ConfigurationError``
|
||||
at startup if absent.
|
||||
"""
|
||||
```
|
||||
|
||||
### Config parser (`config.toml` keys)
|
||||
|
||||
```toml
|
||||
[matching.similarity]
|
||||
strategy = "per_skill"
|
||||
use_bm25_search = false # new — enables BM25+RRF competence matching
|
||||
use_auto_tagging = false # new — enables LLM pre-expansion before BM25
|
||||
|
||||
[azure_openai]
|
||||
# ... existing keys ...
|
||||
chat_deployment = "gpt-4.1" # new — required when use_auto_tagging = true
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Required when | Description |
|
||||
|----------|--------------|-------------|
|
||||
| `AZURE_OPENAI_LLM_API_KEY` | `use_auto_tagging = true` | API key for the Azure OpenAI chat deployment used in auto-tagging. Server raises `ConfigurationError` at startup if absent and `use_auto_tagging = true`. |
|
||||
|
||||
---
|
||||
|
||||
## BM25 Score Behavior
|
||||
|
||||
### Example: Query "Python" against candidate competences
|
||||
|
||||
| Candidate | Tokens | BM25 Score | RRF Score (k=60) |
|
||||
|-----------|--------|------------|-----------------|
|
||||
| Python | ["python"] | ~3.5 | 1.000 |
|
||||
| Python (advanced) | ["python", "advanced"] | ~2.1 | 0.984 |
|
||||
| JavaScript | ["javascript"] | 0.0 | **0.000** |
|
||||
| TypeScript | ["typescript"] | 0.0 | **0.000** |
|
||||
| Node.js | ["node", "js"] | 0.0 | **0.000** |
|
||||
|
||||
Candidates without any shared token receive **0.0** — the false-positive problem is
|
||||
eliminated.
|
||||
|
||||
### Example: Query "Machine Learning" against candidate competences
|
||||
|
||||
| Candidate | Tokens | BM25 Score | RRF Score |
|
||||
|-----------|--------|------------|-----------|
|
||||
| Machine Learning | ["machine", "learning"] | ~4.0 | 1.000 |
|
||||
| Deep Learning | ["deep", "learning"] | ~1.8 | 0.984 |
|
||||
| Machine Learning (Python) | ["machine", "learning", "python"] | ~3.2 | 0.492 |
|
||||
| JavaScript | ["javascript"] | 0.0 | **0.000** |
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
### 1. Conceptual synonyms with zero token overlap score 0.0
|
||||
|
||||
BM25 requires at least one shared token between query and document. Skill pairs that are
|
||||
semantically equivalent but lexically disjoint will always score 0.0:
|
||||
|
||||
| Required competence | Candidate competence | Shared tokens | BM25 score |
|
||||
|---------------------|----------------------|---------------|------------|
|
||||
| Data Science | Machine Learning | none | **0.000** |
|
||||
| Data Science | Artificial Intelligence | none | **0.000** |
|
||||
| Frontend Development | UI Engineering | none | **0.000** |
|
||||
| Agile | Scrum | none | **0.000** |
|
||||
| Softwarearchitektur | Software Architecture | none | **0.000** |
|
||||
|
||||
These are **false negatives** — real skill matches that BM25 misses entirely. Embeddings
|
||||
handle all of these correctly via semantic similarity.
|
||||
|
||||
**Consequence:** BM25 mode is best suited to datasets where competence names are
|
||||
**canonical, consistent, and in a single language**. If profiles mix German and English
|
||||
skill names, or use informal abbreviations ("ML" for "Machine Learning"), BM25 will
|
||||
produce false negatives that embeddings would have caught.
|
||||
|
||||
**Mitigation:** Enable `use_auto_tagging = true`. The LLM will bridge synonym and
|
||||
cross-language gaps by pre-expanding the candidate list before BM25 scoring. This
|
||||
addresses limitations 1, 2, and 3 at the cost of one LLM call per candidate per scoring
|
||||
invocation.
|
||||
|
||||
---
|
||||
|
||||
### 2. Abbreviations and acronyms
|
||||
|
||||
Short forms that do not share tokens with the expanded form score 0.0:
|
||||
|
||||
| Required | Candidate | BM25 score |
|
||||
|----------|-----------|------------|
|
||||
| Machine Learning | ML | **0.000** |
|
||||
| Continuous Integration | CI | **0.000** |
|
||||
| Natural Language Processing | NLP | **0.000** |
|
||||
|
||||
Partial exception: "CI/CD" tokenizes to `["ci", "cd"]`, so a query "CI/CD Pipeline"
|
||||
would find partial overlap with a candidate "CI/CD", but not with "Continuous
|
||||
Integration/Continuous Delivery".
|
||||
|
||||
**Mitigation:** `use_auto_tagging = true` (see limitation 1).
|
||||
|
||||
---
|
||||
|
||||
### 3. Cross-language skill names
|
||||
|
||||
A required competence in German and a candidate competence in English (or vice versa)
|
||||
score 0.0 even when they describe the same skill:
|
||||
|
||||
| Required | Candidate | BM25 score |
|
||||
|----------|-----------|------------|
|
||||
| Softwarearchitektur | Software Architecture | **0.000** |
|
||||
| Maschinelles Lernen | Machine Learning | **0.000** |
|
||||
| Datenwissenschaft | Data Science | **0.000** |
|
||||
|
||||
This is a significant limitation for German-language skill databases. Embeddings handle
|
||||
cross-language pairs well; BM25 does not.
|
||||
|
||||
**Mitigation:** `use_auto_tagging = true` (see limitation 1).
|
||||
|
||||
---
|
||||
|
||||
### 4. RRF score compression near 1.0
|
||||
|
||||
Because the normalization formula is `(k+1) / (k+rank)` with `k=60`, ranks 1–5 all
|
||||
compress into the range `[0.871, 1.0]`. There is little score differentiation between
|
||||
the top-ranked candidates. This is intentional (rank differences matter less than
|
||||
presence/absence of token overlap) but means BM25 mode should not be used to produce
|
||||
fine-grained competence similarity scores — only to separate matching (score > 0) from
|
||||
non-matching (score = 0) candidates.
|
||||
|
||||
---
|
||||
|
||||
### 5. Small per-person corpus causes IDF pathology — mitigated by global index
|
||||
|
||||
BM25's IDF term rewards tokens that are rare across the corpus. When the BM25 index was
|
||||
built per candidate (N = that person's competence count ≈ 5–30), any term appearing in
|
||||
most documents had IDF ≤ 0, causing `rank_bm25` to return negative raw scores. After
|
||||
`max(0.0, raw)` clamping, all candidates scored 0.0 — making the BM25 signal useless.
|
||||
|
||||
**Fix (implemented):** The `Bm25Index` is now built once over the **union of all filtered
|
||||
candidates' competences** in `Matcher.match()` before the per-candidate loop (see Key
|
||||
Decision #2). This makes N equal to the total unique competence count across all
|
||||
candidates, giving IDF proper signal. Per-candidate scoring filters the global results to
|
||||
only that candidate's competence set.
|
||||
|
||||
**Residual limitation:** If the filtered candidate pool itself is very small (e.g., 2–3
|
||||
candidates with few unique competences), IDF may still be weak. In practice this is
|
||||
negligible since matching runs are always invoked against a meaningful pool of candidates.
|
||||
|
||||
---
|
||||
|
||||
## Test Strategy
|
||||
|
||||
### Unit tests
|
||||
|
||||
1. **`tests/test_bm25.py`**
|
||||
- `Bm25Index` with empty corpus → all scores 0.0
|
||||
- Exact match → score > 0
|
||||
- No token overlap → score == 0.0
|
||||
- Partial token overlap (multi-token queries) → partial score
|
||||
- Case-insensitive matching ("Python" == "python")
|
||||
- Tokenization edge cases: hyphens, parentheses, slashes
|
||||
|
||||
2. **`tests/test_rrf.py`**
|
||||
- Single list, single candidate → score 1.0
|
||||
- Single list, multiple candidates → scores normalized to [0, 1]
|
||||
- All-zero list → returns empty / all zeros
|
||||
- `k` parameter effect on score distribution
|
||||
|
||||
3. **`tests/test_auto_tagger.py`** (new)
|
||||
- LLM returns valid additions → `expand_competences()` appends them to existing list
|
||||
- LLM returns empty additions → original list returned unchanged
|
||||
- LLM call raises exception → original list returned unchanged (graceful degradation)
|
||||
- LLM returns malformed JSON → original list returned unchanged
|
||||
- Additions from LLM that are NOT in `required` are ignored
|
||||
- `expand_competences()` never duplicates entries already in `existing`
|
||||
- All tests use a mocked `AzureOpenAIClient` (no real LLM calls)
|
||||
|
||||
4. **`tests/test_similarity.py`** (extended)
|
||||
- `compute_competence_similarity()` with `use_bm25_search=True`
|
||||
- Required "Python", candidate ["JavaScript"] → score 0.0
|
||||
- Required "Python", candidate ["Python"] → score 1.0
|
||||
- Required "Python" + "Machine Learning", candidate ["JavaScript", "TypeScript"]
|
||||
→ both scores 0.0, overall competence_score 0.0
|
||||
- With `use_bm25_search=True, use_auto_tagging=True`: mocked `AutoTagger` injects
|
||||
"Machine Learning" into candidate ["ML"] → score > 0
|
||||
|
||||
### Integration test
|
||||
|
||||
5. **`tests/test_matcher_integration.py`** (extended)
|
||||
- With `use_bm25_search=True`: candidate with exact skills scores higher than
|
||||
candidate with only semantically adjacent skills.
|
||||
- The false-positive scenario (Python/ML vs JavaScript/TypeScript) is explicitly
|
||||
tested.
|
||||
- With `use_bm25_search=True, use_auto_tagging=True` (mocked LLM): candidate with
|
||||
"ML" matches required "Machine Learning" after auto-tag expansion.
|
||||
Reference in New Issue
Block a user