18ae28f918
Chemenu kompiliert Rohnotizen zu einem verlinkten, quellengebundenen Wiki: raw/ -> types/ + tools/ -> kb/ -> reports/. Was mechanisch ist, macht tools/wikitool; was Urteil braucht, macht ein Agent unter Contracts, deren Grenzen in Code durchgesetzt sind statt im Prompt. Dieser Commit ist der Startpunkt der oeffentlichen Historie. Die vorherige Entwicklung fand in einer privaten Instanz statt und ist nicht Teil dieses Repositorys; ihre Erzaehlung steht vollstaendig in CHANGES.md, das mit 44 Eintraegen von 0.1.0 bis 2.1.0 erhalten geblieben ist. Der mitgelieferte Korpus ist ein Testbett und eine Demo: 170 Seiten ueber den Stack selbst - Gates, Lint, Versionierung, Suche, das Wiki-Muster. Er dokumentiert das Werkzeug mit den eigenen Mitteln des Werkzeugs. Lizenz: AGPL-3.0 fuer den Stack (tools/, types/), CC-BY-4.0 fuer die Inhalte. Die Grenze zwischen beiden ist der Dateiplan, den dist export berechnet - siehe NOTICE.
42 lines
1.6 KiB
Python
42 lines
1.6 KiB
Python
"""Reciprocal Rank Fusion: merge several backends' rankings into one.
|
|
|
|
RRF is here before a second backend exists because it is what makes adding one
|
|
cheap. It needs no per-backend score calibration - only each hit's *rank* -
|
|
which is the property that lets a lexical and a semantic backend, whose scores
|
|
are not on any common scale, be combined without tuning weights.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from chemenu.search.types import SearchHit
|
|
|
|
# Cormack et al.'s default. Large enough that the difference between rank 1 and
|
|
# rank 2 does not swamp agreement between backends further down the list.
|
|
RRF_K = 60
|
|
|
|
|
|
def reciprocal_rank_fusion(rankings: list[list[SearchHit]], k: int = RRF_K) -> list[SearchHit]:
|
|
"""Fuse ranked hit lists. A page found by two backends outranks a page
|
|
found by one, even if neither ranked it first."""
|
|
scored: dict[str, SearchHit] = {}
|
|
totals: dict[str, float] = {}
|
|
backends: dict[str, list[str]] = {}
|
|
|
|
for ranking in rankings:
|
|
for rank, hit in enumerate(ranking, start=1):
|
|
totals[hit.path] = totals.get(hit.path, 0.0) + 1.0 / (k + rank)
|
|
backends.setdefault(hit.path, []).append(hit.backend)
|
|
existing = scored.get(hit.path)
|
|
if existing is None:
|
|
scored[hit.path] = hit
|
|
elif hit.matches and not existing.matches:
|
|
scored[hit.path] = hit
|
|
|
|
fused: list[SearchHit] = []
|
|
for path, hit in scored.items():
|
|
hit.score = totals[path]
|
|
hit.backend = "+".join(dict.fromkeys(backends[path]))
|
|
fused.append(hit)
|
|
|
|
fused.sort(key=lambda h: (-h.score, h.title.lower()))
|
|
return fused
|