// 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. Empty means every space, which // is the meta-project. Spaces []core.SpaceRef // 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=#`. 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 . 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) } 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), )) if len(q.Spaces) > 0 { want := make([]query.Query, 0, len(q.Spaces)) for _, sp := range q.Spaces { 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 "" }