mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Mémoire : recherche par couverture multi-mots et rareté des termes
Un mot fréquent noyait les bonnes pages : la recherche ne comptait que les occurrences. Elle pondère maintenant chaque terme par sa rareté dans la mémoire et favorise les pages qui couvrent plusieurs mots de la requête. Repris de l'amont AJEAN v0.11.7, avec ses tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7fwdmmVbvLHznF9v1npzN
This commit is contained in:
1 parent
dfdee26e4c
commit
3edd356f34
2 files changed
+135
-14
No files matched your search
@@ -3,6 +3,7 @@ package loki
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -98,36 +99,93 @@ func MemList() []MemPage {
|
||||
// MemSearch cherche les termes de la requête dans le nom + le contenu de chaque
|
||||
// page, renvoie une liste classée {fichier, titre, extrait}. Comme un moteur de
|
||||
// recherche : à compléter par mem_read sur la page la plus pertinente.
|
||||
//
|
||||
// Classement en deux temps, pour ne PAS laisser une grosse page bourrée d'un mot
|
||||
// fréquent écraser une petite page qui matche VRAIMENT la requête :
|
||||
// 1. Couverture d'abord : les pages qui contiennent le PLUS de termes distincts
|
||||
// de la requête passent devant (chercher « copine Nathan » doit remonter la
|
||||
// page qui a les deux mots, pas celle qui répète « Nathan » 40 fois).
|
||||
// 2. À couverture égale, score pondéré par la RARETÉ du terme (IDF) : un mot
|
||||
// rare et discriminant (« copine ») pèse bien plus qu'un mot omniprésent
|
||||
// (« Nathan »). La fréquence est amortie (log) pour que le bourrage ne gagne
|
||||
// pas, et un match dans le NOM ou le TITRE est fortement bonifié.
|
||||
func MemSearch(query string, limit int) []MemHit {
|
||||
if limit <= 0 || limit > 30 {
|
||||
limit = 8
|
||||
}
|
||||
terms := strings.Fields(strings.ToLower(query))
|
||||
type scored struct {
|
||||
hit MemHit
|
||||
score int
|
||||
terms := uniqueTerms(strings.ToLower(query))
|
||||
|
||||
type doc struct {
|
||||
p MemPage
|
||||
name string // minuscules
|
||||
title string // minuscules
|
||||
content string // brut (pour l'extrait)
|
||||
hay string // minuscules : nom + contenu
|
||||
}
|
||||
var ranked []scored
|
||||
docs := make([]doc, 0)
|
||||
df := make(map[string]int) // nb de pages contenant chaque terme
|
||||
for _, p := range MemList() {
|
||||
b, _ := os.ReadFile(filepath.Join(memoryDir(), p.Name))
|
||||
content := string(b)
|
||||
hay := strings.ToLower(p.Name + "\n" + content)
|
||||
score := 0
|
||||
d := doc{
|
||||
p: p,
|
||||
name: strings.ToLower(p.Name),
|
||||
title: strings.ToLower(p.Title),
|
||||
content: content,
|
||||
hay: strings.ToLower(p.Name + "\n" + content),
|
||||
}
|
||||
docs = append(docs, d)
|
||||
for _, t := range terms {
|
||||
score += strings.Count(hay, t)
|
||||
if strings.Contains(strings.ToLower(p.Name), t) {
|
||||
score += 3 // bonus si le terme est dans le nom de la page
|
||||
if strings.Contains(d.hay, t) {
|
||||
df[t]++
|
||||
}
|
||||
}
|
||||
if score == 0 && query != "" {
|
||||
}
|
||||
n := len(docs)
|
||||
|
||||
type scored struct {
|
||||
hit MemHit
|
||||
matched int // termes distincts trouvés (clé de tri primaire)
|
||||
score float64 // pertinence pondérée (clé secondaire)
|
||||
}
|
||||
var ranked []scored
|
||||
for _, d := range docs {
|
||||
matched := 0
|
||||
score := 0.0
|
||||
for _, t := range terms {
|
||||
cnt := strings.Count(d.hay, t)
|
||||
if cnt == 0 {
|
||||
continue
|
||||
}
|
||||
matched++
|
||||
// IDF : rare = discriminant. +1 pour qu'un terme fréquent compte encore.
|
||||
idf := math.Log(float64(n+1)/float64(df[t]+1)) + 1
|
||||
// Fréquence amortie : 10 occurrences ne valent pas 10x une occurrence.
|
||||
tf := 1.0 + math.Log(float64(cnt))
|
||||
field := 1.0
|
||||
if strings.Contains(d.name, t) {
|
||||
field += 4 // le terme est dans le NOM de la page
|
||||
}
|
||||
if strings.Contains(d.title, t) {
|
||||
field += 2 // ... ou dans son TITRE
|
||||
}
|
||||
score += idf * tf * field
|
||||
}
|
||||
if matched == 0 && len(terms) > 0 {
|
||||
continue
|
||||
}
|
||||
ranked = append(ranked, scored{
|
||||
hit: MemHit{File: p.Name, Title: p.Title, Snippet: snippetAround(content, terms)},
|
||||
score: score,
|
||||
hit: MemHit{File: d.p.Name, Title: d.p.Title, Snippet: snippetAround(d.content, terms)},
|
||||
matched: matched,
|
||||
score: score,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(ranked, func(i, j int) bool { return ranked[i].score > ranked[j].score })
|
||||
sort.SliceStable(ranked, func(i, j int) bool {
|
||||
if ranked[i].matched != ranked[j].matched {
|
||||
return ranked[i].matched > ranked[j].matched // couverture d'abord
|
||||
}
|
||||
return ranked[i].score > ranked[j].score
|
||||
})
|
||||
out := []MemHit{}
|
||||
for i, r := range ranked {
|
||||
if i >= limit {
|
||||
@@ -138,6 +196,19 @@ func MemSearch(query string, limit int) []MemHit {
|
||||
return out
|
||||
}
|
||||
|
||||
// uniqueTerms découpe une requête en mots distincts (dédupliqués, ordre gardé).
|
||||
func uniqueTerms(q string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, t := range strings.Fields(q) {
|
||||
if !seen[t] {
|
||||
seen[t] = true
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// snippetAround renvoie un court extrait autour de la 1re occurrence d'un terme.
|
||||
func snippetAround(content string, terms []string) string {
|
||||
flat := strings.Join(strings.Fields(content), " ")
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Reproduit le bug rapporté : une petite page dont le NOM et le TITRE contiennent
|
||||
// « copine » ne remontait pas pour la requête « copine Nathan », noyée par de
|
||||
// grosses pages qui répètent « Nathan » des dizaines de fois. Le classement doit
|
||||
// mettre en tête la page qui matche le PLUS de termes distincts (couverture),
|
||||
// puis pondérer par la rareté.
|
||||
func TestMemSearchCoverageBeatsFrequency(t *testing.T) {
|
||||
testHome(t)
|
||||
|
||||
// Deux pages « bruyantes » bourrées de « Nathan », sans « copine ».
|
||||
if err := MemAdd("business-nathan.md", "# Business Nathan\n"+strings.Repeat("Nathan veut un business. ", 40)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := MemAdd("guide-ajean.md", "# Guide AJEAN\n"+strings.Repeat("projet de Nathan. ", 30)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// La petite page cible : matche les DEUX termes de la requête.
|
||||
if err := MemAdd("profil-copine-nathan.md", "# Profil : Copine de Nathan\n- Prénom : Bao\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hits := MemSearch("copine Nathan", 8)
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("aucun résultat pour « copine Nathan »")
|
||||
}
|
||||
if hits[0].File != "profil-copine-nathan.md" {
|
||||
var names []string
|
||||
for _, h := range hits {
|
||||
names = append(names, h.File)
|
||||
}
|
||||
t.Fatalf("la page copine devrait être 1re (matche les 2 termes), obtenu ordre : %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Une requête mono-terme rare doit privilégier un match dans le nom/titre.
|
||||
func TestMemSearchNameTitleBoost(t *testing.T) {
|
||||
testHome(t)
|
||||
_ = MemAdd("notes-diverses.md", "# Notes\nUn jour Bao est passée dire bonjour, puis repartie.\n")
|
||||
_ = MemAdd("profil-copine.md", "# Profil : Copine\n- Prénom : Bao\n")
|
||||
hits := MemSearch("copine", 8)
|
||||
if len(hits) == 0 || hits[0].File != "profil-copine.md" {
|
||||
t.Fatalf("la page dont le nom/titre porte « copine » devrait être 1re, obtenu : %+v", hits)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user