All checks were successful
release-tag / release-image (push) Successful in 1m36s
1834 lines
53 KiB
Go
1834 lines
53 KiB
Go
package knowledge
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/example/glpi-ai-agent/internal/brainactivity"
|
|
"github.com/example/glpi-ai-agent/internal/model"
|
|
)
|
|
|
|
type Embedder interface {
|
|
Embed(context.Context, []string) ([][]float64, error)
|
|
}
|
|
type ScoringConfig struct {
|
|
SemanticWeight float64
|
|
TitleWeight float64
|
|
LexicalWeight float64
|
|
KeywordWeight float64
|
|
CategoryWeight float64
|
|
EmbeddingProfile string
|
|
EmbeddingIdentity string
|
|
ChunkWords int
|
|
ChunkOverlap int
|
|
MaxChunksPerDoc int
|
|
MaxQueryChunks int
|
|
IndexMode string
|
|
EmbedBatchSize int
|
|
IndexScanInterval time.Duration
|
|
CategoryMode string
|
|
CategoryMapFile string
|
|
IgnoreGlobs []string
|
|
}
|
|
|
|
type LoadOptions struct {
|
|
CategoryMode string
|
|
CategoryMapFile string
|
|
IgnoreGlobs []string
|
|
}
|
|
|
|
type LoadStats struct {
|
|
IgnoredFiles int `json:"ignored_files"`
|
|
UnmappedCategoryFiles int `json:"unmapped_category_files"`
|
|
UnmappedCategories []string `json:"unmapped_categories,omitempty"`
|
|
}
|
|
|
|
// InitStatus exposes the asynchronous local knowledge startup state to the
|
|
// dashboard and readiness endpoint. The HTTP server can therefore be available
|
|
// while a large corpus is still being scanned or embedded.
|
|
type InitStatus struct {
|
|
State string `json:"state"`
|
|
Phase string `json:"phase"`
|
|
TotalFiles int `json:"total_files"`
|
|
ProcessedFiles int `json:"processed_files"`
|
|
LoadedDocs int `json:"loaded_docs"`
|
|
IndexedDocs int `json:"indexed_docs"`
|
|
CacheHits int `json:"cache_hits"`
|
|
PendingEmbeddings int `json:"pending_embeddings"`
|
|
StartedAt time.Time `json:"started_at,omitempty"`
|
|
FinishedAt time.Time `json:"finished_at,omitempty"`
|
|
LastError string `json:"last_error,omitempty"`
|
|
SnapshotLoaded bool `json:"snapshot_loaded"`
|
|
SnapshotPath string `json:"snapshot_path,omitempty"`
|
|
SnapshotSavedAt time.Time `json:"snapshot_saved_at,omitempty"`
|
|
LastScanAt time.Time `json:"last_scan_at,omitempty"`
|
|
LastScanError string `json:"last_scan_error,omitempty"`
|
|
ChangedFiles int `json:"changed_files"`
|
|
DeletedFiles int `json:"deleted_files"`
|
|
ReusedFiles int `json:"reused_files"`
|
|
}
|
|
|
|
type Store struct {
|
|
mu sync.RWMutex
|
|
initMu sync.Mutex
|
|
categoryMapWriteMu sync.Mutex
|
|
initStatus InitStatus
|
|
dir string
|
|
managedDir string
|
|
docs []model.KnowledgeDoc
|
|
files map[string]string
|
|
managed map[string]bool
|
|
external map[string]string
|
|
staticDocs map[string]model.KnowledgeDoc
|
|
titleVectors map[string][]float64
|
|
chunkVectors map[string][][]float64
|
|
chunks map[string][]string
|
|
embedder Embedder
|
|
rag bool
|
|
cachePath string
|
|
allowedSources map[string]struct{}
|
|
scoring ScoringConfig
|
|
loadOptions LoadOptions
|
|
loadStats LoadStats
|
|
categoryMap map[string][]int64
|
|
manifest map[string]fileRecord
|
|
snapshotPath string
|
|
externalCachePath string
|
|
}
|
|
type cacheFile struct {
|
|
Version int `json:"version,omitempty"`
|
|
Hashes map[string]string `json:"hashes"`
|
|
TitleVectors map[string][]float64 `json:"title_vectors,omitempty"`
|
|
ChunkVectors map[string][][]float64 `json:"chunk_vectors,omitempty"`
|
|
}
|
|
|
|
func DefaultScoringConfig() ScoringConfig {
|
|
return ScoringConfig{SemanticWeight: .45, TitleWeight: .20, LexicalWeight: .20, KeywordWeight: .075, CategoryWeight: .075, EmbeddingProfile: "plain", ChunkWords: 160, ChunkOverlap: 30, MaxChunksPerDoc: 24, MaxQueryChunks: 64, IndexMode: "incremental", EmbedBatchSize: 64, IndexScanInterval: 5 * time.Minute}
|
|
}
|
|
|
|
// ResolveEmbeddingProfile selects prompt formatting for the configured embedding model.
|
|
// EmbeddingGemma benefits from distinct retrieval-query and retrieval-document prompts.
|
|
func ResolveEmbeddingProfile(profile, model string) string {
|
|
p := strings.ToLower(strings.TrimSpace(profile))
|
|
if p == "" || p == "auto" {
|
|
if strings.Contains(strings.ToLower(model), "embeddinggemma") {
|
|
return "embeddinggemma"
|
|
}
|
|
return "plain"
|
|
}
|
|
return p
|
|
}
|
|
|
|
func normalizeScoring(c ScoringConfig) ScoringConfig {
|
|
d := DefaultScoringConfig()
|
|
if c.SemanticWeight < 0 || c.TitleWeight < 0 || c.LexicalWeight < 0 || c.KeywordWeight < 0 || c.CategoryWeight < 0 || c.SemanticWeight+c.TitleWeight+c.LexicalWeight+c.KeywordWeight+c.CategoryWeight <= 0 {
|
|
c.SemanticWeight, c.TitleWeight, c.LexicalWeight, c.KeywordWeight, c.CategoryWeight = d.SemanticWeight, d.TitleWeight, d.LexicalWeight, d.KeywordWeight, d.CategoryWeight
|
|
}
|
|
if c.EmbeddingProfile == "" {
|
|
c.EmbeddingProfile = d.EmbeddingProfile
|
|
}
|
|
if c.ChunkWords <= 0 {
|
|
c.ChunkWords = d.ChunkWords
|
|
}
|
|
if c.ChunkOverlap < 0 || c.ChunkOverlap >= c.ChunkWords {
|
|
c.ChunkOverlap = d.ChunkOverlap
|
|
}
|
|
if c.MaxChunksPerDoc <= 0 {
|
|
c.MaxChunksPerDoc = d.MaxChunksPerDoc
|
|
}
|
|
if c.MaxQueryChunks <= 0 {
|
|
c.MaxQueryChunks = d.MaxQueryChunks
|
|
}
|
|
if strings.TrimSpace(c.IndexMode) == "" {
|
|
c.IndexMode = d.IndexMode
|
|
}
|
|
c.IndexMode = strings.ToLower(strings.TrimSpace(c.IndexMode))
|
|
if c.EmbedBatchSize <= 0 {
|
|
c.EmbedBatchSize = d.EmbedBatchSize
|
|
}
|
|
if c.IndexScanInterval < 0 {
|
|
c.IndexScanInterval = d.IndexScanInterval
|
|
}
|
|
return c
|
|
}
|
|
|
|
func NewStore(dir, dataDir string, embedder Embedder, rag bool, allowedSources []string, scoring ...ScoringConfig) (*Store, error) {
|
|
managedDir := filepath.Join(dataDir, "knowledge-managed")
|
|
if err := os.MkdirAll(managedDir, 0o750); err != nil {
|
|
return nil, fmt.Errorf("create managed knowledge directory: %w", err)
|
|
}
|
|
scoreCfg := DefaultScoringConfig()
|
|
if len(scoring) > 0 {
|
|
scoreCfg = normalizeScoring(scoring[0])
|
|
}
|
|
loadOpts := LoadOptions{CategoryMode: scoreCfg.CategoryMode, CategoryMapFile: scoreCfg.CategoryMapFile, IgnoreGlobs: scoreCfg.IgnoreGlobs}
|
|
if strings.TrimSpace(loadOpts.CategoryMode) == "" {
|
|
loadOpts.CategoryMode = "unscoped"
|
|
}
|
|
categoryMap, err := loadCategoryMap(loadOpts.CategoryMapFile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s := &Store{
|
|
dir: dir, managedDir: managedDir,
|
|
titleVectors: map[string][]float64{}, chunkVectors: map[string][][]float64{}, chunks: map[string][]string{},
|
|
files: map[string]string{}, managed: map[string]bool{}, external: map[string]string{}, staticDocs: map[string]model.KnowledgeDoc{},
|
|
embedder: embedder, rag: rag, cachePath: filepath.Join(dataDir, "embeddings.json"), allowedSources: map[string]struct{}{},
|
|
scoring: scoreCfg, loadOptions: loadOpts, categoryMap: categoryMap, manifest: map[string]fileRecord{},
|
|
snapshotPath: filepath.Join(dataDir, "knowledge-index", "snapshot.gob"), externalCachePath: filepath.Join(dataDir, "knowledge-index", "external-embeddings.json"),
|
|
initStatus: InitStatus{State: "waiting", Phase: "waiting"},
|
|
}
|
|
for _, source := range allowedSources {
|
|
s.allowedSources[strings.ToLower(strings.TrimSpace(source))] = struct{}{}
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// Load keeps the synchronous API used by tests and small deployments. The main
|
|
// application uses NewStore + Initialize in a goroutine so the Web UI is
|
|
// reachable immediately even for very large knowledge directories.
|
|
func Load(ctx context.Context, dir, dataDir string, embedder Embedder, rag bool, allowedSources []string, scoring ...ScoringConfig) (*Store, error) {
|
|
s, err := NewStore(dir, dataDir, embedder, rag, allowedSources, scoring...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.Initialize(ctx); err != nil {
|
|
return s, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// Initialize scans, validates and indexes the local knowledge corpus. It is
|
|
// safe to call from a background goroutine. Until it succeeds Ready() is false,
|
|
// so ticket processing can stay paused while the dashboard remains available.
|
|
func (s *Store) Initialize(ctx context.Context) (err error) {
|
|
if s == nil {
|
|
return fmt.Errorf("knowledge store is not initialized")
|
|
}
|
|
mode := strings.ToLower(strings.TrimSpace(s.scoring.IndexMode))
|
|
if mode == "" {
|
|
mode = "incremental"
|
|
}
|
|
if mode != "rebuild" {
|
|
loaded, loadErr := s.loadPersistentSnapshot()
|
|
if loadErr != nil {
|
|
if mode == "readonly" {
|
|
return loadErr
|
|
}
|
|
slog.Warn("persistent knowledge index unavailable; falling back to rebuild", "error", loadErr, "path", s.snapshotPath)
|
|
} else if loaded {
|
|
return nil
|
|
} else if mode == "readonly" {
|
|
return fmt.Errorf("KNOWLEDGE_INDEX_MODE=readonly requires a compatible persistent index at %s", s.snapshotPath)
|
|
}
|
|
}
|
|
return s.fullRebuild(ctx)
|
|
}
|
|
|
|
func (s *Store) fullRebuild(ctx context.Context) (err error) {
|
|
s.initMu.Lock()
|
|
defer s.initMu.Unlock()
|
|
|
|
s.setInitStatus(func(st *InitStatus) {
|
|
*st = InitStatus{State: "loading", Phase: "scanning", StartedAt: time.Now(), SnapshotPath: s.snapshotPath}
|
|
})
|
|
defer func() {
|
|
if err != nil {
|
|
s.setInitStatus(func(st *InitStatus) {
|
|
st.State = "error"
|
|
st.Phase = "error"
|
|
st.LastError = err.Error()
|
|
st.FinishedAt = time.Now()
|
|
})
|
|
}
|
|
}()
|
|
|
|
staticProcessed, staticTotal := 0, 0
|
|
static, staticFiles, stats, err := readDocs(s.dir, s.allowedSources, s.loadOptions, s.categoryMap, func(total, processed, loaded int) {
|
|
staticTotal, staticProcessed = total, processed
|
|
s.setInitStatus(func(st *InitStatus) {
|
|
st.TotalFiles = total
|
|
st.ProcessedFiles = processed
|
|
st.LoadedDocs = loaded
|
|
})
|
|
if processed > 0 && processed%1000 == 0 {
|
|
slog.Info("knowledge scan progress", "processed_files", processed, "total_files", total, "loaded_docs", loaded)
|
|
}
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
managedTotal := 0
|
|
managed, managedFiles, managedStats, err := readDocs(s.managedDir, s.allowedSources, LoadOptions{CategoryMode: "strict"}, s.categoryMap, func(total, processed, loaded int) {
|
|
managedTotal = total
|
|
s.setInitStatus(func(st *InitStatus) {
|
|
st.TotalFiles = staticTotal + total
|
|
st.ProcessedFiles = staticProcessed + processed
|
|
st.LoadedDocs = len(static) + loaded
|
|
})
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
stats.IgnoredFiles += managedStats.IgnoredFiles
|
|
stats.UnmappedCategoryFiles += managedStats.UnmappedCategoryFiles
|
|
stats.UnmappedCategories = mergeStrings(stats.UnmappedCategories, managedStats.UnmappedCategories)
|
|
|
|
staticManifest, err := buildManifestForDocs(static, staticFiles, "static", false)
|
|
if err != nil {
|
|
return fmt.Errorf("build static knowledge manifest: %w", err)
|
|
}
|
|
managedManifest, err := buildManifestForDocs(managed, managedFiles, "managed", true)
|
|
if err != nil {
|
|
return fmt.Errorf("build managed knowledge manifest: %w", err)
|
|
}
|
|
manifest := make(map[string]fileRecord, len(staticManifest)+len(managedManifest))
|
|
for k, v := range staticManifest {
|
|
manifest[k] = v
|
|
}
|
|
for k, v := range managedManifest {
|
|
manifest[k] = v
|
|
}
|
|
if err := augmentManifestAllFiles(s.dir, "static", false, s.loadOptions, manifest); err != nil {
|
|
return fmt.Errorf("complete static knowledge manifest: %w", err)
|
|
}
|
|
if err := augmentManifestAllFiles(s.managedDir, "managed", true, LoadOptions{CategoryMode: "strict"}, manifest); err != nil {
|
|
return fmt.Errorf("complete managed knowledge manifest: %w", err)
|
|
}
|
|
|
|
files := map[string]string{}
|
|
managedMap := map[string]bool{}
|
|
staticMap := map[string]model.KnowledgeDoc{}
|
|
merged := map[string]model.KnowledgeDoc{}
|
|
order := []string{}
|
|
for i, d := range static {
|
|
staticMap[d.ID] = d
|
|
files[d.ID] = staticFiles[i]
|
|
if _, ok := merged[d.ID]; !ok {
|
|
order = append(order, d.ID)
|
|
}
|
|
merged[d.ID] = d
|
|
}
|
|
for i, d := range managed {
|
|
if _, ok := merged[d.ID]; !ok {
|
|
order = append(order, d.ID)
|
|
}
|
|
merged[d.ID] = d
|
|
files[d.ID] = managedFiles[i]
|
|
managedMap[d.ID] = true
|
|
}
|
|
docs := make([]model.KnowledgeDoc, 0, len(order))
|
|
for _, id := range order {
|
|
docs = append(docs, merged[id])
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.docs = docs
|
|
s.files = files
|
|
s.managed = managedMap
|
|
s.external = map[string]string{}
|
|
s.staticDocs = staticMap
|
|
s.loadStats = stats
|
|
s.manifest = manifest
|
|
s.titleVectors = map[string][]float64{}
|
|
s.chunkVectors = map[string][][]float64{}
|
|
s.chunks = map[string][]string{}
|
|
s.mu.Unlock()
|
|
s.setInitStatus(func(st *InitStatus) {
|
|
st.Phase = "indexing"
|
|
st.TotalFiles = staticTotal + managedTotal
|
|
st.ProcessedFiles = st.TotalFiles
|
|
st.LoadedDocs = len(docs)
|
|
})
|
|
slog.Info("knowledge scan complete", "documents", len(docs), "ignored_files", stats.IgnoredFiles, "unmapped_category_files", stats.UnmappedCategoryFiles)
|
|
|
|
if s.rag && len(docs) > 0 {
|
|
if s.embedder == nil {
|
|
return fmt.Errorf("RAG is enabled but no embedding provider is configured")
|
|
}
|
|
if err := s.index(ctx); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := s.persistSnapshot(); err != nil {
|
|
return fmt.Errorf("persist knowledge snapshot: %w", err)
|
|
}
|
|
s.setInitStatus(func(st *InitStatus) {
|
|
st.State = "ready"
|
|
st.Phase = "ready"
|
|
st.IndexedDocs = len(docs)
|
|
st.PendingEmbeddings = 0
|
|
st.FinishedAt = time.Now()
|
|
st.LastError = ""
|
|
st.SnapshotLoaded = false
|
|
st.SnapshotPath = s.snapshotPath
|
|
st.SnapshotSavedAt = time.Now()
|
|
})
|
|
slog.Info("knowledge store ready", "documents", len(docs), "rag_enabled", s.rag, "persistent_index", s.snapshotPath)
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) setInitStatus(fn func(*InitStatus)) {
|
|
s.mu.Lock()
|
|
fn(&s.initStatus)
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *Store) InitStatus() InitStatus {
|
|
if s == nil {
|
|
return InitStatus{State: "error", Phase: "error", LastError: "knowledge store is nil"}
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.initStatus
|
|
}
|
|
|
|
func (s *Store) Ready() bool { return s != nil && s.InitStatus().State == "ready" }
|
|
|
|
func readDocs(dir string, allowed map[string]struct{}, opts LoadOptions, categoryMap map[string][]int64, progress func(total, processed, loaded int)) ([]model.KnowledgeDoc, []string, LoadStats, error) {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, nil, LoadStats{}, fmt.Errorf("read knowledge directory %q: %w", dir, err)
|
|
}
|
|
var docs []model.KnowledgeDoc
|
|
var files []string
|
|
stats := LoadStats{}
|
|
total := 0
|
|
for _, e := range entries {
|
|
if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".json") {
|
|
total++
|
|
}
|
|
}
|
|
processed := 0
|
|
if progress != nil {
|
|
progress(total, 0, 0)
|
|
}
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".json") {
|
|
continue
|
|
}
|
|
processed++
|
|
if matchesAnyGlob(e.Name(), opts.IgnoreGlobs) {
|
|
stats.IgnoredFiles++
|
|
if progress != nil {
|
|
progress(total, processed, len(docs))
|
|
}
|
|
continue
|
|
}
|
|
path := filepath.Join(dir, e.Name())
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, nil, stats, err
|
|
}
|
|
d, unmapped, skip, err := decodeKnowledgeDoc(b, opts.CategoryMode, categoryMap)
|
|
if err != nil {
|
|
return nil, nil, stats, fmt.Errorf("%s: %w", e.Name(), err)
|
|
}
|
|
if len(unmapped) > 0 {
|
|
stats.UnmappedCategoryFiles++
|
|
stats.UnmappedCategories = mergeStrings(stats.UnmappedCategories, unmapped)
|
|
}
|
|
if skip {
|
|
stats.IgnoredFiles++
|
|
if progress != nil {
|
|
progress(total, processed, len(docs))
|
|
}
|
|
continue
|
|
}
|
|
if d.ID == "" || d.Title == "" {
|
|
return nil, nil, stats, fmt.Errorf("%s: id/title required", e.Name())
|
|
}
|
|
if !safeID(d.ID) {
|
|
return nil, nil, stats, fmt.Errorf("%s: invalid id %q", e.Name(), d.ID)
|
|
}
|
|
d.Source = strings.ToLower(strings.TrimSpace(d.Source))
|
|
if d.Source == "" {
|
|
return nil, nil, stats, fmt.Errorf("%s: source required", e.Name())
|
|
}
|
|
if _, ok := allowed[d.Source]; !ok {
|
|
if progress != nil {
|
|
progress(total, processed, len(docs))
|
|
}
|
|
continue
|
|
}
|
|
d.Language = strings.TrimSpace(d.Language)
|
|
d.CommunicationStyle = strings.ToLower(strings.TrimSpace(d.CommunicationStyle))
|
|
docs = append(docs, d)
|
|
files = append(files, path)
|
|
if progress != nil {
|
|
progress(total, processed, len(docs))
|
|
}
|
|
}
|
|
return docs, files, stats, nil
|
|
}
|
|
|
|
func decodeKnowledgeDoc(b []byte, mode string, categoryMap map[string][]int64) (model.KnowledgeDoc, []string, bool, error) {
|
|
var raw map[string]json.RawMessage
|
|
if err := json.Unmarshal(b, &raw); err != nil {
|
|
return model.KnowledgeDoc{}, nil, false, err
|
|
}
|
|
catRaw := raw["categories"]
|
|
raw["categories"] = json.RawMessage(`[]`)
|
|
normalized, err := json.Marshal(raw)
|
|
if err != nil {
|
|
return model.KnowledgeDoc{}, nil, false, err
|
|
}
|
|
var d model.KnowledgeDoc
|
|
if err := json.Unmarshal(normalized, &d); err != nil {
|
|
return model.KnowledgeDoc{}, nil, false, err
|
|
}
|
|
ids, labels, unmapped, err := parseKnowledgeCategories(catRaw, categoryMap)
|
|
if err != nil {
|
|
return model.KnowledgeDoc{}, nil, false, err
|
|
}
|
|
d.Categories = ids
|
|
d.ExternalCategories = labels
|
|
d.UnmappedExternalCategories = append([]string(nil), unmapped...)
|
|
if len(unmapped) == 0 {
|
|
return d, nil, false, nil
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
case "strict":
|
|
return model.KnowledgeDoc{}, unmapped, false, fmt.Errorf("unmapped external categories: %s", strings.Join(unmapped, ", "))
|
|
case "skip":
|
|
return d, unmapped, true, nil
|
|
default:
|
|
// Unmapped external taxonomies remain searchable, but may not trigger an automatic reply.
|
|
d.AutoReply = false
|
|
return d, unmapped, false, nil
|
|
}
|
|
}
|
|
|
|
func parseKnowledgeCategories(raw json.RawMessage, categoryMap map[string][]int64) ([]int64, []string, []string, error) {
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return nil, nil, nil, nil
|
|
}
|
|
var v any
|
|
if err := json.Unmarshal(raw, &v); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
items, ok := v.([]any)
|
|
if !ok {
|
|
items = []any{v}
|
|
}
|
|
ids := []int64{}
|
|
labels := []string{}
|
|
unmapped := []string{}
|
|
for _, item := range items {
|
|
itemIDs, label, err := parseCategoryItem(item, categoryMap)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
ids = append(ids, itemIDs...)
|
|
if label != "" {
|
|
labels = appendUniqueString(labels, label)
|
|
if len(itemIDs) == 0 {
|
|
unmapped = appendUniqueString(unmapped, label)
|
|
}
|
|
}
|
|
}
|
|
return uniqueInt64(ids), labels, unmapped, nil
|
|
}
|
|
|
|
func parseCategoryItem(v any, categoryMap map[string][]int64) ([]int64, string, error) {
|
|
switch x := v.(type) {
|
|
case nil:
|
|
return nil, "", nil
|
|
case float64:
|
|
if x <= 0 || math.Trunc(x) != x {
|
|
return nil, "", fmt.Errorf("category id must be a positive integer")
|
|
}
|
|
return []int64{int64(x)}, "", nil
|
|
case string:
|
|
label := strings.TrimSpace(x)
|
|
if label == "" {
|
|
return nil, "", nil
|
|
}
|
|
if n, err := strconv.ParseInt(label, 10, 64); err == nil && n > 0 {
|
|
return []int64{n}, "", nil
|
|
}
|
|
return append([]int64(nil), categoryMap[normalizeCategoryLabel(label)]...), label, nil
|
|
case map[string]any:
|
|
if id, ok := x["id"]; ok {
|
|
ids, _, err := parseCategoryItem(id, categoryMap)
|
|
if err == nil && len(ids) > 0 {
|
|
return ids, "", nil
|
|
}
|
|
}
|
|
for _, key := range []string{"name", "label", "title"} {
|
|
if val, ok := x[key].(string); ok {
|
|
return parseCategoryItem(val, categoryMap)
|
|
}
|
|
}
|
|
return nil, "", fmt.Errorf("unsupported category object")
|
|
default:
|
|
return nil, "", fmt.Errorf("unsupported category value type %T", v)
|
|
}
|
|
}
|
|
|
|
func loadCategoryMap(path string) (map[string][]int64, error) {
|
|
out := map[string][]int64{}
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return out, nil
|
|
}
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return out, nil
|
|
}
|
|
return nil, fmt.Errorf("read knowledge category map %q: %w", path, err)
|
|
}
|
|
var root map[string]json.RawMessage
|
|
if err := json.Unmarshal(b, &root); err != nil {
|
|
return nil, fmt.Errorf("parse knowledge category map %q: %w", path, err)
|
|
}
|
|
if nested, ok := root["mappings"]; ok {
|
|
var m map[string]json.RawMessage
|
|
if err := json.Unmarshal(nested, &m); err != nil {
|
|
return nil, fmt.Errorf("parse mappings in %q: %w", path, err)
|
|
}
|
|
root = m
|
|
}
|
|
for label, rv := range root {
|
|
ids, err := parseMappingIDs(rv)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("category mapping %q: %w", label, err)
|
|
}
|
|
if len(ids) == 0 {
|
|
return nil, fmt.Errorf("category mapping %q contains no positive GLPI ids", label)
|
|
}
|
|
out[normalizeCategoryLabel(label)] = uniqueInt64(ids)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func parseMappingIDs(raw json.RawMessage) ([]int64, error) {
|
|
var v any
|
|
if err := json.Unmarshal(raw, &v); err != nil {
|
|
return nil, err
|
|
}
|
|
items, ok := v.([]any)
|
|
if !ok {
|
|
items = []any{v}
|
|
}
|
|
ids := []int64{}
|
|
for _, item := range items {
|
|
switch x := item.(type) {
|
|
case float64:
|
|
if x <= 0 || math.Trunc(x) != x {
|
|
return nil, fmt.Errorf("id must be a positive integer")
|
|
}
|
|
ids = append(ids, int64(x))
|
|
case string:
|
|
n, err := strconv.ParseInt(strings.TrimSpace(x), 10, 64)
|
|
if err != nil || n <= 0 {
|
|
return nil, fmt.Errorf("%q is not a positive GLPI category id", x)
|
|
}
|
|
ids = append(ids, n)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported mapping value type %T", item)
|
|
}
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func matchesAnyGlob(name string, patterns []string) bool {
|
|
for _, pattern := range patterns {
|
|
if ok, _ := filepath.Match(pattern, name); ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
func normalizeCategoryLabel(s string) string {
|
|
return strings.ToLower(strings.Join(strings.Fields(s), " "))
|
|
}
|
|
func uniqueInt64(in []int64) []int64 {
|
|
seen := map[int64]struct{}{}
|
|
out := []int64{}
|
|
for _, v := range in {
|
|
if v <= 0 {
|
|
continue
|
|
}
|
|
if _, ok := seen[v]; ok {
|
|
continue
|
|
}
|
|
seen[v] = struct{}{}
|
|
out = append(out, v)
|
|
}
|
|
return out
|
|
}
|
|
func appendUniqueString(in []string, s string) []string {
|
|
for _, v := range in {
|
|
if strings.EqualFold(v, s) {
|
|
return in
|
|
}
|
|
}
|
|
return append(in, s)
|
|
}
|
|
func mergeStrings(a, b []string) []string {
|
|
out := append([]string(nil), a...)
|
|
for _, s := range b {
|
|
out = appendUniqueString(out, s)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func (s *Store) LoadStats() LoadStats {
|
|
if s == nil {
|
|
return LoadStats{}
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
out := s.loadStats
|
|
out.UnmappedCategories = append([]string(nil), s.loadStats.UnmappedCategories...)
|
|
return out
|
|
}
|
|
|
|
func (s *Store) Count() int {
|
|
if s == nil {
|
|
return 0
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return len(s.docs)
|
|
}
|
|
func (s *Store) ByID(id string) (model.KnowledgeDoc, bool) {
|
|
if s == nil {
|
|
return model.KnowledgeDoc{}, false
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for _, d := range s.docs {
|
|
if d.ID == id {
|
|
return d, true
|
|
}
|
|
}
|
|
return model.KnowledgeDoc{}, false
|
|
}
|
|
func (s *Store) List() []model.KnowledgeDoc {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
out := append([]model.KnowledgeDoc(nil), s.docs...)
|
|
sort.SliceStable(out, func(i, j int) bool { return strings.ToLower(out[i].Title) < strings.ToLower(out[j].Title) })
|
|
return out
|
|
}
|
|
|
|
func (s *Store) Upsert(ctx context.Context, d model.KnowledgeDoc) error {
|
|
if s == nil {
|
|
return fmt.Errorf("knowledge store is not initialized")
|
|
}
|
|
if !s.Ready() {
|
|
return fmt.Errorf("knowledge store is still initializing")
|
|
}
|
|
d.ID = strings.TrimSpace(d.ID)
|
|
d.Title = strings.TrimSpace(d.Title)
|
|
d.Text = strings.TrimSpace(d.Text)
|
|
d.Answer = strings.TrimSpace(d.Answer)
|
|
d.Source = strings.ToLower(strings.TrimSpace(d.Source))
|
|
d.Language = strings.TrimSpace(d.Language)
|
|
d.CommunicationStyle = strings.ToLower(strings.TrimSpace(d.CommunicationStyle))
|
|
if d.ID == "" || d.Title == "" {
|
|
return fmt.Errorf("id/title required")
|
|
}
|
|
if !safeID(d.ID) {
|
|
return fmt.Errorf("knowledge id may contain only letters, digits, dot, dash and underscore")
|
|
}
|
|
if d.Source == "" {
|
|
return fmt.Errorf("source required")
|
|
}
|
|
if _, ok := s.allowedSources[d.Source]; !ok {
|
|
return fmt.Errorf("source %q is not allowed", d.Source)
|
|
}
|
|
if d.Language == "" || d.CommunicationStyle == "" {
|
|
return fmt.Errorf("language and communication_style required")
|
|
}
|
|
if d.MinScore < 0 || d.MinScore > 1 {
|
|
return fmt.Errorf("min_score must be between 0 and 1")
|
|
}
|
|
s.mu.RLock()
|
|
_, exists := s.files[d.ID]
|
|
isManaged := s.managed[d.ID]
|
|
externalSource := s.external[d.ID]
|
|
s.mu.RUnlock()
|
|
if externalSource != "" {
|
|
return fmt.Errorf("externally synchronized knowledge entry %q from %q is read-only", d.ID, externalSource)
|
|
}
|
|
if exists && !isManaged {
|
|
return fmt.Errorf("static knowledge entry %q is read-only; use a new id for a managed entry", d.ID)
|
|
}
|
|
|
|
var titleVector []float64
|
|
var chunkVectors [][]float64
|
|
chunks := chunkText(d.Text, s.scoring.ChunkWords, s.scoring.ChunkOverlap, s.scoring.MaxChunksPerDoc)
|
|
if s.rag {
|
|
if s.embedder == nil {
|
|
return fmt.Errorf("RAG is enabled but no embedding provider is configured")
|
|
}
|
|
embedded, err := s.embedDocuments(ctx, []model.KnowledgeDoc{d})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
titleVector = embedded[d.ID].title
|
|
chunkVectors = embedded[d.ID].chunks
|
|
}
|
|
|
|
path := filepath.Join(s.managedDir, d.ID+".json")
|
|
b, err := json.MarshalIndent(d, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, b, 0o640); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return err
|
|
}
|
|
info, statErr := os.Stat(path)
|
|
if statErr != nil {
|
|
return statErr
|
|
}
|
|
rawSum := sha256.Sum256(b)
|
|
manifestKey := "managed/" + filepath.Base(path)
|
|
s.mu.Lock()
|
|
replaced := false
|
|
for i := range s.docs {
|
|
if s.docs[i].ID == d.ID {
|
|
s.docs[i] = d
|
|
replaced = true
|
|
break
|
|
}
|
|
}
|
|
if !replaced {
|
|
s.docs = append(s.docs, d)
|
|
}
|
|
s.files[d.ID] = path
|
|
s.managed[d.ID] = true
|
|
s.manifest[manifestKey] = fileRecord{Key: manifestKey, Path: path, ID: d.ID, Size: info.Size(), ModTimeUnixNano: info.ModTime().UnixNano(), RawHash: hex.EncodeToString(rawSum[:]), Managed: true, Included: true, Unmapped: append([]string(nil), d.UnmappedExternalCategories...)}
|
|
s.chunks[d.ID] = chunks
|
|
if s.rag {
|
|
s.titleVectors[d.ID] = titleVector
|
|
s.chunkVectors[d.ID] = chunkVectors
|
|
}
|
|
s.mu.Unlock()
|
|
return s.persistVectorCache()
|
|
}
|
|
|
|
func (s *Store) Delete(id string) error {
|
|
if s == nil {
|
|
return fmt.Errorf("knowledge store is not initialized")
|
|
}
|
|
if !s.Ready() {
|
|
return fmt.Errorf("knowledge store is still initializing")
|
|
}
|
|
id = strings.TrimSpace(id)
|
|
if !safeID(id) {
|
|
return fmt.Errorf("invalid knowledge id")
|
|
}
|
|
s.mu.RLock()
|
|
path := s.files[id]
|
|
isManaged := s.managed[id]
|
|
s.mu.RUnlock()
|
|
if path == "" {
|
|
return os.ErrNotExist
|
|
}
|
|
if !isManaged {
|
|
return fmt.Errorf("static knowledge entry %q is read-only", id)
|
|
}
|
|
if err := os.Remove(path); err != nil {
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
out := s.docs[:0]
|
|
for _, d := range s.docs {
|
|
if d.ID != id {
|
|
out = append(out, d)
|
|
}
|
|
}
|
|
s.docs = append([]model.KnowledgeDoc(nil), out...)
|
|
delete(s.files, id)
|
|
delete(s.managed, id)
|
|
for key, rec := range s.manifest {
|
|
if rec.Managed && rec.ID == id {
|
|
delete(s.manifest, key)
|
|
}
|
|
}
|
|
delete(s.titleVectors, id)
|
|
delete(s.chunkVectors, id)
|
|
delete(s.chunks, id)
|
|
s.mu.Unlock()
|
|
return s.persistVectorCache()
|
|
}
|
|
|
|
func (s *Store) IsManaged(id string) bool {
|
|
if s == nil {
|
|
return false
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.managed[id]
|
|
}
|
|
func (s *Store) Origin(id string) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if s.managed[id] {
|
|
return "managed"
|
|
}
|
|
if src := s.external[id]; src != "" {
|
|
return src
|
|
}
|
|
if _, ok := s.staticDocs[id]; ok {
|
|
return "static"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ReplaceExternalSource atomically replaces all read-only documents imported
|
|
// from one connector source. Embeddings are reused when the normalized article
|
|
// did not change. Long article bodies are indexed as overlapping chunks.
|
|
func (s *Store) ReplaceExternalSource(ctx context.Context, source string, docs []model.KnowledgeDoc) error {
|
|
if s == nil {
|
|
return fmt.Errorf("knowledge store is not initialized")
|
|
}
|
|
source = strings.ToLower(strings.TrimSpace(source))
|
|
if _, ok := s.allowedSources[source]; !ok {
|
|
return fmt.Errorf("source %q is not allowed", source)
|
|
}
|
|
|
|
s.mu.RLock()
|
|
oldDocs := make(map[string]model.KnowledgeDoc, len(s.docs))
|
|
oldTitle := cloneVectorMap(s.titleVectors)
|
|
oldChunks := cloneChunkVectorMap(s.chunkVectors)
|
|
for _, d := range s.docs {
|
|
oldDocs[d.ID] = d
|
|
}
|
|
s.mu.RUnlock()
|
|
cached := loadCache(s.externalCachePath)
|
|
if len(cached.Hashes) == 0 {
|
|
cached = loadCache(s.cachePath) // one-time migration from the legacy combined cache
|
|
}
|
|
|
|
changed := make([]model.KnowledgeDoc, 0)
|
|
seen := map[string]struct{}{}
|
|
for i := range docs {
|
|
d := &docs[i]
|
|
d.ID = strings.TrimSpace(d.ID)
|
|
d.Title = strings.TrimSpace(d.Title)
|
|
d.Source = strings.ToLower(strings.TrimSpace(d.Source))
|
|
if d.Source == "" {
|
|
d.Source = source
|
|
}
|
|
if d.Source != source {
|
|
return fmt.Errorf("external document %q has source %q, expected %q", d.ID, d.Source, source)
|
|
}
|
|
if d.ID == "" || d.Title == "" || !safeID(d.ID) {
|
|
return fmt.Errorf("invalid external knowledge document id/title")
|
|
}
|
|
if _, dup := seen[d.ID]; dup {
|
|
return fmt.Errorf("duplicate external knowledge id %q", d.ID)
|
|
}
|
|
seen[d.ID] = struct{}{}
|
|
h := hashDoc(*d, s.scoring)
|
|
bodyChunks := chunkText(d.Text, s.scoring.ChunkWords, s.scoring.ChunkOverlap, s.scoring.MaxChunksPerDoc)
|
|
same := false
|
|
if old, ok := oldDocs[d.ID]; ok && hashDoc(old, s.scoring) == h && len(oldTitle[d.ID]) > 0 && len(oldChunks[d.ID]) == len(bodyChunks) {
|
|
same = true
|
|
} else if cached.Hashes[d.ID] == h && len(cached.TitleVectors[d.ID]) > 0 && len(cached.ChunkVectors[d.ID]) == len(bodyChunks) {
|
|
oldTitle[d.ID] = append([]float64(nil), cached.TitleVectors[d.ID]...)
|
|
oldChunks[d.ID] = cloneChunkVectors(cached.ChunkVectors[d.ID])
|
|
same = true
|
|
}
|
|
if !same {
|
|
changed = append(changed, *d)
|
|
}
|
|
}
|
|
|
|
newEmbedded := map[string]embeddedDoc{}
|
|
if s.rag && len(changed) > 0 {
|
|
if s.embedder == nil {
|
|
return fmt.Errorf("RAG is enabled but no embedding provider is configured")
|
|
}
|
|
var err error
|
|
newEmbedded, err = s.embedDocuments(ctx, changed)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
s.mu.Lock()
|
|
for _, d := range docs {
|
|
if src := s.external[d.ID]; src == "" {
|
|
if _, exists := oldDocs[d.ID]; exists {
|
|
s.mu.Unlock()
|
|
return fmt.Errorf("external knowledge id %q collides with local knowledge", d.ID)
|
|
}
|
|
} else if src != source {
|
|
s.mu.Unlock()
|
|
return fmt.Errorf("external knowledge id %q belongs to source %q", d.ID, src)
|
|
}
|
|
}
|
|
rebuilt := make([]model.KnowledgeDoc, 0, len(s.docs)+len(docs))
|
|
for _, d := range s.docs {
|
|
if s.external[d.ID] != source {
|
|
rebuilt = append(rebuilt, d)
|
|
}
|
|
}
|
|
for id, src := range s.external {
|
|
if src == source {
|
|
delete(s.external, id)
|
|
delete(s.titleVectors, id)
|
|
delete(s.chunkVectors, id)
|
|
delete(s.chunks, id)
|
|
}
|
|
}
|
|
for _, d := range docs {
|
|
rebuilt = append(rebuilt, d)
|
|
s.external[d.ID] = source
|
|
s.chunks[d.ID] = chunkText(d.Text, s.scoring.ChunkWords, s.scoring.ChunkOverlap, s.scoring.MaxChunksPerDoc)
|
|
if e, ok := newEmbedded[d.ID]; ok {
|
|
s.titleVectors[d.ID] = e.title
|
|
s.chunkVectors[d.ID] = e.chunks
|
|
} else {
|
|
s.titleVectors[d.ID] = oldTitle[d.ID]
|
|
s.chunkVectors[d.ID] = oldChunks[d.ID]
|
|
}
|
|
}
|
|
s.docs = rebuilt
|
|
s.mu.Unlock()
|
|
return s.persistExternalVectorCache(source)
|
|
}
|
|
|
|
func (s *Store) persistVectorCache() error {
|
|
return s.persistSnapshot()
|
|
}
|
|
|
|
// FindMetadata searches knowledge metadata without copying the complete corpus.
|
|
// It is intended for interactive UI lookups on very large knowledge stores.
|
|
func (s *Store) FindMetadata(query string, limit int) []model.KnowledgeDoc {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
q := strings.ToLower(strings.TrimSpace(query))
|
|
if limit <= 0 {
|
|
limit = 30
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
out := make([]model.KnowledgeDoc, 0, limit)
|
|
for _, d := range s.docs {
|
|
hay := strings.ToLower(d.ID + " " + d.Title + " " + strings.Join(d.Keywords, " ") + " " + strings.Join(d.ExternalCategories, " "))
|
|
if q != "" && !strings.Contains(hay, q) {
|
|
continue
|
|
}
|
|
out = append(out, d)
|
|
if len(out) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Store) ManagedDir() string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return s.managedDir
|
|
}
|
|
func safeID(v string) bool {
|
|
if v == "" {
|
|
return false
|
|
}
|
|
for _, r := range v {
|
|
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.') {
|
|
return false
|
|
}
|
|
}
|
|
return !strings.Contains(v, "..")
|
|
}
|
|
|
|
// Search calculates a transparent hybrid relevance score. Embedding similarity
|
|
// is only one component; titles, explicit keywords and category/learning hints
|
|
// are scored separately. Missing metadata does not lower a document's score:
|
|
// the weights of available components are normalized dynamically.
|
|
func (s *Store) Search(ctx context.Context, text string, topK int, categorySets ...[]model.Category) ([]model.KnowledgeHit, error) {
|
|
startedAt := time.Now()
|
|
if s == nil {
|
|
return nil, fmt.Errorf("knowledge store is not initialized")
|
|
}
|
|
s.mu.RLock()
|
|
scoreCfg := s.scoring
|
|
ragEnabled := s.rag
|
|
embedder := s.embedder
|
|
s.mu.RUnlock()
|
|
var cats []model.Category
|
|
if len(categorySets) > 0 {
|
|
cats = categorySets[0]
|
|
}
|
|
|
|
queryTitle, queryBody := splitQueryText(text)
|
|
queryChunks := chunkText(queryBody, scoreCfg.ChunkWords, scoreCfg.ChunkOverlap, scoreCfg.MaxQueryChunks)
|
|
if len(queryChunks) == 0 {
|
|
queryChunks = chunkText(text, scoreCfg.ChunkWords, scoreCfg.ChunkOverlap, scoreCfg.MaxQueryChunks)
|
|
}
|
|
var queryVectors [][]float64
|
|
var queryTitleVector []float64
|
|
if ragEnabled && embedder != nil {
|
|
if len(queryChunks) > 0 {
|
|
q, err := s.embedTexts(ctx, formatQueryEmbeddings(queryChunks, scoreCfg.EmbeddingProfile), scoreCfg.EmbedBatchSize)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
queryVectors = q
|
|
}
|
|
if strings.TrimSpace(queryTitle) != "" {
|
|
tq, err := embedder.Embed(ctx, formatQueryEmbeddings([]string{queryTitle}, scoreCfg.EmbeddingProfile))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(tq) > 0 {
|
|
queryTitleVector = tq[0]
|
|
}
|
|
}
|
|
}
|
|
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if len(s.docs) == 0 {
|
|
return nil, nil
|
|
}
|
|
hits := make([]model.KnowledgeHit, 0, len(s.docs))
|
|
for _, d := range s.docs {
|
|
semantic, bestChunk, bestQueryChunk := 0.0, "", ""
|
|
semanticAvailable := false
|
|
if len(queryVectors) > 0 && len(s.chunkVectors[d.ID]) > 0 {
|
|
semanticAvailable = true
|
|
for qi, qv := range queryVectors {
|
|
for di, dv := range s.chunkVectors[d.ID] {
|
|
score := clamp01(cosine(qv, dv))
|
|
if score > semantic || bestChunk == "" {
|
|
semantic = score
|
|
if di < len(s.chunks[d.ID]) {
|
|
bestChunk = s.chunks[d.ID][di]
|
|
}
|
|
if qi < len(queryChunks) {
|
|
bestQueryChunk = queryChunks[qi]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if strings.TrimSpace(d.Text) != "" {
|
|
semanticAvailable = true
|
|
docChunks := s.chunks[d.ID]
|
|
if len(docChunks) == 0 {
|
|
docChunks = []string{d.Text}
|
|
}
|
|
for _, qc := range queryChunks {
|
|
for _, dc := range docChunks {
|
|
score := tokenF1(qc, dc)
|
|
if score > semantic || bestChunk == "" {
|
|
semantic, bestChunk, bestQueryChunk = score, dc, qc
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
title := 0.0
|
|
titleAvailable := strings.TrimSpace(d.Title) != ""
|
|
if titleAvailable {
|
|
titleQuery := queryTitle
|
|
if strings.TrimSpace(titleQuery) == "" {
|
|
titleQuery = text
|
|
}
|
|
title = titleSimilarity(titleQuery, d.Title)
|
|
if len(queryTitleVector) > 0 && len(s.titleVectors[d.ID]) > 0 {
|
|
title = math.Max(title, clamp01(cosine(queryTitleVector, s.titleVectors[d.ID])))
|
|
}
|
|
}
|
|
lexicalScore := lexicalSimilarity(text, d)
|
|
keyword, keywordAvailable := keywordSimilarity(text, d.Keywords)
|
|
category, categoryAvailable := categorySimilarity(text, d.Categories, cats)
|
|
// Keywords and category profiles are positive evidence signals. Metadata that
|
|
// exists but has no lexical overlap must not drag an otherwise strong
|
|
// semantic/title match toward zero.
|
|
keywordAvailable = keywordAvailable && keyword > 0
|
|
categoryAvailable = categoryAvailable && category > 0
|
|
total := weightedScore(scoreCfg,
|
|
scorePart{semantic, scoreCfg.SemanticWeight, semanticAvailable},
|
|
scorePart{title, scoreCfg.TitleWeight, titleAvailable},
|
|
scorePart{lexicalScore, scoreCfg.LexicalWeight, lexicalScore > 0},
|
|
scorePart{keyword, scoreCfg.KeywordWeight, keywordAvailable},
|
|
scorePart{category, scoreCfg.CategoryWeight, categoryAvailable},
|
|
)
|
|
hits = append(hits, model.KnowledgeHit{Doc: d, Score: total, SemanticScore: semantic, TitleScore: title, LexicalScore: lexicalScore, KeywordScore: keyword, CategoryScore: category, BestChunkExcerpt: excerpt(bestChunk, 280), BestQueryExcerpt: excerpt(bestQueryChunk, 280), QueryChunkCount: len(queryChunks), DocumentChunkCount: len(s.chunks[d.ID])})
|
|
}
|
|
sort.SliceStable(hits, func(i, j int) bool {
|
|
if hits[i].Score == hits[j].Score {
|
|
return hits[i].TitleScore > hits[j].TitleScore
|
|
}
|
|
return hits[i].Score > hits[j].Score
|
|
})
|
|
if topK > 0 && len(hits) > topK {
|
|
hits = hits[:topK]
|
|
}
|
|
activityHits := make([]brainactivity.Hit, 0, len(hits))
|
|
for _, hit := range hits {
|
|
activityHits = append(activityHits, brainactivity.Hit{ID: hit.Doc.ID, Score: hit.Score})
|
|
}
|
|
brainactivity.EmitSearch("agent", text, activityHits, time.Since(startedAt))
|
|
return hits, nil
|
|
}
|
|
|
|
// FilterHitsBySources keeps the existing relevance order while applying a
|
|
// purpose-specific source allowlist. This lets one shared index serve normal
|
|
// retrieval and category-only knowledge without exposing category-only entries
|
|
// as reply candidates.
|
|
func FilterHitsBySources(hits []model.KnowledgeHit, sources []string, topK int) []model.KnowledgeHit {
|
|
allowed := make(map[string]struct{}, len(sources))
|
|
for _, source := range sources {
|
|
normalized := strings.ToLower(strings.TrimSpace(source))
|
|
if normalized != "" {
|
|
allowed[normalized] = struct{}{}
|
|
}
|
|
}
|
|
if len(allowed) == 0 || len(hits) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]model.KnowledgeHit, 0, len(hits))
|
|
for _, hit := range hits {
|
|
if _, ok := allowed[strings.ToLower(strings.TrimSpace(hit.Doc.Source))]; !ok {
|
|
continue
|
|
}
|
|
out = append(out, hit)
|
|
if topK > 0 && len(out) >= topK {
|
|
break
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// RerankForCategory applies a deterministic post-classification boost when a
|
|
// knowledge document is explicitly mapped to the category selected by the
|
|
// classifier. This happens after the model decision, so the dashboard and
|
|
// policy can distinguish retrieval evidence from category alignment.
|
|
func (s *Store) RerankForCategory(hits []model.KnowledgeHit, categoryID int64) []model.KnowledgeHit {
|
|
if s == nil || len(hits) == 0 || categoryID == 0 {
|
|
return hits
|
|
}
|
|
s.mu.RLock()
|
|
cfg := s.scoring
|
|
s.mu.RUnlock()
|
|
out := append([]model.KnowledgeHit(nil), hits...)
|
|
for i := range out {
|
|
match := false
|
|
for _, id := range out[i].Doc.Categories {
|
|
if id == categoryID {
|
|
match = true
|
|
break
|
|
}
|
|
}
|
|
if match {
|
|
out[i].CategoryScore = 1
|
|
}
|
|
out[i].Score = weightedScore(cfg,
|
|
scorePart{out[i].SemanticScore, cfg.SemanticWeight, out[i].SemanticScore > 0},
|
|
scorePart{out[i].TitleScore, cfg.TitleWeight, out[i].TitleScore > 0},
|
|
scorePart{out[i].LexicalScore, cfg.LexicalWeight, out[i].LexicalScore > 0},
|
|
scorePart{out[i].KeywordScore, cfg.KeywordWeight, out[i].KeywordScore > 0},
|
|
scorePart{out[i].CategoryScore, cfg.CategoryWeight, out[i].CategoryScore > 0},
|
|
)
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
if out[i].Score == out[j].Score {
|
|
return out[i].SemanticScore > out[j].SemanticScore
|
|
}
|
|
return out[i].Score > out[j].Score
|
|
})
|
|
return out
|
|
}
|
|
|
|
func (s *Store) index(ctx context.Context) error {
|
|
_ = os.MkdirAll(filepath.Dir(s.cachePath), 0o750)
|
|
cf := loadCache(s.cachePath)
|
|
var need []model.KnowledgeDoc
|
|
cacheHits := 0
|
|
for _, d := range s.docs {
|
|
bodyChunks := chunkText(d.Text, s.scoring.ChunkWords, s.scoring.ChunkOverlap, s.scoring.MaxChunksPerDoc)
|
|
s.mu.Lock()
|
|
s.chunks[d.ID] = bodyChunks
|
|
s.mu.Unlock()
|
|
h := hashDoc(d, s.scoring)
|
|
if cf.Hashes[d.ID] == h && len(cf.TitleVectors[d.ID]) > 0 && len(cf.ChunkVectors[d.ID]) == len(bodyChunks) {
|
|
s.mu.Lock()
|
|
s.titleVectors[d.ID] = append([]float64(nil), cf.TitleVectors[d.ID]...)
|
|
s.chunkVectors[d.ID] = cloneChunkVectors(cf.ChunkVectors[d.ID])
|
|
s.mu.Unlock()
|
|
cacheHits++
|
|
} else {
|
|
need = append(need, d)
|
|
}
|
|
}
|
|
s.setInitStatus(func(st *InitStatus) {
|
|
st.CacheHits = cacheHits
|
|
st.IndexedDocs = cacheHits
|
|
st.PendingEmbeddings = len(need)
|
|
})
|
|
slog.Info("knowledge index prepared", "documents", len(s.docs), "cache_hits", cacheHits, "documents_to_embed", len(need))
|
|
if len(need) == 0 {
|
|
return s.persistVectorCache()
|
|
}
|
|
|
|
// Batch by documents, not by the whole corpus. A corpus with tens of
|
|
// thousands of files can otherwise allocate hundreds of thousands of
|
|
// embedding input strings before the first request is sent to Ollama.
|
|
const docsPerBatch = 20
|
|
embeddedDocs := 0
|
|
for start := 0; start < len(need); start += docsPerBatch {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
end := start + docsPerBatch
|
|
if end > len(need) {
|
|
end = len(need)
|
|
}
|
|
batch := need[start:end]
|
|
embedded, err := s.embedDocuments(ctx, batch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
for _, d := range batch {
|
|
s.titleVectors[d.ID] = embedded[d.ID].title
|
|
s.chunkVectors[d.ID] = embedded[d.ID].chunks
|
|
}
|
|
s.mu.Unlock()
|
|
embeddedDocs += len(batch)
|
|
indexed := cacheHits + embeddedDocs
|
|
pending := len(need) - embeddedDocs
|
|
s.setInitStatus(func(st *InitStatus) {
|
|
st.IndexedDocs = indexed
|
|
st.PendingEmbeddings = pending
|
|
})
|
|
if embeddedDocs%500 == 0 || embeddedDocs == len(need) {
|
|
slog.Info("knowledge embedding progress", "indexed_docs", indexed, "total_docs", len(s.docs), "cache_hits", cacheHits, "pending_embeddings", pending)
|
|
}
|
|
}
|
|
return s.persistVectorCache()
|
|
}
|
|
|
|
type embeddedDoc struct {
|
|
title []float64
|
|
chunks [][]float64
|
|
}
|
|
|
|
func (s *Store) embedDocuments(ctx context.Context, docs []model.KnowledgeDoc) (map[string]embeddedDoc, error) {
|
|
out := make(map[string]embeddedDoc, len(docs))
|
|
type ref struct {
|
|
id string
|
|
title bool
|
|
chunk int
|
|
}
|
|
var texts []string
|
|
var refs []ref
|
|
for _, d := range docs {
|
|
texts = append(texts, formatDocumentEmbedding(d.Title, d.Title, s.scoring.EmbeddingProfile))
|
|
refs = append(refs, ref{id: d.ID, title: true})
|
|
parts := chunkText(d.Text, s.scoring.ChunkWords, s.scoring.ChunkOverlap, s.scoring.MaxChunksPerDoc)
|
|
for i, part := range parts {
|
|
texts = append(texts, formatDocumentEmbedding(d.Title, part, s.scoring.EmbeddingProfile))
|
|
refs = append(refs, ref{id: d.ID, chunk: i})
|
|
}
|
|
}
|
|
vectors, err := s.embedTexts(ctx, texts, s.scoring.EmbedBatchSize)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(vectors) != len(refs) {
|
|
return nil, fmt.Errorf("embedding provider returned %d vectors for %d inputs", len(vectors), len(refs))
|
|
}
|
|
for i, r := range refs {
|
|
if len(vectors[i]) == 0 {
|
|
return nil, fmt.Errorf("embedding provider returned empty vector for %s", r.id)
|
|
}
|
|
e := out[r.id]
|
|
if r.title {
|
|
e.title = vectors[i]
|
|
} else {
|
|
for len(e.chunks) <= r.chunk {
|
|
e.chunks = append(e.chunks, nil)
|
|
}
|
|
e.chunks[r.chunk] = vectors[i]
|
|
}
|
|
out[r.id] = e
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Store) embedTexts(ctx context.Context, texts []string, batch int) ([][]float64, error) {
|
|
if len(texts) == 0 {
|
|
return nil, nil
|
|
}
|
|
if batch <= 0 {
|
|
batch = 64
|
|
}
|
|
out := make([][]float64, 0, len(texts))
|
|
for start := 0; start < len(texts); start += batch {
|
|
end := start + batch
|
|
if end > len(texts) {
|
|
end = len(texts)
|
|
}
|
|
vv, err := s.embedder.Embed(ctx, texts[start:end])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(vv) != end-start {
|
|
return nil, fmt.Errorf("embedding provider returned %d vectors for %d inputs", len(vv), end-start)
|
|
}
|
|
out = append(out, vv...)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func loadCache(path string) cacheFile {
|
|
cf := cacheFile{Version: 3, Hashes: map[string]string{}, TitleVectors: map[string][]float64{}, ChunkVectors: map[string][][]float64{}}
|
|
if b, err := os.ReadFile(path); err == nil {
|
|
_ = json.Unmarshal(b, &cf)
|
|
if cf.Version != 3 {
|
|
cf = cacheFile{Version: 3, Hashes: map[string]string{}, TitleVectors: map[string][]float64{}, ChunkVectors: map[string][][]float64{}}
|
|
}
|
|
}
|
|
if cf.Hashes == nil {
|
|
cf.Hashes = map[string]string{}
|
|
}
|
|
if cf.TitleVectors == nil {
|
|
cf.TitleVectors = map[string][]float64{}
|
|
}
|
|
if cf.ChunkVectors == nil {
|
|
cf.ChunkVectors = map[string][][]float64{}
|
|
}
|
|
return cf
|
|
}
|
|
|
|
func formatQueryEmbeddings(texts []string, profile string) []string {
|
|
out := make([]string, len(texts))
|
|
for i, text := range texts {
|
|
if profile == "embeddinggemma" {
|
|
out[i] = "task: search result | query: " + strings.TrimSpace(text)
|
|
} else {
|
|
out[i] = text
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func formatDocumentEmbedding(title, text, profile string) string {
|
|
if profile == "embeddinggemma" {
|
|
t := strings.TrimSpace(title)
|
|
if t == "" {
|
|
t = "none"
|
|
}
|
|
return "title: " + t + " | text: " + strings.TrimSpace(text)
|
|
}
|
|
return text
|
|
}
|
|
|
|
func splitQueryText(text string) (title, body string) {
|
|
text = strings.TrimSpace(text)
|
|
if text == "" {
|
|
return "", ""
|
|
}
|
|
if i := strings.IndexByte(text, '\n'); i >= 0 {
|
|
title = strings.TrimSpace(text[:i])
|
|
body = strings.TrimSpace(text[i+1:])
|
|
return title, body
|
|
}
|
|
return text, text
|
|
}
|
|
|
|
func chunkText(text string, words, overlap, maxChunks int) []string {
|
|
parts := strings.Fields(strings.TrimSpace(text))
|
|
if len(parts) == 0 {
|
|
return nil
|
|
}
|
|
if words <= 0 {
|
|
words = 160
|
|
}
|
|
if overlap < 0 || overlap >= words {
|
|
overlap = 0
|
|
}
|
|
if maxChunks <= 0 {
|
|
maxChunks = 24
|
|
}
|
|
step := words - overlap
|
|
out := make([]string, 0, minInt(maxChunks, (len(parts)+step-1)/step))
|
|
for start := 0; start < len(parts) && len(out) < maxChunks; start += step {
|
|
end := start + words
|
|
if end > len(parts) {
|
|
end = len(parts)
|
|
}
|
|
out = append(out, strings.Join(parts[start:end], " "))
|
|
if end == len(parts) {
|
|
break
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
type scorePart struct {
|
|
value, weight float64
|
|
available bool
|
|
}
|
|
|
|
func weightedScore(_ ScoringConfig, parts ...scorePart) float64 {
|
|
var sum, weights float64
|
|
for _, p := range parts {
|
|
if !p.available || p.weight <= 0 {
|
|
continue
|
|
}
|
|
sum += clamp01(p.value) * p.weight
|
|
weights += p.weight
|
|
}
|
|
if weights == 0 {
|
|
return 0
|
|
}
|
|
return clamp01(sum / weights)
|
|
}
|
|
|
|
func titleSimilarity(query, title string) float64 {
|
|
q := strings.TrimSpace(query)
|
|
t := strings.TrimSpace(title)
|
|
if q == "" || t == "" {
|
|
return 0
|
|
}
|
|
qn := normalizeText(q)
|
|
tn := normalizeText(t)
|
|
if qn == tn || strings.Contains(tn, qn) || strings.Contains(qn, tn) {
|
|
return 1
|
|
}
|
|
// Title relevance is intentionally asymmetric: if the short ticket subject
|
|
// is fully represented by one of several concepts in a longer KB title, that
|
|
// is a strong title match rather than a low symmetric F1 score.
|
|
return math.Max(tokenCoverage(q, t), tokenF1(q, t))
|
|
}
|
|
|
|
func lexicalSimilarity(query string, d model.KnowledgeDoc) float64 {
|
|
best := math.Max(tokenF1(query, d.Title+" "+d.Text), tokenCoverage(query, d.Title+" "+d.Text))
|
|
if t, _ := splitQueryText(query); strings.TrimSpace(t) != "" {
|
|
best = math.Max(best, tokenCoverage(t, d.Title))
|
|
}
|
|
for _, kw := range d.Keywords {
|
|
best = math.Max(best, phraseCoverage(kw, query))
|
|
}
|
|
return clamp01(best)
|
|
}
|
|
|
|
func keywordSimilarity(query string, keywords []string) (float64, bool) {
|
|
if len(keywords) == 0 {
|
|
return 0, false
|
|
}
|
|
best := 0.0
|
|
for _, kw := range keywords {
|
|
kw = strings.TrimSpace(kw)
|
|
if kw == "" {
|
|
continue
|
|
}
|
|
best = math.Max(best, phraseCoverage(kw, query))
|
|
}
|
|
return clamp01(best), true
|
|
}
|
|
|
|
func categorySimilarity(query string, ids []int64, categories []model.Category) (float64, bool) {
|
|
if len(ids) == 0 || len(categories) == 0 {
|
|
return 0, false
|
|
}
|
|
wanted := make(map[int64]struct{}, len(ids))
|
|
for _, id := range ids {
|
|
wanted[id] = struct{}{}
|
|
}
|
|
best, found := 0.0, false
|
|
for _, c := range categories {
|
|
if _, ok := wanted[c.ID]; !ok {
|
|
continue
|
|
}
|
|
found = true
|
|
for _, part := range append([]string{c.Name, c.CompleteName}, append(c.Hints, c.Examples...)...) {
|
|
if strings.TrimSpace(part) == "" {
|
|
continue
|
|
}
|
|
best = math.Max(best, phraseCoverage(part, query))
|
|
}
|
|
}
|
|
return clamp01(best), found
|
|
}
|
|
|
|
// phraseCoverage asks "how much of this concept phrase occurs in the query?".
|
|
// It is better suited to support terminology than symmetric F1 because a long
|
|
// user ticket may contain lots of harmless extra words.
|
|
func phraseCoverage(phrase, query string) float64 {
|
|
pn := normalizeText(phrase)
|
|
qn := normalizeText(query)
|
|
if pn == "" || qn == "" {
|
|
return 0
|
|
}
|
|
if strings.Contains(qn, pn) {
|
|
return 1
|
|
}
|
|
return tokenCoverage(phrase, query)
|
|
}
|
|
|
|
func tokenCoverage(needle, haystack string) float64 {
|
|
a := tokenList(needle)
|
|
b := tokenList(haystack)
|
|
if len(a) == 0 || len(b) == 0 {
|
|
return 0
|
|
}
|
|
var sum float64
|
|
for _, x := range a {
|
|
best := 0.0
|
|
for _, y := range b {
|
|
best = math.Max(best, tokenSimilarity(x, y))
|
|
}
|
|
sum += best
|
|
}
|
|
return clamp01(sum / float64(len(a)))
|
|
}
|
|
|
|
func tokenSimilarity(a, b string) float64 {
|
|
if a == b {
|
|
return 1
|
|
}
|
|
if len(a) < 4 || len(b) < 4 {
|
|
return 0
|
|
}
|
|
// Helpdesk-German contains many compounds and inflections (anmelden,
|
|
// Anmeldung, Benutzeranmeldung, Nutzerkonto, Benutzerkonto). Exact-token
|
|
// overlap is therefore too brittle. First reward strong substring matches,
|
|
// then compare a deliberately small set of German support stems.
|
|
short, long := a, b
|
|
if len(short) > len(long) {
|
|
short, long = long, short
|
|
}
|
|
if len(short) >= 5 && strings.Contains(long, short) {
|
|
ratio := float64(len(short)) / float64(len(long))
|
|
return clamp01(.75 + .25*ratio)
|
|
}
|
|
sa, sb := supportStem(a), supportStem(b)
|
|
if sa == sb && len(sa) >= 5 {
|
|
return .95
|
|
}
|
|
stemShort, stemLong := sa, sb
|
|
if len(stemShort) > len(stemLong) {
|
|
stemShort, stemLong = stemLong, stemShort
|
|
}
|
|
if len(stemShort) >= 5 && strings.Contains(stemLong, stemShort) {
|
|
return .90
|
|
}
|
|
common := 0
|
|
limit := len(a)
|
|
if len(b) < limit {
|
|
limit = len(b)
|
|
}
|
|
for common < limit && a[common] == b[common] {
|
|
common++
|
|
}
|
|
minLen := len(a)
|
|
if len(b) < minLen {
|
|
minLen = len(b)
|
|
}
|
|
maxLen := len(a)
|
|
if len(b) > maxLen {
|
|
maxLen = len(b)
|
|
}
|
|
if common >= 5 && float64(common)/float64(minLen) >= .70 {
|
|
return clamp01(float64(common) / float64(maxLen))
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func supportStem(s string) string {
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
// Long, semantically common German suffixes first. This is intentionally
|
|
// conservative and is not meant to be a full linguistic stemmer.
|
|
for _, suffix := range []string{"ungen", "ern", "ung", "ieren", "ischen", "ische", "isch", "enden", "ende", "en", "er", "es", "e", "n", "s"} {
|
|
if strings.HasSuffix(s, suffix) && len(s)-len(suffix) >= 5 {
|
|
s = strings.TrimSuffix(s, suffix)
|
|
break
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
func normalizeText(s string) string {
|
|
return strings.Join(tokenList(s), " ")
|
|
}
|
|
|
|
func tokenList(s string) []string {
|
|
parts := strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) })
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
if len([]rune(p)) < 3 || isStopword(p) {
|
|
continue
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func isStopword(s string) bool {
|
|
switch s {
|
|
case "der", "die", "das", "den", "dem", "des", "ein", "eine", "einer", "einem", "einen", "und", "oder", "aber", "mit", "ohne", "für", "fuer", "von", "vom", "zum", "zur", "ist", "sind", "war", "wird", "werden", "ich", "wir", "sie", "seit", "heute", "gestern", "bitte", "hilfe", "vielen", "dank", "nicht", "mehr", "kann", "mich", "mir", "mein", "meine", "meinen", "meinem":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func tokenF1(a, b string) float64 {
|
|
aTok, bTok := tokens(a), tokens(b)
|
|
if len(aTok) == 0 || len(bTok) == 0 {
|
|
return 0
|
|
}
|
|
common := 0
|
|
for t := range aTok {
|
|
if _, ok := bTok[t]; ok {
|
|
common++
|
|
}
|
|
}
|
|
if common == 0 {
|
|
return 0
|
|
}
|
|
precision := float64(common) / float64(len(aTok))
|
|
recall := float64(common) / float64(len(bTok))
|
|
return 2 * precision * recall / (precision + recall)
|
|
}
|
|
|
|
func excerpt(s string, max int) string {
|
|
s = strings.Join(strings.Fields(s), " ")
|
|
if len([]rune(s)) <= max {
|
|
return s
|
|
}
|
|
r := []rune(s)
|
|
return string(r[:max]) + "…"
|
|
}
|
|
func clamp01(v float64) float64 {
|
|
if v < 0 {
|
|
return 0
|
|
}
|
|
if v > 1 {
|
|
return 1
|
|
}
|
|
return v
|
|
}
|
|
func cloneVectorMap(in map[string][]float64) map[string][]float64 {
|
|
out := make(map[string][]float64, len(in))
|
|
for k, v := range in {
|
|
out[k] = append([]float64(nil), v...)
|
|
}
|
|
return out
|
|
}
|
|
func cloneChunkVectorMap(in map[string][][]float64) map[string][][]float64 {
|
|
out := make(map[string][][]float64, len(in))
|
|
for k, v := range in {
|
|
out[k] = cloneChunkVectors(v)
|
|
}
|
|
return out
|
|
}
|
|
func cloneChunkVectors(in [][]float64) [][]float64 {
|
|
out := make([][]float64, len(in))
|
|
for i, v := range in {
|
|
out[i] = append([]float64(nil), v...)
|
|
}
|
|
return out
|
|
}
|
|
func cloneStringSliceMap(in map[string][]string) map[string][]string {
|
|
out := make(map[string][]string, len(in))
|
|
for k, v := range in {
|
|
out[k] = append([]string(nil), v...)
|
|
}
|
|
return out
|
|
}
|
|
func minInt(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func hashDoc(d model.KnowledgeDoc, cfg ScoringConfig) string {
|
|
// Only fields that change the actual embedding input belong in this fingerprint.
|
|
// Category mappings affect deterministic ranking/policy, not the text sent to
|
|
// the embedding model, so remapping categories must not force re-embedding.
|
|
// Formatting-only changes to AnswerHTML must not force re-embedding.
|
|
b, _ := json.Marshal(struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Text string `json:"text"`
|
|
Keywords []string `json:"keywords"`
|
|
Profile string `json:"profile"`
|
|
EmbeddingIdentity string `json:"embedding_identity"`
|
|
ChunkWords int `json:"chunk_words"`
|
|
ChunkOverlap int `json:"chunk_overlap"`
|
|
MaxChunks int `json:"max_chunks"`
|
|
}{d.ID, d.Title, d.Text, d.Keywords, cfg.EmbeddingProfile, cfg.EmbeddingIdentity, cfg.ChunkWords, cfg.ChunkOverlap, cfg.MaxChunksPerDoc})
|
|
h := sha256.Sum256(b)
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
func cosine(a, b []float64) float64 {
|
|
if len(a) == 0 || len(a) != len(b) {
|
|
return 0
|
|
}
|
|
var dot, aa, bb float64
|
|
for i := range a {
|
|
dot += a[i] * b[i]
|
|
aa += a[i] * a[i]
|
|
bb += b[i] * b[i]
|
|
}
|
|
if aa == 0 || bb == 0 {
|
|
return 0
|
|
}
|
|
return dot / (math.Sqrt(aa) * math.Sqrt(bb))
|
|
}
|
|
func lexical(text string, d model.KnowledgeDoc) float64 {
|
|
q := tokens(text)
|
|
hay := tokens(d.Title + " " + d.Text + " " + strings.Join(d.Keywords, " ") + " " + strings.Join(d.ExternalCategories, " "))
|
|
if len(q) == 0 {
|
|
return 0
|
|
}
|
|
hits := 0
|
|
for t := range q {
|
|
if _, ok := hay[t]; ok {
|
|
hits++
|
|
}
|
|
}
|
|
return float64(hits) / float64(len(q))
|
|
}
|
|
func tokens(s string) map[string]struct{} {
|
|
m := map[string]struct{}{}
|
|
for _, p := range tokenList(s) {
|
|
m[p] = struct{}{}
|
|
}
|
|
return m
|
|
}
|