483 lines
12 KiB
Go
483 lines
12 KiB
Go
package indexer
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/blevesearch/bleve/v2"
|
|
"github.com/blevesearch/bleve/v2/mapping"
|
|
"github.com/blevesearch/bleve/v2/search/query"
|
|
|
|
"github.com/sjzar/chatlog/internal/model"
|
|
)
|
|
|
|
const (
|
|
runtimeIndexVersionKey = "chatlog_index_version"
|
|
runtimeIndexVersion = "1"
|
|
fingerprintKey = "chatlog_index_fingerprint"
|
|
lastBuiltKey = "chatlog_index_last_built"
|
|
)
|
|
|
|
// SearchHit represents a single Bleve search hit mapped back to chatlog's domain model.
|
|
type SearchHit struct {
|
|
Message *model.Message
|
|
Snippet string
|
|
Score float64
|
|
}
|
|
|
|
// Index wraps a Bleve index with concurrency control and helper utilities.
|
|
type Index struct {
|
|
mu sync.RWMutex
|
|
idx bleve.Index
|
|
path string
|
|
}
|
|
|
|
// Open opens an existing index at the given path or creates a new one with the default mapping.
|
|
func Open(path string) (*Index, error) {
|
|
parent := filepath.Dir(path)
|
|
if err := os.MkdirAll(parent, 0o755); err != nil {
|
|
return nil, fmt.Errorf("create index parent dir: %w", err)
|
|
}
|
|
|
|
var (
|
|
idx bleve.Index
|
|
err error
|
|
)
|
|
if _, statErr := os.Stat(path); statErr == nil {
|
|
idx, err = bleve.Open(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open bleve index: %w", err)
|
|
}
|
|
} else if errors.Is(statErr, os.ErrNotExist) {
|
|
mapping, mapErr := buildMapping()
|
|
if mapErr != nil {
|
|
return nil, mapErr
|
|
}
|
|
idx, err = bleve.New(path, mapping)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create bleve index: %w", err)
|
|
}
|
|
} else {
|
|
return nil, fmt.Errorf("stat index: %w", statErr)
|
|
}
|
|
|
|
return &Index{idx: idx, path: path}, nil
|
|
}
|
|
|
|
// Close closes the underlying Bleve index.
|
|
func (i *Index) Close() error {
|
|
if i == nil {
|
|
return nil
|
|
}
|
|
i.mu.Lock()
|
|
defer i.mu.Unlock()
|
|
if i.idx == nil {
|
|
return nil
|
|
}
|
|
return i.idx.Close()
|
|
}
|
|
|
|
// Reset drops all existing data and recreates the index with the default mapping.
|
|
func (i *Index) Reset() error {
|
|
i.mu.Lock()
|
|
defer i.mu.Unlock()
|
|
|
|
if i.idx != nil {
|
|
_ = i.idx.Close()
|
|
}
|
|
if err := os.RemoveAll(i.path); err != nil {
|
|
return fmt.Errorf("remove index dir: %w", err)
|
|
}
|
|
mapping, err := buildMapping()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
idx, err := bleve.New(i.path, mapping)
|
|
if err != nil {
|
|
return fmt.Errorf("recreate bleve index: %w", err)
|
|
}
|
|
i.idx = idx
|
|
return nil
|
|
}
|
|
|
|
// SetMetadata stores an internal key/value pair within the index (not searchable).
|
|
func (i *Index) SetMetadata(key string, value []byte) error {
|
|
i.mu.Lock()
|
|
defer i.mu.Unlock()
|
|
if i.idx == nil {
|
|
return errors.New("index not initialized")
|
|
}
|
|
return i.idx.SetInternal([]byte(key), value)
|
|
}
|
|
|
|
// GetMetadata retrieves an internal key/value pair.
|
|
func (i *Index) GetMetadata(key string) ([]byte, error) {
|
|
i.mu.RLock()
|
|
defer i.mu.RUnlock()
|
|
if i.idx == nil {
|
|
return nil, errors.New("index not initialized")
|
|
}
|
|
return i.idx.GetInternal([]byte(key))
|
|
}
|
|
|
|
// IndexMessages indexes a batch of messages using Bleve batches for efficiency.
|
|
func (i *Index) IndexMessages(messages []*model.Message) error {
|
|
if len(messages) == 0 {
|
|
return nil
|
|
}
|
|
i.mu.Lock()
|
|
defer i.mu.Unlock()
|
|
if i.idx == nil {
|
|
return errors.New("index not initialized")
|
|
}
|
|
|
|
batch := i.idx.NewBatch()
|
|
const batchSize = 250
|
|
for idx, msg := range messages {
|
|
doc, err := newDocument(msg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := batch.Index(doc.ID, doc); err != nil {
|
|
return fmt.Errorf("batch index: %w", err)
|
|
}
|
|
if (idx+1)%batchSize == 0 {
|
|
if err := i.idx.Batch(batch); err != nil {
|
|
return fmt.Errorf("flush batch: %w", err)
|
|
}
|
|
batch = i.idx.NewBatch()
|
|
}
|
|
}
|
|
|
|
if batch.Size() > 0 {
|
|
if err := i.idx.Batch(batch); err != nil {
|
|
return fmt.Errorf("flush final batch: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Search executes a Bleve search over the indexed content applying optional filters.
|
|
func (i *Index) Search(req *model.SearchRequest, talkers []string, senders []string, startUnix, endUnix int64, offset, limit int) ([]*SearchHit, int, error) {
|
|
if req == nil {
|
|
return nil, 0, errors.New("search request is nil")
|
|
}
|
|
|
|
queryObj := buildQuery(req.Query, talkers, senders, startUnix, endUnix)
|
|
if queryObj == nil {
|
|
return []*SearchHit{}, 0, nil
|
|
}
|
|
|
|
i.mu.RLock()
|
|
idx := i.idx
|
|
i.mu.RUnlock()
|
|
if idx == nil {
|
|
return nil, 0, errors.New("index not initialized")
|
|
}
|
|
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
if limit > 200 {
|
|
limit = 200
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
|
|
searchRequest := bleve.NewSearchRequestOptions(queryObj, limit, offset, false)
|
|
searchRequest.Highlight = bleve.NewHighlightWithStyle("html")
|
|
searchRequest.Fields = []string{"message_json"}
|
|
searchRequest.IncludeLocations = false
|
|
|
|
result, err := idx.Search(searchRequest)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("bleve search: %w", err)
|
|
}
|
|
|
|
hits := make([]*SearchHit, 0, len(result.Hits))
|
|
for _, hit := range result.Hits {
|
|
messageJSON, ok := hit.Fields["message_json"].(string)
|
|
if !ok || messageJSON == "" {
|
|
continue
|
|
}
|
|
var msg model.Message
|
|
if err := json.Unmarshal([]byte(messageJSON), &msg); err != nil {
|
|
return nil, 0, fmt.Errorf("decode message: %w", err)
|
|
}
|
|
|
|
snippet := ""
|
|
if frags, ok := hit.Fragments["content"]; ok && len(frags) > 0 {
|
|
snippet = strings.Join(frags, " … ")
|
|
}
|
|
|
|
hits = append(hits, &SearchHit{
|
|
Message: &msg,
|
|
Snippet: snippet,
|
|
Score: hit.Score,
|
|
})
|
|
}
|
|
|
|
return hits, int(result.Total), nil
|
|
}
|
|
|
|
// Document representation stored inside Bleve.
|
|
type document struct {
|
|
ID string `json:"id"`
|
|
Talker string `json:"talker"`
|
|
Sender string `json:"sender"`
|
|
Unix int64 `json:"unix"`
|
|
Seq int64 `json:"seq"`
|
|
Content string `json:"content"`
|
|
MessageJSON string `json:"message_json"`
|
|
}
|
|
|
|
func newDocument(msg *model.Message) (*document, error) {
|
|
if msg == nil {
|
|
return nil, errors.New("nil message")
|
|
}
|
|
content := msg.PlainTextContent()
|
|
messageJSON, err := json.Marshal(msg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal message: %w", err)
|
|
}
|
|
return &document{
|
|
ID: fmt.Sprintf("%s:%d", msg.Talker, msg.Seq),
|
|
Talker: msg.Talker,
|
|
Sender: msg.Sender,
|
|
Unix: msg.Time.Unix(),
|
|
Seq: msg.Seq,
|
|
Content: content,
|
|
MessageJSON: string(messageJSON),
|
|
}, nil
|
|
}
|
|
|
|
func buildMapping() (*mapping.IndexMappingImpl, error) {
|
|
indexMapping := bleve.NewIndexMapping()
|
|
indexMapping.TypeField = "type"
|
|
indexMapping.DefaultAnalyzer = "standard"
|
|
|
|
docMapping := mapping.NewDocumentMapping()
|
|
|
|
contentField := mapping.NewTextFieldMapping()
|
|
contentField.Analyzer = "standard"
|
|
contentField.Store = false
|
|
docMapping.AddFieldMappingsAt("content", contentField)
|
|
|
|
talkerField := mapping.NewTextFieldMapping()
|
|
talkerField.Analyzer = "keyword"
|
|
talkerField.Store = true
|
|
talkerField.IncludeInAll = false
|
|
docMapping.AddFieldMappingsAt("talker", talkerField)
|
|
|
|
senderField := mapping.NewTextFieldMapping()
|
|
senderField.Analyzer = "keyword"
|
|
senderField.Store = true
|
|
senderField.IncludeInAll = false
|
|
docMapping.AddFieldMappingsAt("sender", senderField)
|
|
|
|
unixField := mapping.NewNumericFieldMapping()
|
|
unixField.Store = true
|
|
docMapping.AddFieldMappingsAt("unix", unixField)
|
|
|
|
seqField := mapping.NewNumericFieldMapping()
|
|
seqField.Store = true
|
|
docMapping.AddFieldMappingsAt("seq", seqField)
|
|
|
|
messageField := mapping.NewTextFieldMapping()
|
|
messageField.Analyzer = "keyword"
|
|
messageField.Store = true
|
|
messageField.Index = false
|
|
docMapping.AddFieldMappingsAt("message_json", messageField)
|
|
|
|
indexMapping.DefaultMapping = docMapping
|
|
|
|
return indexMapping, nil
|
|
}
|
|
|
|
func buildQuery(input string, talkers []string, senders []string, startUnix, endUnix int64) query.Query {
|
|
contentQuery := buildContentQuery(input)
|
|
|
|
var must []query.Query
|
|
if contentQuery != nil {
|
|
must = append(must, contentQuery)
|
|
}
|
|
|
|
if len(talkers) > 0 {
|
|
must = append(must, buildTermsFilter("talker", talkers))
|
|
}
|
|
if len(senders) > 0 {
|
|
must = append(must, buildTermsFilter("sender", senders))
|
|
}
|
|
if startUnix > 0 || endUnix > 0 {
|
|
var minPtr, maxPtr *float64
|
|
if startUnix > 0 {
|
|
min := float64(startUnix)
|
|
minPtr = &min
|
|
}
|
|
if endUnix > 0 {
|
|
max := float64(endUnix)
|
|
maxPtr = &max
|
|
}
|
|
rangeQuery := query.NewNumericRangeQuery(minPtr, maxPtr)
|
|
rangeQuery.SetField("unix")
|
|
must = append(must, rangeQuery)
|
|
}
|
|
|
|
if len(must) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if len(must) == 1 {
|
|
return must[0]
|
|
}
|
|
|
|
return query.NewConjunctionQuery(must)
|
|
}
|
|
|
|
func buildContentQuery(input string) query.Query {
|
|
s := strings.TrimSpace(input)
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
|
|
upper := strings.ToUpper(s)
|
|
advanced := strings.ContainsAny(s, "\"'*()") ||
|
|
strings.Contains(upper, " AND ") ||
|
|
strings.Contains(upper, " OR ") ||
|
|
strings.Contains(upper, " NEAR ") ||
|
|
strings.HasPrefix(upper, "NOT ")
|
|
|
|
if advanced {
|
|
return query.NewQueryStringQuery(s)
|
|
}
|
|
|
|
tokens := strings.Fields(s)
|
|
if len(tokens) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if len(tokens) == 1 {
|
|
mq := query.NewMatchQuery(tokens[0])
|
|
mq.SetField("content")
|
|
return mq
|
|
}
|
|
|
|
conj := make([]query.Query, 0, len(tokens))
|
|
for _, token := range tokens {
|
|
t := strings.TrimSpace(token)
|
|
if t == "" {
|
|
continue
|
|
}
|
|
mq := query.NewMatchQuery(t)
|
|
mq.SetField("content")
|
|
conj = append(conj, mq)
|
|
}
|
|
|
|
if len(conj) == 0 {
|
|
return nil
|
|
}
|
|
if len(conj) == 1 {
|
|
return conj[0]
|
|
}
|
|
return query.NewConjunctionQuery(conj)
|
|
}
|
|
|
|
func buildTermsFilter(field string, values []string) query.Query {
|
|
sanitized := make([]query.Query, 0, len(values))
|
|
for _, val := range values {
|
|
trimmed := strings.TrimSpace(val)
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
tq := query.NewTermQuery(trimmed)
|
|
tq.SetField(field)
|
|
sanitized = append(sanitized, tq)
|
|
}
|
|
if len(sanitized) == 0 {
|
|
return nil
|
|
}
|
|
if len(sanitized) == 1 {
|
|
return sanitized[0]
|
|
}
|
|
return query.NewDisjunctionQuery(sanitized)
|
|
}
|
|
|
|
// EnsureVersion ensures the stored index version matches current runtime version.
|
|
func (i *Index) EnsureVersion() (bool, error) {
|
|
current, _ := i.GetMetadata(runtimeIndexVersionKey)
|
|
if string(current) == runtimeIndexVersion {
|
|
return true, nil
|
|
}
|
|
if err := i.SetMetadata(runtimeIndexVersionKey, []byte(runtimeIndexVersion)); err != nil {
|
|
return false, err
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// UpdateFingerprint persists the dataset fingerprint into index metadata.
|
|
func (i *Index) UpdateFingerprint(fp string) error {
|
|
return i.SetMetadata(fingerprintKey, []byte(fp))
|
|
}
|
|
|
|
// Fingerprint reads the stored dataset fingerprint from index metadata.
|
|
func (i *Index) Fingerprint() string {
|
|
b, err := i.GetMetadata(fingerprintKey)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// FingerprintMatches reports whether the stored dataset fingerprint equals the provided value.
|
|
func (i *Index) FingerprintMatches(fp string) bool {
|
|
if fp == "" {
|
|
return false
|
|
}
|
|
return i.Fingerprint() == fp
|
|
}
|
|
|
|
// EnsureFingerprint compares the stored fingerprint with the provided value.
|
|
// It returns true when the fingerprint already matches. When it differs, the
|
|
// metadata is updated to the new fingerprint and false is returned to signal
|
|
// callers that a rebuild just occurred.
|
|
func (i *Index) EnsureFingerprint(fp string) (bool, error) {
|
|
if fp == "" {
|
|
return false, nil
|
|
}
|
|
current := i.Fingerprint()
|
|
if current == fp {
|
|
return true, nil
|
|
}
|
|
if err := i.UpdateFingerprint(fp); err != nil {
|
|
return false, err
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// UpdateLastBuilt 记录最近一次索引构建完成时间(Unix 秒)
|
|
func (i *Index) UpdateLastBuilt(t time.Time) error {
|
|
return i.SetMetadata(lastBuiltKey, []byte(strconv.FormatInt(t.Unix(), 10)))
|
|
}
|
|
|
|
// LastBuilt 返回最近一次索引构建完成时间
|
|
func (i *Index) LastBuilt() time.Time {
|
|
b, err := i.GetMetadata(lastBuiltKey)
|
|
if err != nil || len(b) == 0 {
|
|
return time.Time{}
|
|
}
|
|
sec, parseErr := strconv.ParseInt(string(b), 10, 64)
|
|
if parseErr != nil {
|
|
return time.Time{}
|
|
}
|
|
return time.Unix(sec, 0)
|
|
}
|