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

@@ -75,6 +75,32 @@ type Query struct {
Page int
// PerPage is the page size; 0 leaves the engine default.
PerPage int
// IncludeFields and ExcludeFields only limit which document fields the
// engine sends back: a field left out of the answer stays searchable
// and highlightable. Empty keeps the engine default (every stored
// field). The document key is always returned, because the ids are the
// result. Engines that cannot project ignore them.
IncludeFields []string
ExcludeFields []string
// Highlight asks for snippets of the matches in SearchResult.Hits. Nil
// asks for no highlights and keeps the request as it was. Engines
// without HighlightSearcher ignore it.
Highlight *Highlight
}
// Highlight configures the snippets an engine returns for a Query.
// Snippets are always cut around the matches; there is deliberately no
// option to highlight a whole field.
type Highlight struct {
// Fields are the fields to highlight. Empty is the engine default,
// which is the QueryBy fields for Typesense.
Fields []string
// AffixTokens is the number of tokens of context on each side of a
// match; 0 is the engine default.
AffixTokens int
// MaxSnippetsPerArray is the most snippets kept for one array field;
// 0 means no cap.
MaxSnippetsPerArray int
}
// SearchResult is one page of candidate ids and the number of documents
@@ -83,6 +109,36 @@ type Query struct {
type SearchResult struct {
IDs []string
Found int
// Hits has one entry per id, in the same order as IDs
// (Hits[i].ID == IDs[i]). An engine called directly may leave it nil;
// SearchPage always fills it. Like the ids, hits are candidates: drop
// the Hit of every id the SQL re-check rejects before showing a
// snippet.
Hits []Hit
}
// Hit is the engine's answer for one id beyond the id itself.
type Hit struct {
ID string
// Highlights is nil when the hit has none.
Highlights []Snippet
}
// Snippet is one highlighted excerpt of a field.
type Snippet struct {
Field string
// ArrayIndex is the element index for an array field, and -1 for a
// non-array field or when the engine did not say which element.
ArrayIndex int
Segments []Segment
}
// Segment is a run of a snippet that either matched the query or not.
// Text is plain stored text, verbatim, never HTML or markup. It can
// contain < or &, so the caller escapes it when rendering.
type Segment struct {
Text string
Match bool
}
// PageSearcher is implemented by an engine that can report how many
@@ -92,18 +148,51 @@ type PageSearcher interface {
SearchPage(ctx context.Context, index string, q Query) (SearchResult, error)
}
// HighlightSearcher is implemented by an engine that can return snippets
// of the matches. It is optional so that existing engines stay valid. An
// engine that implements it and answers true fills Hit.Highlights for a
// Query with Highlight; ask through SupportsHighlights.
type HighlightSearcher interface {
PageSearcher
SupportsHighlights() bool
}
// SupportsHighlights reports whether e fills Hit.Highlights: true only when
// e implements HighlightSearcher and its SupportsHighlights answers true. A
// nil e is false. When it is false, an app builds its own snippets from the
// rows it loads.
func SupportsHighlights(e Engine) bool {
hs, ok := e.(HighlightSearcher)
return ok && hs.SupportsHighlights()
}
// SearchPage asks e for one page of q: through PageSearcher when e
// implements it, otherwise through SearchIDs with Found set to the number
// of ids returned.
// of ids returned. On success Hits is always in step with IDs: when the
// engine left it nil, it is one Hit without highlights per id (an empty,
// non-nil slice for zero ids); hits the engine filled are kept as they are.
func SearchPage(ctx context.Context, e Engine, index string, q Query) (SearchResult, error) {
var res SearchResult
if ps, ok := e.(PageSearcher); ok {
return ps.SearchPage(ctx, index, q)
r, err := ps.SearchPage(ctx, index, q)
if err != nil {
return r, err
}
res = r
} else {
ids, err := e.SearchIDs(ctx, index, q)
if err != nil {
return SearchResult{}, err
}
res = SearchResult{IDs: ids, Found: len(ids)}
}
ids, err := e.SearchIDs(ctx, index, q)
if err != nil {
return SearchResult{}, err
if res.Hits == nil {
res.Hits = make([]Hit, len(res.IDs))
for i, id := range res.IDs {
res.Hits[i] = Hit{ID: id}
}
}
return SearchResult{IDs: ids, Found: len(ids)}, nil
return res, nil
}
// IndexDropper is implemented by an engine that can tell whether the index