Search does not index card comments #164

Closed
opened 2026-08-26 20:25:25 +00:00 by claude-bot · 2 comments
Collaborator

SearchService.SnapshotAsync (added in claude/issue-159) indexes a card's title, description, tag names, assignee and author names, and its board and column. It deliberately does not index the discussion.

Why not, today: comments would be the third collection hanging off Card in that query, which is the cartesian blow-up CardService.GetColumnsAsync is written to avoid (see docs/data.md), and indexing them means AES-decrypting every comment on every board to find the handful that mention the word.

What it costs a member: a decision that only ever got written down in a comment thread is not findable. On a board where the description is a one-liner and the argument happens underneath it, that is most of the content.

What a fix would look like: a second query rather than an include (the same shape GetColumnsAsync already uses to count comments), feeding a sixth SearchField weighted below Description. The thing to measure first is what decrypting every comment body adds to the first keystroke of a search session — the snapshot is built once per session, so the cost lands in one place and is easy to time. If it is significant, an alternative is indexing comments lazily: search titles/descriptions first and only fall back to comments when the query returns nothing.


Filed by Claude while working on #159 (run).

`SearchService.SnapshotAsync` (added in `claude/issue-159`) indexes a card's title, description, tag names, assignee and author names, and its board and column. It deliberately does **not** index the discussion. **Why not, today:** comments would be the third collection hanging off `Card` in that query, which is the cartesian blow-up `CardService.GetColumnsAsync` is written to avoid (see `docs/data.md`), and indexing them means AES-decrypting every comment on every board to find the handful that mention the word. **What it costs a member:** a decision that only ever got written down in a comment thread is not findable. On a board where the description is a one-liner and the argument happens underneath it, that is most of the content. **What a fix would look like:** a *second* query rather than an include (the same shape `GetColumnsAsync` already uses to count comments), feeding a sixth `SearchField` weighted below `Description`. The thing to measure first is what decrypting every comment body adds to the first keystroke of a search session — the snapshot is built once per session, so the cost lands in one place and is easy to time. If it is significant, an alternative is indexing comments lazily: search titles/descriptions first and only fall back to comments when the query returns nothing. --- _Filed by Claude while working on #159 ([run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/386))._
Author
Collaborator

🤖 On it — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 On it — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/392); the reply will link the full session transcript. <!-- claude-run:comment --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/issue-164/run-392.md) — updating every ~5 minutes while the task runs.
Author
Collaborator

Search now indexes card comments. Branch claude/issue-164 is pushed.

What changed

A sixth SearchField, Comments, weighted 0.7 — below Description (1.0) on purpose: a thread is longer than everything else on a card put together, so a word turns up in one by accident more often, and the card a word is written on has to outrank the card where somebody mentioned it in a reply.

A second query, never a third Include. SearchService.DiscussionsAsync projects two columns a comment (CardId, BodyMarkdown), ordered by card, and runs each thread into one string — the same shape CardService.GetColumnsAsync uses to count comments, for the same cartesian-product reason. It runs beside the card read rather than after it (own DbContext, Task.WhenAll so neither exception lands on nobody), because both are mostly decryption.

A hit found that way says so — SearchHit.InComments, set only when a query word scored best in the thread and nowhere better, rendered as three words in the meta line. Without it, a result whose title and preview hold none of what was typed reads as the box being broken.

The search panel: a title match, a description match, and a card found only by a word in its comment thread, marked "in a comment"

What it costs — measured first, as the issue asked

A throwaway harness drove the real SearchService.SearchAsync against a real Postgres with a seeded corpus (cold snapshot, median of 25, CI container with Postgres on the same box, so absolutes are pessimistic):

corpus before with comments
120 cards, 600 comments 33 ms, 4.4 MiB 50 ms, 7.7 MiB
720 cards, 3,600 comments 119 ms, 25.5 MiB 157 ms, 44.6 MiB
720 cards, 14,400 comments 126 ms, 25.4 MiB 272 ms, 96 MiB

Paid once, on the search that builds the snapshot — a later keystroke went from ~2.0 ms to ~3.0 ms at 720 cards, which the 160 ms debounce swallows whole. Running the two queries concurrently is what made it acceptable: sequential, the bottom two rows were 267 ms and 358 ms.

I did not take the lazy-fallback option the issue offered as the alternative. Searching titles first and only reaching for comments when nothing came back means a card whose entire thread is about the word stays invisible because something else matched — which cards a search can reach would depend on what else it found. Consistency was worth more than the tail of the cost; the reasoning and the numbers are now in docs/search.md.

Verified

  • dotnet build Ponente.sln clean, dotnet test Ponente.sln 448 passed (4 new CardSearchTests: found by a comment, ranked below a description match, the marker set/not set, a word in the fourth reply).
  • tests/e2e/search.spec.js extended and run green against a live app (dotnet run + local Postgres, Playwright chromium): the new case comments on a fixture card, finds it by a word that exists nowhere else, and asserts both the marker and its absence on an ordinary hit. Also re-ran board.spec.js and filter.spec.js green. One i18n.spec.js case fails in this setup only because it hard-codes the compose stack's http://localhost:8090 when adding a cookie — unrelated to this change, and it passes on the real stack.
  • Docs updated in the same commit: docs/search.md (six fields and weights, the marker, the measurement table, the rejected alternative, the memory note) and docs/data.md (the "third include" rule is about the include, not about never reading comment bodies). Changelog: the still-unreleased search bullet gained a clause in both locales rather than a second entry, since search has not shipped yet.

⏱ 22m 55s · 120 turns · tokens: 159.4k in / 53.5k out (+13322.2k cache-read)
📜 Full session transcript
→ Opened PR: #168

Search now indexes card comments. Branch `claude/issue-164` is pushed. ## What changed **A sixth `SearchField`, `Comments`, weighted 0.7** — below `Description` (1.0) on purpose: a thread is longer than everything else on a card put together, so a word turns up in one by accident more often, and the card a word is written *on* has to outrank the card where somebody mentioned it in a reply. **A second query, never a third `Include`.** `SearchService.DiscussionsAsync` projects two columns a comment (`CardId`, `BodyMarkdown`), ordered by card, and runs each thread into one string — the same shape `CardService.GetColumnsAsync` uses to *count* comments, for the same cartesian-product reason. It runs **beside** the card read rather than after it (own `DbContext`, `Task.WhenAll` so neither exception lands on nobody), because both are mostly decryption. **A hit found that way says so** — `SearchHit.InComments`, set only when a query word scored best in the thread and nowhere better, rendered as three words in the meta line. Without it, a result whose title and preview hold none of what was typed reads as the box being broken. ![The search panel: a title match, a description match, and a card found only by a word in its comment thread, marked "in a comment"](https://git.grandiras.net/attachments/ebbc6fb9-b52a-4a37-964f-a917486a2c90) ## What it costs — measured first, as the issue asked A throwaway harness drove the real `SearchService.SearchAsync` against a real Postgres with a seeded corpus (cold snapshot, median of 25, CI container with Postgres on the same box, so absolutes are pessimistic): | corpus | before | with comments | |---|---|---| | 120 cards, 600 comments | 33 ms, 4.4 MiB | 50 ms, 7.7 MiB | | 720 cards, 3,600 comments | 119 ms, 25.5 MiB | 157 ms, 44.6 MiB | | 720 cards, 14,400 comments | 126 ms, 25.4 MiB | 272 ms, 96 MiB | Paid **once**, on the search that builds the snapshot — a later keystroke went from ~2.0 ms to ~3.0 ms at 720 cards, which the 160 ms debounce swallows whole. Running the two queries concurrently is what made it acceptable: sequential, the bottom two rows were 267 ms and 358 ms. **I did not take the lazy-fallback option** the issue offered as the alternative. Searching titles first and only reaching for comments when nothing came back means a card whose entire thread is about the word stays invisible *because* something else matched — which cards a search can reach would depend on what else it found. Consistency was worth more than the tail of the cost; the reasoning and the numbers are now in `docs/search.md`. ## Verified - `dotnet build Ponente.sln` clean, `dotnet test Ponente.sln` **448 passed** (4 new `CardSearchTests`: found by a comment, ranked below a description match, the marker set/not set, a word in the fourth reply). - `tests/e2e/search.spec.js` extended and **run green against a live app** (`dotnet run` + local Postgres, Playwright chromium): the new case comments on a fixture card, finds it by a word that exists nowhere else, and asserts both the marker and its absence on an ordinary hit. Also re-ran `board.spec.js` and `filter.spec.js` green. One `i18n.spec.js` case fails in this setup only because it hard-codes the compose stack's `http://localhost:8090` when adding a cookie — unrelated to this change, and it passes on the real stack. - Docs updated in the same commit: `docs/search.md` (six fields and weights, the marker, the measurement table, the rejected alternative, the memory note) and `docs/data.md` (the "third include" rule is about the *include*, not about never reading comment bodies). Changelog: the still-unreleased search bullet gained a clause in both locales rather than a second entry, since search has not shipped yet. --- ⏱ 22m 55s · 120 turns · tokens: 159.4k in / 53.5k out (+13322.2k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/issue-164/run-392.md) → Opened PR: https://git.grandiras.net/Grandiras/Ponente/pulls/168
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/Ponente#164
No description provided.