ddb658ffbb
Step one of letting Maven read instead of recall. No LLM yet. internal/kiwix/client.go: search a local Kiwix server, parse the RSS reply, hand back title + path + plain-text snippet + word count. The snippet is the unit of context; a full article is ~100KB of HTML and will not fit a 4096 token window. internal/kiwix/retrieval_eval.go plus knowledge_v1.json: the 9 knowledge questions from the phrasing fixture, each with hand-written English keywords, scored on whether a wanted article comes back in the top 5. Opt-in via MAVEN_KIWIX_URL, since CI has no Kiwix. No pass bar, the number is the finding. Result on the live mirror: 8/8 answerable questions hit, 7 of them at rank 1. Retrieval works. Keywords are written by hand on purpose, since Kiwix ranks by keyword and not by meaning, so a natural question fails. A query-rewrite step is the next piece of work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
136 lines
3.4 KiB
Go
136 lines
3.4 KiB
Go
package kiwix
|
|
|
|
// This scores retrieval alone: no LLM. For each general-knowledge question we
|
|
// hand-write English keywords and ask whether the article that would answer it
|
|
// comes back in the top N hits. If this score is low, reading Wikipedia cannot
|
|
// help the model no matter how good the prompt is.
|
|
//
|
|
// The unanswerable case (know-dont-know) is not scored. Whether the junk it
|
|
// returns is "nothing useful" is a human judgement, so the report just prints
|
|
// the titles and leaves the score to the 8 answerable cases.
|
|
|
|
import (
|
|
"context"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
//go:embed knowledge_v1.json
|
|
var knowledgeFixtureJSON []byte
|
|
|
|
// EvalCase — one question with hand-written keywords.
|
|
type EvalCase struct {
|
|
ID string `json:"id"`
|
|
Question string `json:"question"`
|
|
Query string `json:"query"`
|
|
WantTitles []string `json:"want_titles"`
|
|
ExpectMiss bool `json:"expect_miss"`
|
|
}
|
|
|
|
type fixture struct {
|
|
Name string `json:"name"`
|
|
Book string `json:"book"`
|
|
Cases []EvalCase `json:"cases"`
|
|
}
|
|
|
|
// Outcome — what one case retrieved.
|
|
type Outcome struct {
|
|
Case EvalCase
|
|
Titles []string // titles of the top N hits, in rank order
|
|
Rank int // 1-based rank of the first wanted title, 0 if none
|
|
Err error
|
|
}
|
|
|
|
// Hit is true when a wanted title came back.
|
|
func (o Outcome) Hit() bool { return o.Rank > 0 }
|
|
|
|
// Report — the score plus per-case detail.
|
|
type Report struct {
|
|
Name string
|
|
Book string
|
|
TopN int
|
|
Scored int // answerable cases
|
|
Hits int
|
|
Errors int
|
|
Outcomes []Outcome
|
|
}
|
|
|
|
// Accuracy over the answerable cases.
|
|
func (r Report) Accuracy() float64 {
|
|
if r.Scored == 0 {
|
|
return 0
|
|
}
|
|
return float64(r.Hits) / float64(r.Scored)
|
|
}
|
|
|
|
// RunRetrievalEval searches for every fixture case.
|
|
func RunRetrievalEval(ctx context.Context, c *Client, topN int) (Report, error) {
|
|
var f fixture
|
|
if err := json.Unmarshal(knowledgeFixtureJSON, &f); err != nil {
|
|
return Report{}, err
|
|
}
|
|
rep := Report{Name: f.Name, Book: f.Book, TopN: topN}
|
|
for _, cs := range f.Cases {
|
|
res, err := c.Search(ctx, cs.Query, f.Book, topN)
|
|
o := Outcome{Case: cs, Err: err}
|
|
if err != nil {
|
|
rep.Errors++
|
|
}
|
|
for i, hit := range res {
|
|
o.Titles = append(o.Titles, hit.Title)
|
|
if o.Rank == 0 && matches(cs.WantTitles, hit.Title) {
|
|
o.Rank = i + 1
|
|
}
|
|
}
|
|
if !cs.ExpectMiss {
|
|
rep.Scored++
|
|
if o.Hit() {
|
|
rep.Hits++
|
|
}
|
|
}
|
|
rep.Outcomes = append(rep.Outcomes, o)
|
|
}
|
|
return rep, nil
|
|
}
|
|
|
|
func matches(want []string, title string) bool {
|
|
for _, w := range want {
|
|
if strings.EqualFold(strings.TrimSpace(title), w) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// String — the headline number.
|
|
func (r Report) String() string {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "%s: %d/%d answerable questions retrieve a wanted article in top %d (%.1f%%), %d errors\n",
|
|
r.Name, r.Hits, r.Scored, r.TopN, 100*r.Accuracy(), r.Errors)
|
|
fmt.Fprintf(&b, " book: %s\n", r.Book)
|
|
return b.String()
|
|
}
|
|
|
|
// Detail — per case: what was asked, what was searched, what came back.
|
|
func (r Report) Detail() string {
|
|
var b strings.Builder
|
|
for _, o := range r.Outcomes {
|
|
mark := "MISS"
|
|
switch {
|
|
case o.Case.ExpectMiss:
|
|
mark = "n/a "
|
|
case o.Hit():
|
|
mark = fmt.Sprintf("hit@%d", o.Rank)
|
|
}
|
|
fmt.Fprintf(&b, " %-6s %-20s q=%q\n", mark, o.Case.ID, o.Case.Query)
|
|
if o.Err != nil {
|
|
fmt.Fprintf(&b, " error: %v\n", o.Err)
|
|
continue
|
|
}
|
|
fmt.Fprintf(&b, " got: %s\n", strings.Join(o.Titles, " | "))
|
|
}
|
|
return b.String()
|
|
}
|