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:
@@ -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 <b> <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.
|
||||
|
||||
Reference in New Issue
Block a user