feat(beachcomber): search highlights and field projection (quick-261108-pe4)

- Additive: Query.IncludeFields, ExcludeFields and Highlight; left empty,
  the Typesense request is the same as before.
- SearchResult.Hits holds one Hit per id, in step with IDs; SearchPage
  always fills it.
- Snippets are plain-text Segment runs split at private-use highlight tags,
  never HTML; MaxSnippetsPerArray is applied client-side.
- Optional HighlightSearcher capability, checked with SupportsHighlights
  (true for Typesense, false for null and third-party engines).
- README and docs: Highlights sections with the rule that a caller drops
  the hit of every id failing the SQL re-check before showing a snippet.

Targets framework v0.6.4 (additive, no breaking change).
This commit is contained in:
Jakub Zych
2026-11-08 18:31:27 +01:00
parent 6f9b77033f
commit 7819c6746c
9 changed files with 756 additions and 27 deletions

View File

@@ -135,6 +135,73 @@ A paginated endpoint also needs to know how many documents matched. An engine th
`beachcomber.Query.QueryByWeights` ranks the fields of `QueryBy`, one weight per field in the same order, as Scout's `query_by_weights` option does.
## Highlights
Set `beachcomber.Query.Highlight` to get snippets of the matches with the IDs. `beachcomber.SearchResult.Hits` holds one `beachcomber.Hit` per ID, in the same order as `IDs`, including hits without highlights; `beachcomber.SearchPage` always fills it, also for engines that do not highlight. A `beachcomber.Snippet` names its `Field` and, for an array field, the element in `ArrayIndex` (-1 for a non-array field), and splits the excerpt into `beachcomber.Segment` runs of plain stored text with `Match` set on the matched ones. The text is never HTML: a stored `<b>` comes back as `<b>`, so escape `Text` when rendering and mark the matches yourself.
`beachcomber.Highlight.Fields` picks the fields (empty is the engine default), `AffixTokens` the context around a match and `MaxSnippetsPerArray` caps the snippets of one array field. An engine that fills highlights implements the optional `beachcomber.HighlightSearcher` interface; check with `beachcomber.SupportsHighlights`, and when it is false build snippets from the rows loaded after the re-check.
`beachcomber.Query.IncludeFields` and `beachcomber.Query.ExcludeFields` shrink the answer without changing what is searched or highlighted. A large text field can be stored, highlighted and still left out of the answer, so a page of hits stays small. The document ID is always returned.
Highlights belong to candidates, like the IDs. Drop the `Hit` of every ID that fails the SQL re-check before showing any snippet:
```go src=modules/beachcomber/typesense/example_test.go#ExampleEngine_SearchPage
// A stand-in Typesense node. The documents carry only id and title:
// text is excluded from the answer, yet still highlighted.
node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
fmt.Println("exclude_fields", q.Get("exclude_fields"), "highlight_fields", q.Get("highlight_fields"),
"highlight_affix_num_tokens", q.Get("highlight_affix_num_tokens"))
fmt.Println("private-use start tag:", q.Get("highlight_start_tag") == "\ue000")
fmt.Fprint(w, `{"found":2,"hits":[`+
`{"document":{"id":"3","title":"Kind of Blue"},"highlights":[{"field":"text","snippet":"a <b> \ue000blue\ue001 note"}]},`+
`{"document":{"id":"8","title":"Draft"},"highlights":[{"field":"text","snippet":"private \ue000blue\ue001 draft"}]}]}`)
}))
defer node.Close()
u, _ := url.Parse(node.URL)
port, _ := strconv.Atoi(u.Port())
engine := typesense.New(typesense.Config{APIKey: "test-only-key", Host: u.Hostname(), Port: port, Protocol: "http"})
fmt.Println("highlights:", beachcomber.SupportsHighlights(engine))
res, err := beachcomber.SearchPage(context.Background(), engine, "acme_blog_posts", beachcomber.Query{
Q: "blue",
QueryBy: []string{"title", "text"},
ExcludeFields: []string{"text"},
Highlight: &beachcomber.Highlight{Fields: []string{"text"}, AffixTokens: 3},
})
if err != nil {
fmt.Println(err)
return
}
// Stands in for the SQL re-check: id 8 is not visible to the caller, so
// its hit is dropped before any snippet is shown.
allowed := map[string]bool{"3": true}
for _, hit := range res.Hits {
if !allowed[hit.ID] {
continue
}
for _, sn := range hit.Highlights {
var b strings.Builder
for _, seg := range sn.Segments {
text := html.EscapeString(seg.Text) // segments are plain text
if seg.Match {
text = "<mark>" + text + "</mark>"
}
b.WriteString(text)
}
fmt.Println(hit.ID, sn.Field, sn.ArrayIndex, b.String())
}
}
// Output:
// highlights: true
// exclude_fields text highlight_fields text highlight_affix_num_tokens 3
// private-use start tag: true
// 3 text -1 a &lt;b&gt; <mark>blue</mark> note
```
> [!WARNING]
> A snippet of a hit whose ID failed the SQL re-check leaks the private document: it quotes the indexed text of a draft, a deleted record or another user's data.
## Reindexing
`beachcomber.Service.Sync` pushes one model on demand; a reindex command loops over the table with it. Two helpers cover the index itself:
@@ -193,4 +260,4 @@ fmt.Println(typesense.New(typesense.Config{}).Configured())
// false
```
The Typesense engine implements `beachcomber.PageSearcher` on the same request, adding the answer's `found` count, and sends `query_by_weights` when the query has weights. A page is at most `typesense.MaxPerPage` (250) documents, Typesense's own limit: a larger `PerPage` is an error, so fetch more IDs page by page. Each request times out after `search.typesense.connection_timeout_seconds` (2 seconds by default). A failed answer is a `typesense.StatusError` with the method, path and status, never the answer body.
The Typesense engine implements `beachcomber.PageSearcher` on the same request, adding the answer's `found` count, and sends `query_by_weights` when the query has weights. A page is at most `typesense.MaxPerPage` (250) documents, Typesense's own limit: a larger `PerPage` is an error, so fetch more IDs page by page. Each request times out after `search.typesense.connection_timeout_seconds` (2 seconds by default). A failed answer is a `typesense.StatusError` with the method, path and status, never the answer body. `include_fields` (always with `id`), `exclude_fields`, `highlight_fields`, `highlight_affix_num_tokens` and the highlight tags are sent only when the query sets them, and `MaxSnippetsPerArray` is applied by the engine after the answer, because Typesense has no parameter for it.