~bigbes/sr-ht-spec

ref: 2928bf9d79d9ae999a27c74ee5811693489a3a4e sr-ht-spec/search/search.go -rw-r--r-- 10.3 KiB
2928bf9d — bigbes feat(cmd): specsrht space create/list 27 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
// 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 ""
}