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
+28 -1
View File
@@ -166,15 +166,42 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
// labels are short, and their names are typed by the person searching for
// them, so there is no spelling to be generous about. The diacritic
// folding that matters for task text does not earn a second index here.
//
// The third arm is a word inside a word. An index is built of tokens, and
// `SpeedAdmin` is one of them — so "speed admin" asks for two words that are
// nowhere. A prefix query gets half of it (`speed*` matches, `admin*` cannot)
// and the two are joined with AND, so the half that works is thrown away.
//
// Notes got the same arm, and for the same reason. Having it in one of the
// two would be worse than having it in neither: "it finds it in my notes but
// not in my tasks" is a bug report waiting, and the answer to it would be
// this line.
//
// It scans, and it only ever runs when somebody typed something in the box —
// Today and Kommende do not set `Search`. Every term has to appear, so the
// arms are ANDed with each other while the whole thing is ORed with the two
// above.
inside := make([]string, 0, maxSearchTerms)
insideArgs := make([]any, 0, maxSearchTerms)
for _, term := range SearchTerms(f.Search) {
inside = append(inside, "t.fold LIKE ?")
insideArgs = append(insideArgs, "%"+FoldDanish(term)+"%")
}
text := ""
if len(inside) > 0 {
text = " OR (" + strings.Join(inside, " AND ") + ")"
}
where = append(where, `(
t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?)
OR t.id IN (
SELECT tl.task_id FROM task_labels tl
JOIN labels l ON l.id = tl.label_id
WHERE lower(l.name) LIKE ?
)
)`+text+`
)`)
args = append(args, expr, "%"+strings.ToLower(strings.TrimSpace(f.Search))+"%")
args = append(args, insideArgs...)
}
if f.FilterSQL != "" {
where = append(where, "("+f.FilterSQL+")")