fix(search): et ord inde i et ord kunne ikke findes
Release / Build and publish image (push) Failing after 9m57s

Meldt ind som "søgning har ikke det store held": en note med
`gamerule keepInventory true` kunne ikke findes ved at skrive "keep inventory".

Et indeks er bygget af tokens, og et token er det, en tokenizer besluttede var et
ord. `keepInventory` er ét. Så "keep inventory" spørger efter to ord, der ikke
findes nogen steder, og søgningen svarer, at der ikke er noget — mens noten står
lige der.

Præfikssøgningen tager halvdelen af det. `keep*` rammer faktisk
`keepinventory`; `inventory*` kan ikke, for det er ikke et præfiks af noget. Og
de to led er bundet sammen med AND, så den halvdel, der virker, bliver kasseret af
den, der ikke kan.

Det er ikke et sært tilfælde i en notesapp. Ti års noter er fulde af kommandoer,
identifikatorer og filnavne, og hver eneste af dem er flere ord, indekset tror er
ét: `keepInventory`, `VACUUM INTO`, `apple-notes-til-markdown`.

Indekset svarer først. Har det ingenting, læses teksten: hvert ord skal stå et
sted i noten, hvor som helst, i hvilket som helst ord. `fold` er
`title || ' ' || body` med de danske bogstaver translittereret, så én søjle dækker
titlen, brødteksten og "gron" for "grøn" på én gang.

Opgavesøgningen har fået den samme arm. At have den i den ene af de to ville være
værre end at have den i ingen af dem: "den finder det i mine noter, men ikke i
mine opgaver" er den næste melding, og svaret på den ville være denne linje.

Hvad det koster, og hvornår formen er forkert, står skrevet ved koden: en LIKE med
et indledende % kan ikke bruge et indeks, så den læser hver note. Ved tolv hundrede
noter er det fire megabyte og et par millisekunder, og den kører kun, når den
hurtige vej allerede kom tomhændet tilbage — altså netop den søgning, der var ved
at skuffe nogen. Ved hundrede tusind er svaret et FTS5-`trigram`-indeks, ikke en
større scanning.

Prøverne er set fejle uden rettelsen, og de dækker begge veje: "inventory" alene,
som ingen præfikssøgning kan nå, og "Bedrock", som stadig skal besvares af
indekset — ellers er rangeringen, der blev bygget til tolv hundrede noter, stille
forsvundet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-20 18:33:22 +02:00
co-authored by Claude Opus 5
parent 9d34b57166
commit be581eb8ae
5 changed files with 208 additions and 2 deletions
+64
View File
@@ -369,3 +369,67 @@ func TestSearchAnswersWithTheBestMatchFirst(t *testing.T) {
t.Errorf("øverst stod %q; den note, der hedder det, man søgte efter, skal stå først", found[0].Title)
}
}
// A word inside a word.
//
// Reported as "søgning har ikke det store held": a note holding
// `gamerule keepInventory true` could not be found by typing "keep inventory".
// The index is built of tokens, and `keepInventory` is one token — so the two
// words are nowhere, and the search says there is nothing while the note is right
// there.
//
// A decade of notes is full of these: commands, identifiers, file names. Every one
// of them is several words the index thinks is one.
func TestAWordInsideAWordIsStillFound(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
if err := db.SaveNote(ctx, &Note{
CreatedBy: userID,
Body: "Minecraft Bedrock\n\ngamerule keepInventory true",
}); err != nil {
t.Fatal(err)
}
// A second note, so a fallback that simply returns everything would be caught.
if err := db.SaveNote(ctx, &Note{
CreatedBy: userID,
Body: "Indkøb\n\nKaffe og filtre",
}); err != nil {
t.Fatal(err)
}
for _, q := range []string{
"keep inventory", // the two words, which is how somebody looks for it
"inventory", // the tail on its own — no prefix query can reach this
"keepinventory", // written as one, which the index does hold
"KEEP INVENTORY", // and case is not a question anybody is asking
"vacuum", // not in either note
} {
found, err := db.SearchNotes(ctx, q, 10)
if err != nil {
t.Fatalf("%q: %v", q, err)
}
want := 1
if q == "vacuum" {
want = 0
}
if len(found) != want {
t.Errorf("%q found %d notes, want %d", q, len(found), want)
continue
}
if want == 1 && found[0].Title != "Minecraft Bedrock" {
t.Errorf("%q found %q", q, found[0].Title)
}
}
// The fast path is still the fast path: a word the index does hold must not be
// answered by the scan, or the ranking that was built for twelve hundred notes
// is quietly gone.
found, err := db.SearchNotes(ctx, "Bedrock", 10)
if err != nil {
t.Fatal(err)
}
if len(found) != 1 {
t.Fatalf("an ordinary word found %d notes", len(found))
}
}