// Package search is spec.sr.ht's keyword search: one global bleve index over
// every document of every space, queried through a filter.
//
// It is warren's index/ + search/ packages absorbed, with three structural
// changes the design calls for.
//
// # One index, filtered at query time
//
// warren indexed one vault, so the question never came up. Here it is the
// central decision: there is exactly one bleve index, every document in it
// carries its space, and a project — a named set of spaces — is a term filter
// over that field, not an index of its own. Per-project indexes were specified
// in an earlier draft and retracted: with them, every merge fans out to N
// rebuilds, adding a space to a project forces one, and the "everything"
// project is a second full copy of the corpus. As a filter, the meta-project is
// genuinely degenerate — a filter that excludes nothing — and a merge touches
// one index.
//
// # Rebuilds, not incremental updates
//
// At tens of documents a day, a batch rebuild is cheap and a per-document
// upsert/delete path is machinery bought against a cost nobody has measured.
// The unit of a rebuild is therefore a space at a revision (RebuildSpace) or
// the whole corpus (RebuildAll), never a document. Both report their duration
// in Stats so the decision to revisit is made against a measurement.
//
// # Keyword only
//
// warren also had a sqlite-vec semantic index and fused the two rankings with
// reciprocal rank fusion. Vector search is Phase 5 here, so none of it is
// ported — not even as unreachable code. What is left in its place is a seam,
// not a stub: Search returns ranked Hits, and a later hybrid ranker fuses two
// such lists. Nothing in this package assumes it is the only ranker.
//
// The package depends on core/, doc/ and gitx/ document types and on nothing
// else of this service. In particular it does not touch db/: index staleness
// stamps live in Postgres and service/ writes them, while this package is
// handed a revision's worth of documents and returns hits.
package search
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/blevesearch/bleve/v2"
bsearch "github.com/blevesearch/bleve/v2/search"
"github.com/blevesearch/bleve/v2/search/query"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/doc"
)
// DefaultLimit is how many hits a Query with no Limit returns.
const DefaultLimit = 20
// Query is one search over the global index.
//
// The zero value searches nothing: an empty Text returns no hits rather than
// every document, because "search for nothing" is a caller that has not
// collected its input yet, not a request to list the corpus.
type Query struct {
// Text is the user's query, analyzed with the same analyzers the documents
// were indexed with, per field.
Text string
// Spaces restricts results to a set of spaces. This is what a project is:
// the design's "a project is a saved filter over one global index, not a
// container" is this field and nothing else — a project resolves to a
// core.SpaceFilter and it is handed over whole.
//
// It is a filter rather than a []core.SpaceRef because the empty slice had
// two defensible meanings and the two are opposites: here it read as "no
// restriction", while a project's empty membership means "no space". An
// empty project passed into a query therefore used to return the whole
// corpus. The filter carries its own polarity, and the zero value is
// neither answer — Search refuses it rather than guessing, since a scope
// nobody set is a caller bug and both defaults are wrong for one of them.
// core.EverythingFilter() is how a caller says "every space".
Spaces core.SpaceFilter
// Sections restricts results to top-level sections ("specs", "notes",
// "reports"). Empty means every section except the activity log — see
// doc.LogSection: log entries summarise other documents, so left in they
// compete with the documents they describe for the same queries. Naming
// "log" here is the way back in.
Sections []string
Limit int
Offset int
}
// Hit is one ranked document. Space, Path, Rev and Anchor together are a
// pinned, immutable URL for the result: the read plane serves
// `/~owner/space/path?rev=<sha>#<anchor>`.
type Hit struct {
Space core.SpaceRef `json:"space"`
ID string `json:"id"`
Rev string `json:"rev,omitempty"`
Path string `json:"path,omitempty"`
// Anchor is the heading anchor within Path, set for an activity-log entry.
Anchor string `json:"anchor,omitempty"`
Title string `json:"title,omitempty"`
Section string `json:"section,omitempty"`
Lang Lang `json:"lang,omitempty"`
Score float64 `json:"score"`
// Snippet is a highlighted fragment of the matching text, with the matched
// terms wrapped in <mark>. Everything around them is HTML-escaped by bleve's
// formatter, so the fragment is safe to render as HTML and must be, or the
// marks show up as literal text.
Snippet string `json:"snippet,omitempty"`
}
// Results is one page of ranked hits.
type Results struct {
Hits []Hit `json:"hits"`
// Total is how many documents matched, not how many were returned.
Total uint64 `json:"total"`
Took time.Duration `json:"took"`
}
// Search runs a query against the global index.
func (x *Index) Search(ctx context.Context, q Query) (Results, error) {
text := strings.TrimSpace(q.Text)
if text == "" {
return Results{}, nil
}
if q.Limit <= 0 {
q.Limit = DefaultLimit
}
if q.Offset < 0 {
return Results{}, fmt.Errorf("search: negative offset %d", q.Offset)
}
if q.Spaces.IsZero() {
return Results{}, errors.New("search: query names no space scope; " +
"pass core.EverythingFilter() to search every space, or a project's filter to restrict it")
}
// A filter that selects no space — an empty project — has a known answer,
// and it is not "everything". Asking the index would be asking a question
// with no terms in it.
if q.Spaces.MatchesNothing() {
return Results{}, nil
}
bq, err := buildQuery(text, q)
if err != nil {
return Results{}, err
}
req := bleve.NewSearchRequestOptions(bq, q.Limit, q.Offset, false)
req.Fields = []string{fieldSpace, fieldRev, fieldPath, fieldAnchor, fieldTitle, fieldSection, fieldLang}
req.Highlight = bleve.NewHighlight()
req.Highlight.AddField(fieldBodyEN)
req.Highlight.AddField(fieldBodyRU)
x.mu.RLock()
defer x.mu.RUnlock()
if x.idx == nil {
return Results{}, errors.New("search: index is closed")
}
res, err := x.idx.SearchInContext(ctx, req)
if err != nil {
return Results{}, fmt.Errorf("search: query %q: %w", text, err)
}
out := Results{Total: res.Total, Took: res.Took, Hits: make([]Hit, 0, len(res.Hits))}
for _, h := range res.Hits {
hit, err := toHit(h)
if err != nil {
return Results{}, err
}
out.Hits = append(out.Hits, hit)
}
return out, nil
}
// buildQuery assembles the bleve query: the text across both languages' fields,
// conjoined with the space and section filters.
func buildQuery(text string, q Query) (query.Query, error) {
// The query text is run against all four analyzed fields. Both languages
// every time, not the detected language of the query: a two-word query is
// far too short to classify, and an English term inside a Russian document
// lives in that document's English field.
match := func(field string, boost float64) query.Query {
m := bleve.NewMatchQuery(text)
m.SetField(field)
m.SetBoost(boost)
return m
}
b := bleve.NewBooleanQuery()
b.AddMust(bleve.NewDisjunctionQuery(
match(fieldTitleEN, titleBoost),
match(fieldTitleRU, titleBoost),
match(fieldBodyEN, 1),
match(fieldBodyRU, 1),
))
// The meta-project adds no term at all: a filter that excludes nothing is
// the absence of a restriction, not the enumeration of every space.
if refs := q.Spaces.Refs(); !q.Spaces.Everything() {
want := make([]query.Query, 0, len(refs))
for _, sp := range refs {
if sp.Owner == "" || sp.Name == "" {
return nil, errors.New("search: query carries an empty space")
}
t := bleve.NewTermQuery(sp.String())
t.SetField(fieldSpace)
want = append(want, t)
}
b.AddMust(bleve.NewDisjunctionQuery(want...))
}
if len(q.Sections) > 0 {
want := make([]query.Query, 0, len(q.Sections))
for _, s := range q.Sections {
if s == "" {
return nil, errors.New("search: query carries an empty section")
}
t := bleve.NewTermQuery(s)
t.SetField(fieldSection)
want = append(want, t)
}
b.AddMust(bleve.NewDisjunctionQuery(want...))
} else {
t := bleve.NewTermQuery(doc.LogSection)
t.SetField(fieldSection)
b.AddMustNot(t)
}
return b, nil
}
func toHit(h *bsearch.DocumentMatch) (Hit, error) {
str := func(field string) string {
s, _ := h.Fields[field].(string)
return s
}
raw := str(fieldSpace)
if raw == "" {
return Hit{}, fmt.Errorf("search: indexed document %q carries no space", h.ID)
}
sp, err := core.ParseSpaceRef(raw)
if err != nil {
return Hit{}, fmt.Errorf("search: indexed document %q carries space %q: %w", h.ID, raw, err)
}
hit := Hit{
Space: sp,
ID: strings.TrimPrefix(h.ID, raw+":"),
Rev: str(fieldRev),
Path: str(fieldPath),
Anchor: str(fieldAnchor),
Title: str(fieldTitle),
Section: str(fieldSection),
Lang: Lang(str(fieldLang)),
Score: h.Score,
}
hit.Snippet = snippet(h, hit.Lang)
return hit, nil
}
// snippet picks the highlighted fragment to show. The two body fields are the
// two halves of one document, and bleve highlights every requested field
// whether or not it matched — a field with no term locations yields its opening
// text, unmarked. Preferring the document's own language would therefore show
// the Russian opening of a document that matched on its English half. The
// matched field is the one that appears in Locations; the language preference
// only breaks a tie between two halves that both matched.
func snippet(h *bsearch.DocumentMatch, lang Lang) string {
order := []string{fieldBodyEN, fieldBodyRU}
if lang == LangRU {
order = []string{fieldBodyRU, fieldBodyEN}
}
for _, field := range order {
if len(h.Locations[field]) == 0 {
continue
}
if frags := h.Fragments[field]; len(frags) > 0 {
return frags[0]
}
}
// Matched on a title or on nothing highlightable: fall back to whichever
// half has text, so a hit is never returned with no context at all.
for _, field := range order {
if frags := h.Fragments[field]; len(frags) > 0 {
return frags[0]
}
}
return ""
}