fix: index the document title, which was searchable nowhere
Reported from the live instance: a pasted document titled "Passport numbers" could not be found by "passport". The page then said "There is 1 passage indexed, so this is a real absence rather than an empty index" — asserting a real absence over the codebase's own worst failure shape, on the line written to prevent it. It was not an absence. The word was in the corpus; nothing looked at the column holding it. The FTS5 table had two indexed columns, heading and body, and the title is neither. A chunk's heading trail comes from Markdown headings *inside* the body, so a plain paste has an empty trail and the only searchable text is the body. The title was sealed, opened for display on every result row, and never handed to the index at all. Adds a title column, populated in indexDocument so that Ingest and Rebuild both fill it — a title indexed on only one of those paths would make a document findable until the next restart, which is a worse bug than this one. It sits after body because snippet() and bm25() address columns by declared position; putting it first would move body from 1 to 2 and silently change which column is highlighted. Weighted 1.0, the same as body rather than the heading's 3.0. The bug is that a title term found nothing, not that it ranked low, and a title match outranking a document that genuinely discusses the term would be a different wrong answer. The known consequence is the one the repeated heading trail already has: a title match hits every chunk of that document. On the first test: it passed against the unfixed code. The search page lists every document below the results, so asserting the title appears somewhere in the HTML is satisfied by a 0-result page. It now counts rst-result-title rows, and was watched failing 0-want-1 before the fix and passing after. No migration: the index is in-memory and rebuilt at boot, so an existing corpus becomes searchable by title on restart with nothing re-ingested.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJRwhRTr2kBeBjQNuH4HWm
3 files changed,
+72
−4
internal/memoria/index/index.go+26 −4internal/memoria/store.go+13 −0internal/memoriatest/app_test.go+33 −0
diff --git a/internal/memoria/index/index.go b/internal/memoria/index/index.go| index 30d3795..d5aa976 100644 |
| --- a/internal/memoria/index/index.go |
| +++ b/internal/memoria/index/index.go |
| @@ -76,6 +76,17 @@ func Open() (*Index, error) { |
| CREATE VIRTUAL TABLE chunks USING fts5( |
| heading, |
| body, |
| + -- title is the document's own title, repeated into every one |
| + -- of its chunks. Without it a document is unfindable by the |
| + -- words in its name: a pasted "Passport numbers" whose body |
| + -- is numbers matched nothing for "passport", and the page |
| + -- reported that as a real absence. |
| + -- |
| + -- It goes AFTER body deliberately. snippet() and bm25() |
| + -- address columns by declared position, so putting it first |
| + -- would move body from 1 to 2 and silently change which |
| + -- column gets highlighted. |
| + title, |
| chunk_id UNINDEXED, |
| document_id UNINDEXED, |
| ordinal UNINDEXED, |
| @@ -102,6 +113,9 @@ type Entry struct { |
| Ordinal int |
| Heading string |
| Text string |
| + // Title is the document's title. The same value for every chunk of |
| + // a document, as the heading trail is for every chunk of a section. |
| + Title string |
| } |
| // Add indexes entries in one transaction. |
| @@ -113,13 +127,13 @@ func (i *Index) Add(entries ...Entry) error { |
| return err |
| } |
| defer tx.Rollback() |
| - st, err := tx.Prepare(`INSERT INTO chunks (heading, body, chunk_id, document_id, ordinal) VALUES (?, ?, ?, ?, ?)`) |
| + st, err := tx.Prepare(`INSERT INTO chunks (heading, body, title, chunk_id, document_id, ordinal) VALUES (?, ?, ?, ?, ?, ?)`) |
| if err != nil { |
| return err |
| } |
| defer st.Close() |
| for _, e := range entries { |
| - if _, err := st.Exec(e.Heading, e.Text, e.ChunkID, e.DocumentID, e.Ordinal); err != nil { |
| + if _, err := st.Exec(e.Heading, e.Text, e.Title, e.ChunkID, e.DocumentID, e.Ordinal); err != nil { |
| return err |
| } |
| } |
| @@ -149,6 +163,14 @@ func (i *Index) Count() (int, error) { |
| // Search runs a BM25 query. The heading column is weighted above the |
| // body: a passage under "Revenue" is a better answer to "revenue" than |
| // one that mentions the word in passing. |
| +// |
| +// Title carries the same weight as body, not more. The bug it fixes is |
| +// that a title term found nothing at all; ranking a title match above a |
| +// document that genuinely discusses the term would be a different and |
| +// worse answer. Note the consequence, which is the same one the repeated |
| +// heading trail already has: a title match hits every chunk of that |
| +// document, so a one-word title query can fill the page with one |
| +// document. Acceptable while documents are small; revisit with §12. |
| func (i *Index) Search(query string, limit int) ([]Hit, error) { |
| q := MatchQuery(query) |
| if q == "" { |
| @@ -162,10 +184,10 @@ func (i *Index) Search(query string, limit int) ([]Hit, error) { |
| rows, err := i.db.Query(` |
| SELECT chunk_id, document_id, ordinal, heading, |
| snippet(chunks, 1, '<mark>', '</mark>', '…', 24), |
| - bm25(chunks, 3.0, 1.0) |
| + bm25(chunks, 3.0, 1.0, 1.0) |
| FROM chunks |
| WHERE chunks MATCH ? |
| - ORDER BY bm25(chunks, 3.0, 1.0) |
| + ORDER BY bm25(chunks, 3.0, 1.0, 1.0) |
| LIMIT ?`, q, limit) |
| if err != nil { |
| return nil, err |
diff --git a/internal/memoria/store.go b/internal/memoria/store.go| index fefde48..98525a6 100644 |
| --- a/internal/memoria/store.go |
| +++ b/internal/memoria/store.go |
| @@ -195,6 +195,18 @@ func (s *Store) Ingest(ctx context.Context, source, key, title, body string) (in |
| // indexDocument opens a document's chunks and adds them to the index. |
| func (s *Store) indexDocument(ctx context.Context, docID int64) error { |
| + // The title is indexed alongside every chunk, so it is read here |
| + // rather than left to the caller: Ingest and Rebuild both land in |
| + // this function, and a title indexed on only one of those paths |
| + // would make a document findable until the next restart. |
| + var doc Document |
| + if err := s.db.G.WithContext(ctx).Where("id = ?", docID).First(&doc).Error; err != nil { |
| + return err |
| + } |
| + title, err := s.sealer.Open(seal.PurposeTitle, doc.SealedTitle) |
| + if err != nil { |
| + return fmt.Errorf("open document title %d: %w", docID, err) |
| + } |
| var rows []Chunk |
| if err := s.db.G.WithContext(ctx).Where("document_id = ?", docID).Order("ordinal").Find(&rows).Error; err != nil { |
| return err |
| @@ -215,6 +227,7 @@ func (s *Store) indexDocument(ctx context.Context, docID int64) error { |
| Ordinal: r.Ordinal, |
| Heading: string(heading), |
| Text: string(text), |
| + Title: string(title), |
| }) |
| } |
| if len(entries) == 0 { |
diff --git a/internal/memoriatest/app_test.go b/internal/memoriatest/app_test.go| index 3b64fb2..283606d 100644 |
| --- a/internal/memoriatest/app_test.go |
| +++ b/internal/memoriatest/app_test.go |
| @@ -211,3 +211,36 @@ func TestEditingUnderTheSameKeyUpdatesOneDocument(t *testing.T) { |
| t.Error("the new passage was not indexed") |
| } |
| } |
| + |
| +// A term that appears only in the document's title must find the |
| +// document. Reported from the live instance: a paste titled "Passport |
| +// numbers" whose body was numbers could not be found by "passport", and |
| +// the page said so confidently — "1 passage indexed, so this is a real |
| +// absence". It was not an absence. The index held two columns, heading |
| +// and body, and neither is the title: a chunk's heading trail comes from |
| +// Markdown headings *inside* the body, so a plain paste has none at all. |
| +// The title was sealed, shown on every result, and never indexed. |
| +func TestATermOnlyInTheTitleIsFound(t *testing.T) { |
| + h := newApp(t) |
| + store(t, h, "Passport numbers", "L898902C3\n\n7727833B1\n\n3041005D9") |
| + body := get(t, h, "/?q=passport").Body.String() |
| + // Count the result rows, not the title text. The same page lists |
| + // every document below the results, so asserting on the title alone |
| + // passes on a 0-result page — which is how the first draft of this |
| + // test passed against the very bug it was written for. |
| + if n := strings.Count(body, "rst-result-title"); n != 1 { |
| + t.Errorf("a term that appears only in the title returns %d results, want 1:\n%s", n, body) |
| + } |
| +} |
| + |
| +// The title must not drown the body. Indexing the title into every chunk |
| +// makes it a term in each one; this checks a body match still ranks and |
| +// that the title has not become the only thing that matches. |
| +func TestBodyStillMatchesWhenTitleIsIndexed(t *testing.T) { |
| + h := newApp(t) |
| + store(t, h, "Passport numbers", "The renewal fee rose in June.") |
| + body := get(t, h, "/?q=renewal").Body.String() |
| + if n := strings.Count(body, "rst-result-title"); n != 1 { |
| + t.Errorf("body term returns %d results, want 1:\n%s", n, body) |
| + } |
| +} |