ask and holds on the ledger answer from a full-text index of the heads and the current versions' text and clauses; it is built at start (index not built is the answer meanwhile), a new version replaces its record's documents, a reserved, withdrawn or retracted record has none. A new pure Go dependency, bleve. Issue 1966. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
222 lines
6.1 KiB
Go
222 lines
6.1 KiB
Go
package main
|
|
|
|
// The search index (novox/hq to-be 55 §1, ADR 0597 §12): a full-text index in the module's own memory, written with the
|
|
// pure Go library bleve. At start the module reads every head from the ledger and the bodies of every current version into
|
|
// it, then keeps it current as it writes; `ask` and `holds` answer from it, and refuse with "index not built" while it is
|
|
// built. Nothing but the module writes it and nothing outside it reads it.
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/blevesearch/bleve/v2"
|
|
"github.com/blevesearch/bleve/v2/analysis/lang/en"
|
|
"github.com/blevesearch/bleve/v2/mapping"
|
|
"github.com/blevesearch/bleve/v2/search/query"
|
|
)
|
|
|
|
// ErrIndexNotBuilt is the answer of a question asked while the index is built.
|
|
var ErrIndexNotBuilt = errors.New("index not built: the search index is being built from the ledger; ask again in a moment (get, list and propose answer at once)")
|
|
|
|
// searchIndex is the index and what it holds.
|
|
type searchIndex struct {
|
|
mu sync.Mutex
|
|
idx bleve.Index
|
|
built bool
|
|
builtAt time.Time
|
|
docs int
|
|
// docsOf are the documents each record has in the index, so a new version replaces them.
|
|
docsOf map[Key][]string
|
|
}
|
|
|
|
func newSearchIndex() (*searchIndex, error) {
|
|
m := bleve.NewIndexMapping()
|
|
text := mapping.NewTextFieldMapping()
|
|
text.Analyzer = en.AnalyzerName
|
|
doc := bleve.NewDocumentMapping()
|
|
for _, f := range []string{"title", "topic", "text", "clause"} {
|
|
doc.AddFieldMappingsAt(f, text)
|
|
}
|
|
m.DefaultMapping = doc
|
|
idx, err := bleve.NewMemOnly(m)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &searchIndex{idx: idx, docsOf: map[Key][]string{}}, nil
|
|
}
|
|
|
|
type indexDoc struct {
|
|
Title string `json:"title,omitempty"`
|
|
Topic string `json:"topic,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
Clause string `json:"clause,omitempty"`
|
|
}
|
|
|
|
// put replaces the documents of a record by those of its served version: its title, topic and sections as one document,
|
|
// and each clause in force or proposed as one of its own. A reserved, withdrawn or retracted record has none.
|
|
func (s *searchIndex) put(r *Record) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
key := r.Key()
|
|
b := s.idx.NewBatch()
|
|
for _, id := range s.docsOf[key] {
|
|
b.Delete(id)
|
|
}
|
|
delete(s.docsOf, key)
|
|
if r.Status != StatusReserved && r.Status != StatusWithdrawn && r.Status != StatusRetracted {
|
|
var body strings.Builder
|
|
for _, sec := range r.Sections {
|
|
body.WriteString(sec.Text + "\n")
|
|
}
|
|
id := fmt.Sprintf("%s/%d", key.Kind, key.Number)
|
|
if err := b.Index(id, indexDoc{Title: r.Title, Topic: r.Topic, Text: body.String()}); err != nil {
|
|
return err
|
|
}
|
|
ids := []string{id}
|
|
for _, c := range r.Clauses {
|
|
if c.Status == ClauseRetracted {
|
|
continue
|
|
}
|
|
cid := fmt.Sprintf("%s#%d", id, c.N)
|
|
if err := b.Index(cid, indexDoc{Clause: c.Text}); err != nil {
|
|
return err
|
|
}
|
|
ids = append(ids, cid)
|
|
}
|
|
s.docsOf[key] = ids
|
|
}
|
|
s.docs = 0
|
|
for _, ids := range s.docsOf {
|
|
s.docs += len(ids)
|
|
}
|
|
return s.idx.Batch(b)
|
|
}
|
|
|
|
// search ranks the documents against the question's words: a title weighs twice, as the in-memory ranking did.
|
|
func (s *searchIndex) search(question string, limit int) ([]Hit, error) {
|
|
s.mu.Lock()
|
|
built := s.built
|
|
s.mu.Unlock()
|
|
if !built {
|
|
return nil, ErrIndexNotBuilt
|
|
}
|
|
var words []string
|
|
for _, w := range wordsOf(question) {
|
|
if !askingWords[w] && len(w) >= 3 {
|
|
words = append(words, w)
|
|
}
|
|
}
|
|
if len(words) == 0 {
|
|
return nil, nil
|
|
}
|
|
var should []query.Query
|
|
for _, w := range words {
|
|
for field, boost := range map[string]float64{"title": 2, "topic": 1, "text": 0.05, "clause": 1} {
|
|
q := bleve.NewMatchQuery(w)
|
|
q.SetField(field)
|
|
q.SetBoost(boost)
|
|
should = append(should, q)
|
|
}
|
|
}
|
|
req := bleve.NewSearchRequestOptions(bleve.NewDisjunctionQuery(should...), 5000, 0, false)
|
|
res, err := s.idx.Search(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var hits []Hit
|
|
for _, h := range res.Hits {
|
|
var kind string
|
|
var number, clause int
|
|
rest := h.ID
|
|
if i := strings.IndexByte(rest, '#'); i >= 0 {
|
|
if _, err := fmt.Sscanf(rest[i+1:], "%d", &clause); err != nil {
|
|
continue
|
|
}
|
|
rest = rest[:i]
|
|
}
|
|
if i := strings.IndexByte(rest, '/'); i >= 0 {
|
|
kind = rest[:i]
|
|
if _, err := fmt.Sscanf(rest[i+1:], "%d", &number); err != nil {
|
|
continue
|
|
}
|
|
}
|
|
hits = append(hits, Hit{Key: Key{kind, number}, Clause: clause, Rank: h.Score})
|
|
}
|
|
sort.SliceStable(hits, func(i, j int) bool {
|
|
if hits[i].Rank != hits[j].Rank {
|
|
return hits[i].Rank > hits[j].Rank
|
|
}
|
|
if hits[i].Key != hits[j].Key {
|
|
return hits[i].Key.Number > hits[j].Key.Number
|
|
}
|
|
return hits[i].Clause < hits[j].Clause
|
|
})
|
|
if limit > 0 && len(hits) > limit {
|
|
hits = hits[:limit]
|
|
}
|
|
return hits, nil
|
|
}
|
|
|
|
// IndexState says how the index stands: built or not, when, and how many documents it holds.
|
|
type IndexState struct {
|
|
Built bool `json:"built"`
|
|
BuiltAt time.Time `json:"built-at,omitempty"`
|
|
Docs int `json:"documents"`
|
|
}
|
|
|
|
func (s *searchIndex) state() IndexState {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return IndexState{Built: s.built, BuiltAt: s.builtAt, Docs: s.docs}
|
|
}
|
|
|
|
// BuildIndex reads every record from the ledger into the index and marks it built; asked again it builds it anew.
|
|
func (s *ledgerStore) BuildIndex(ctx context.Context) error {
|
|
idx, err := newSearchIndex()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
all, err := s.All(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, e := range all {
|
|
if err := idx.put(e.Current); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
idx.mu.Lock()
|
|
idx.built, idx.builtAt = true, time.Now().UTC()
|
|
idx.mu.Unlock()
|
|
s.mu.Lock()
|
|
s.index = idx
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// IndexState is how the module's index stands.
|
|
func (s *ledgerStore) IndexState() IndexState {
|
|
s.mu.Lock()
|
|
idx := s.index
|
|
s.mu.Unlock()
|
|
if idx == nil {
|
|
return IndexState{}
|
|
}
|
|
return idx.state()
|
|
}
|
|
|
|
// indexed keeps the index current with a version the store wrote.
|
|
func (s *ledgerStore) indexed(r *Record, current bool) {
|
|
if s.index == nil || !current {
|
|
return
|
|
}
|
|
if err := s.index.put(r); err != nil && s.logf != nil {
|
|
s.logf("[mesh-knowledge] the search index could not take %s: %v", r.Key(), err)
|
|
}
|
|
}
|