feat(search): M4 tier-weighted BM25 re-rank (infra#72)
The eval set under brain/eval/qa-2026-05.md showed BM25 top-1 at 20% with 5 of the missing slugs being short focused knowledge entries that lost to long aggregate docs on raw term-frequency. Tier weighting addresses that without touching the BM25 algorithm itself. How - Result struct gains a Tier field, populated during the file walk via extractTier (frontmatter wins, path prefix as fallback — mirrors the graph.inferTierFromPath logic so the two callers stay in lockstep). - After the existing sort (and optional hybridMerge), do a final stable re-sort by float64(Score) * tierWeight(Tier). Knowledge ×1.5, note ×1.0, inbox ×0.3, unknown ×1.0. - hydrate() (vector-only hits) also fills Tier so re-ranking covers the hybrid path. Test covers the load-bearing case: a long note-tier doc with raw=10 loses to a short knowledge-tier doc with raw=8 after weighting (8×1.5=12 vs 10×1.0=10). Measurement gate is in infra#72: re-run brain/eval/score.py against the live brain after this image lands; close the issue when top-1 hit rate lifts by ≥10 absolute points.
This commit is contained in:
@@ -43,6 +43,30 @@ type Result struct {
|
|||||||
Score int `json:"score"`
|
Score int `json:"score"`
|
||||||
Wing string `json:"wing,omitempty"`
|
Wing string `json:"wing,omitempty"`
|
||||||
Hall string `json:"hall,omitempty"`
|
Hall string `json:"hall,omitempty"`
|
||||||
|
// Tier is the DIKW classification used for retrieval weighting
|
||||||
|
// (infra#72). Read from frontmatter when present, otherwise
|
||||||
|
// inferred from the parent directory.
|
||||||
|
Tier string `json:"tier,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tierWeight maps the DIKW tier to a score multiplier applied right
|
||||||
|
// before the final truncation. Knowledge entries (focused lessons that
|
||||||
|
// age well) get boosted; inbox entries (raw captures, sessions, clips)
|
||||||
|
// get demoted. Empty / unknown tiers keep the original BM25 score
|
||||||
|
// (multiplier 1.0). See infra#72 for the failure mode this addresses:
|
||||||
|
// short focused entries lose to long aggregate dump-files under
|
||||||
|
// raw BM25 ranking.
|
||||||
|
func tierWeight(tier string) float64 {
|
||||||
|
switch tier {
|
||||||
|
case "knowledge":
|
||||||
|
return 1.5
|
||||||
|
case "note":
|
||||||
|
return 1.0
|
||||||
|
case "inbox":
|
||||||
|
return 0.3
|
||||||
|
default:
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryOptions configures a search.
|
// QueryOptions configures a search.
|
||||||
@@ -120,6 +144,7 @@ func QueryContext(ctx context.Context, brainDir string, opts QueryOptions) ([]Re
|
|||||||
}
|
}
|
||||||
rel = filepath.ToSlash(rel)
|
rel = filepath.ToSlash(rel)
|
||||||
wing, hall := extractWingHall(string(content), rel)
|
wing, hall := extractWingHall(string(content), rel)
|
||||||
|
tier := extractTier(string(content), rel)
|
||||||
results = append(results, Result{
|
results = append(results, Result{
|
||||||
Path: rel,
|
Path: rel,
|
||||||
Title: extractTitle(string(content), d.Name()),
|
Title: extractTitle(string(content), d.Name()),
|
||||||
@@ -127,6 +152,7 @@ func QueryContext(ctx context.Context, brainDir string, opts QueryOptions) ([]Re
|
|||||||
Score: score,
|
Score: score,
|
||||||
Wing: wing,
|
Wing: wing,
|
||||||
Hall: hall,
|
Hall: hall,
|
||||||
|
Tier: tier,
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -150,6 +176,15 @@ func QueryContext(ctx context.Context, brainDir string, opts QueryOptions) ([]Re
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tier-weighted final re-rank (infra#72). Knowledge tier entries
|
||||||
|
// boost ×1.5, inbox demote ×0.3, note stays at ×1.0. Applied after
|
||||||
|
// hybridMerge so RRF ranking still drives candidate generation;
|
||||||
|
// the tier weight only re-orders the merged set.
|
||||||
|
sort.SliceStable(results, func(i, j int) bool {
|
||||||
|
return float64(results[i].Score)*tierWeight(results[i].Tier) >
|
||||||
|
float64(results[j].Score)*tierWeight(results[j].Tier)
|
||||||
|
})
|
||||||
|
|
||||||
if len(results) > opts.Limit {
|
if len(results) > opts.Limit {
|
||||||
results = results[:opts.Limit]
|
results = results[:opts.Limit]
|
||||||
}
|
}
|
||||||
@@ -235,12 +270,14 @@ func hydrate(brainDir, relPath string) (Result, error) {
|
|||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
wing, hall := extractWingHall(string(content), relPath)
|
wing, hall := extractWingHall(string(content), relPath)
|
||||||
|
tier := extractTier(string(content), relPath)
|
||||||
return Result{
|
return Result{
|
||||||
Path: relPath,
|
Path: relPath,
|
||||||
Title: extractTitle(string(content), filepath.Base(relPath)),
|
Title: extractTitle(string(content), filepath.Base(relPath)),
|
||||||
Excerpt: excerpt(string(content), 300),
|
Excerpt: excerpt(string(content), 300),
|
||||||
Wing: wing,
|
Wing: wing,
|
||||||
Hall: hall,
|
Hall: hall,
|
||||||
|
Tier: tier,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +306,50 @@ func resolveRoots(brainDir, wing, hall string) ([]string, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractTier reads the DIKW tier from frontmatter first, falling back
|
||||||
|
// to the path prefix mapping (infra#72). Mirrors graph.inferTierFromPath
|
||||||
|
// so the two callers stay in lockstep — frontmatter is canonical,
|
||||||
|
// path inference is the migration-window fallback.
|
||||||
|
func extractTier(content, relPath string) string {
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||||
|
inFrontmatter := false
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.TrimSpace(line) == "---" {
|
||||||
|
if !inFrontmatter {
|
||||||
|
inFrontmatter = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !inFrontmatter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, val, ok := strings.Cut(line, ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(key) == "tier" {
|
||||||
|
return strings.Trim(strings.TrimSpace(val), `"'`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts := strings.Split(relPath, "/")
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch parts[0] {
|
||||||
|
case "inbox", "raw", "sessions", "clips":
|
||||||
|
return "inbox"
|
||||||
|
case "notes":
|
||||||
|
return "note"
|
||||||
|
case "wiki":
|
||||||
|
return "note"
|
||||||
|
case "knowledge":
|
||||||
|
return "knowledge"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// extractWingHall reads wing/hall from frontmatter first, falling back to
|
// extractWingHall reads wing/hall from frontmatter first, falling back to
|
||||||
// path segments brain/wiki/<wing>/<hall>/.
|
// path segments brain/wiki/<wing>/<hall>/.
|
||||||
func extractWingHall(content, relPath string) (wing, hall string) {
|
func extractWingHall(content, relPath string) (wing, hall string) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
@@ -130,6 +131,29 @@ func TestSearch_ReturnsMatchingPages(t *testing.T) {
|
|||||||
assert.Contains(t, results[0].Excerpt, "Retry")
|
assert.Contains(t, results[0].Excerpt, "Retry")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSearch_TierWeightingReordersResults(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// A long note-tier dump mentions the keyword many times (high raw
|
||||||
|
// BM25 score); a short knowledge entry mentions it three times.
|
||||||
|
// Raw BM25 prefers the dump; tier weighting (knowledge ×1.5 vs
|
||||||
|
// note ×1.0) flips the order if the score gap is within reach.
|
||||||
|
// note raw = 5 × 2 terms = 10 hits, weight 1.0 → 10
|
||||||
|
// knowledge raw = 4 × 2 terms = 8 hits, weight 1.5 → 12 (overtakes)
|
||||||
|
noteBody := "---\ntier: note\n---\n" + strings.Repeat("scram trap. ", 5)
|
||||||
|
knowledgeBody := "---\ntier: knowledge\n---\n" + strings.Repeat("scram trap. ", 4)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki", "sources"), 0o755))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "knowledge"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki", "sources", "dump.md"), []byte(noteBody), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "knowledge", "trap.md"), []byte(knowledgeBody), 0o644))
|
||||||
|
|
||||||
|
results, err := search.Query(dir, search.QueryOptions{Query: "scram trap", Limit: 5})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.GreaterOrEqual(t, len(results), 2)
|
||||||
|
assert.Equal(t, "knowledge/trap.md", results[0].Path, "knowledge tier weight should beat note tier")
|
||||||
|
assert.Equal(t, "knowledge", results[0].Tier)
|
||||||
|
assert.Equal(t, "note", results[1].Tier)
|
||||||
|
}
|
||||||
|
|
||||||
func TestSearch_WingHallScoping(t *testing.T) {
|
func TestSearch_WingHallScoping(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
for _, p := range []struct{ rel, body string }{
|
for _, p := range []struct{ rel, body string }{
|
||||||
|
|||||||
Reference in New Issue
Block a user