feat(wing): BM25 scorer as the independent cross-check (#205 S1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
package wing
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||
)
|
||||
|
||||
const (
|
||||
maxQueryTerms = 12
|
||||
minTermLen = 5
|
||||
hitsPerWing = 3
|
||||
)
|
||||
|
||||
// BM25Scorer scores a note against each wing by running the brain's own BM25
|
||||
// search scoped to that wing. It is deliberately a different mechanism from
|
||||
// the LLM, so agreement between the two is evidence and not an echo.
|
||||
type BM25Scorer struct {
|
||||
brainDir string
|
||||
wings []string
|
||||
exclude string
|
||||
}
|
||||
|
||||
// NewBM25Scorer scores against wings under brainDir. exclude is a brain-
|
||||
// relative path ignored in results (the backtest sample itself); "" for none.
|
||||
func NewBM25Scorer(brainDir string, wings []string, exclude string) *BM25Scorer {
|
||||
return &BM25Scorer{brainDir: brainDir, wings: wings, exclude: exclude}
|
||||
}
|
||||
|
||||
// Score implements Scorer. A wing whose search fails scores 0 rather than
|
||||
// failing the note: the Resolver treats "no clear opinion" as an Exception.
|
||||
func (b *BM25Scorer) Score(text string) map[string]float64 {
|
||||
query := strings.Join(topTerms(text), " ")
|
||||
out := map[string]float64{}
|
||||
if query == "" {
|
||||
return out
|
||||
}
|
||||
for _, w := range b.wings {
|
||||
hits, err := search.Query(b.brainDir, search.QueryOptions{Query: query, Wing: w, Limit: hitsPerWing + 1})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var sum float64
|
||||
n := 0
|
||||
for _, h := range hits {
|
||||
if b.exclude != "" && h.Path == b.exclude {
|
||||
continue
|
||||
}
|
||||
if n == hitsPerWing {
|
||||
break
|
||||
}
|
||||
sum += float64(h.Score)
|
||||
n++
|
||||
}
|
||||
if sum > 0 {
|
||||
out[w] = sum
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// topTerms picks the most frequent longer words, ties broken alphabetically
|
||||
// so the query is deterministic.
|
||||
func topTerms(text string) []string {
|
||||
if len(text) > maxNoteChars {
|
||||
text = text[:maxNoteChars]
|
||||
}
|
||||
counts := map[string]int{}
|
||||
for _, f := range strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
}) {
|
||||
if len([]rune(f)) >= minTermLen {
|
||||
counts[f]++
|
||||
}
|
||||
}
|
||||
terms := make([]string, 0, len(counts))
|
||||
for t := range counts {
|
||||
terms = append(terms, t)
|
||||
}
|
||||
sort.Slice(terms, func(i, j int) bool {
|
||||
if counts[terms[i]] != counts[terms[j]] {
|
||||
return counts[terms[i]] > counts[terms[j]]
|
||||
}
|
||||
return terms[i] < terms[j]
|
||||
})
|
||||
if len(terms) > maxQueryTerms {
|
||||
terms = terms[:maxQueryTerms]
|
||||
}
|
||||
return terms
|
||||
}
|
||||
Reference in New Issue
Block a user