375 lines
12 KiB
Go
375 lines
12 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
|
||
)
|
||
|
||
// Retriever defaults and tuning constants.
|
||
const (
|
||
// defaultKeywordWeight is the relative weight of keyword (FTS/TF-IDF)
|
||
// relevance in the combined score.
|
||
defaultKeywordWeight = 1.0
|
||
|
||
// defaultSemanticWeight is the relative weight of semantic-vector relevance
|
||
// in the combined score. It only contributes when an embedder is configured.
|
||
defaultSemanticWeight = 1.0
|
||
|
||
// defaultRecencyHalfLifeDays is the number of days over which a memory's
|
||
// recency weight halves, used to favor recently touched memories (design §2
|
||
// attenuation).
|
||
defaultRecencyHalfLifeDays = 30.0
|
||
|
||
// defaultTopK is the number of results returned when Retrieve or Inject is
|
||
// called with a non-positive topK.
|
||
defaultTopK = 10
|
||
|
||
// hoursPerDay converts a day count into a time.Duration.
|
||
hoursPerDay = 24
|
||
)
|
||
|
||
// Result is a single retrieval hit: the memory item plus its combined
|
||
// relevance × decay score explaining its ranking. Higher is more relevant.
|
||
type Result struct {
|
||
Item MemoryItem
|
||
Score float64
|
||
// SourceConversation is the conversation the item was extracted from, for
|
||
// 溯源 (design §2). It mirrors Item.SourceConversation so callers can trace a
|
||
// hit without reaching into Item.
|
||
SourceConversation string
|
||
// Project is the linked project-index entry (设计 §3 项目分层), populated only
|
||
// when the retriever is wired with a project-index store and the item is a
|
||
// project-scoped memory carrying a ProjectID. It lets callers surface the
|
||
// 项目索引 → 项目描述 → 章节内容 chain for a retrieved item.
|
||
Project *projectindex.Project
|
||
}
|
||
|
||
// Retriever ranks a persona's long-term memories by relevance (keyword and,
|
||
// when an embedder or vector index is configured, semantic vectors) weighted by
|
||
// decay (decay_weight × time decay), then returns the top K. It is stateless and
|
||
// safe for concurrent use; the ranking is recomputed from the store on every
|
||
// call, so results always reflect persisted state.
|
||
type Retriever struct {
|
||
store *Store
|
||
embedder embedding.Embedder
|
||
vindex vector.Index
|
||
projectStore *projectindex.Store
|
||
keywordWeight float64
|
||
semanticWeight float64
|
||
recencyHalfLife time.Duration
|
||
}
|
||
|
||
// RetrieverOption customizes a Retriever built by NewRetriever.
|
||
type RetrieverOption func(*Retriever)
|
||
|
||
// WithEmbedder sets the semantic embedder used for vector similarity computed
|
||
// in-memory. When nil (the default), retrieval falls back to keyword matching
|
||
// plus decay only. When a vector index is also wired via WithVectorIndex, the
|
||
// index takes precedence: it is the persistent semantic path (design §3
|
||
// 可选增强), and WithEmbedder computes similarity on the fly instead.
|
||
func WithEmbedder(e embedding.Embedder) RetrieverOption {
|
||
return func(r *Retriever) {
|
||
r.embedder = e
|
||
}
|
||
}
|
||
|
||
// WithVectorIndex wires a persistent vector.Index into retrieval. When set,
|
||
// semantic relevance is answered by idx.Search over the stored vectors rather
|
||
// than by in-memory embedding, so a configured pgvector/SQLite vector store is
|
||
// genuinely used (design §3 "真正启用向量检索"). When nil (the default), the
|
||
// semantic path is skipped unless WithEmbedder is set. The caller is responsible
|
||
// for keeping the index in sync with stored items (embed + add on write).
|
||
func WithVectorIndex(idx vector.Index) RetrieverOption {
|
||
return func(r *Retriever) {
|
||
r.vindex = idx
|
||
}
|
||
}
|
||
|
||
// WithProjectIndex wires a projectindex.Store so retrieval can attach the linked
|
||
// project-index entry (设计 §3 项目分层) to each project-scoped hit. When nil
|
||
// (the default), Result.Project is left unset.
|
||
func WithProjectIndex(s *projectindex.Store) RetrieverOption {
|
||
return func(r *Retriever) {
|
||
r.projectStore = s
|
||
}
|
||
}
|
||
|
||
// WithKeywordWeight sets the relative weight of keyword relevance. Non-positive
|
||
// values are ignored and the default kept.
|
||
func WithKeywordWeight(w float64) RetrieverOption {
|
||
return func(r *Retriever) {
|
||
if w > 0 {
|
||
r.keywordWeight = w
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithSemanticWeight sets the relative weight of semantic relevance. Non-positive
|
||
// values are ignored and the default kept.
|
||
func WithSemanticWeight(w float64) RetrieverOption {
|
||
return func(r *Retriever) {
|
||
if w > 0 {
|
||
r.semanticWeight = w
|
||
}
|
||
}
|
||
}
|
||
|
||
// WithRecencyHalfLife sets how many days a memory's recency weight takes to
|
||
// halve. Non-positive values are ignored and the default kept.
|
||
func WithRecencyHalfLife(days float64) RetrieverOption {
|
||
return func(r *Retriever) {
|
||
if days > 0 {
|
||
r.recencyHalfLife = time.Duration(days * float64(hoursPerDay*time.Hour))
|
||
}
|
||
}
|
||
}
|
||
|
||
// NewRetriever builds a Retriever over store. A nil store is invalid and panics
|
||
// at call time; callers should pass a non-nil store.
|
||
func NewRetriever(store *Store, opts ...RetrieverOption) *Retriever {
|
||
r := &Retriever{
|
||
store: store,
|
||
keywordWeight: defaultKeywordWeight,
|
||
semanticWeight: defaultSemanticWeight,
|
||
recencyHalfLife: time.Duration(defaultRecencyHalfLifeDays * float64(hoursPerDay*time.Hour)),
|
||
}
|
||
for _, opt := range opts {
|
||
opt(r)
|
||
}
|
||
return r
|
||
}
|
||
|
||
// Retrieve returns up to topK items for personaID that best match query, ranked
|
||
// by relevance (keyword + optional semantic) weighted by decay (decay_weight ×
|
||
// time decay). Query and candidate contents are both read under ctx; a canceled
|
||
// context aborts the work. A blank query returns the most recently used items
|
||
// (decay order only).
|
||
func (r *Retriever) Retrieve(ctx context.Context, personaID, query string, topK int) ([]Result, error) {
|
||
if topK <= 0 {
|
||
topK = defaultTopK
|
||
}
|
||
items, err := r.store.ListByPersona(ctx, personaID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("memory: retrieve list: %w", err)
|
||
}
|
||
if len(items) == 0 {
|
||
return []Result{}, nil
|
||
}
|
||
|
||
kw := r.keywordScores(items, query)
|
||
sem := r.semanticScores(ctx, items, query)
|
||
|
||
now := time.Now().UTC()
|
||
// matched is true when at least one item shares relevance with the query. In
|
||
// that mode relevance dominates ranking and recency only attenuates among
|
||
// matches; unmatched items get score 0 and sort below. When nothing matches
|
||
// (e.g. a blank query), we rank by recency alone.
|
||
matched := false
|
||
relevance := make(map[string]float64, len(items))
|
||
for _, it := range items {
|
||
rel := kw[it.ID] + sem[it.ID]
|
||
relevance[it.ID] = rel
|
||
if rel > 0 {
|
||
matched = true
|
||
}
|
||
}
|
||
|
||
results := make([]Result, 0, len(items))
|
||
for _, it := range items {
|
||
decay := r.decayFactor(it, now)
|
||
score := decay
|
||
if matched {
|
||
score = relevance[it.ID] * decay
|
||
}
|
||
res := Result{
|
||
Item: it,
|
||
Score: score,
|
||
SourceConversation: it.SourceConversation,
|
||
Project: r.projectFor(ctx, it),
|
||
}
|
||
results = append(results, res)
|
||
}
|
||
|
||
sort.SliceStable(results, func(i, j int) bool {
|
||
if results[i].Score != results[j].Score {
|
||
return results[i].Score > results[j].Score
|
||
}
|
||
if !results[i].Item.UpdatedAt.Equal(results[j].Item.UpdatedAt) {
|
||
return results[i].Item.UpdatedAt.After(results[j].Item.UpdatedAt)
|
||
}
|
||
return results[i].Item.ID < results[j].Item.ID
|
||
})
|
||
|
||
if len(results) > topK {
|
||
results = results[:topK]
|
||
}
|
||
return results, nil
|
||
}
|
||
|
||
// keywordScores scores each item against query with an in-memory TF-IDF inverted
|
||
// index (embedding.Index), reusing the embedding package's tokenizer. Scores are
|
||
// normalized to [0,1] by the maximum so they are comparable with the semantic
|
||
// scores.
|
||
func (r *Retriever) keywordScores(items []MemoryItem, query string) map[string]float64 {
|
||
scores := make(map[string]float64, len(items))
|
||
index := embedding.NewIndex[string]()
|
||
for _, it := range items {
|
||
index.Add(keywordText(it), it.ID)
|
||
}
|
||
hits := index.Search(query)
|
||
if len(hits) == 0 {
|
||
return scores
|
||
}
|
||
max := hits[0].Score
|
||
if max <= 0 {
|
||
return scores
|
||
}
|
||
for _, h := range hits {
|
||
scores[h.ID] = h.Score / max
|
||
}
|
||
return scores
|
||
}
|
||
|
||
// semanticScores computes per-item semantic relevance for query. When a vector
|
||
// index is wired (WithVectorIndex) it answers from the persistent index via
|
||
// idx.Search — the real vector path (design §3). Otherwise, with an embedder
|
||
// configured, it embeds query and candidates in-memory and uses clamped cosine
|
||
// similarity. With neither it returns an empty map, so semantic relevance
|
||
// contributes nothing.
|
||
func (r *Retriever) semanticScores(ctx context.Context, items []MemoryItem, query string) map[string]float64 {
|
||
scores := make(map[string]float64, len(items))
|
||
if r.vindex != nil {
|
||
return r.vectorIndexScores(ctx, items, query)
|
||
}
|
||
if r.embedder == nil {
|
||
return scores
|
||
}
|
||
|
||
texts := make([]string, len(items))
|
||
for i, it := range items {
|
||
texts[i] = it.Content
|
||
}
|
||
vectors, err := r.embedder.Embed(ctx, texts)
|
||
if err != nil {
|
||
return scores
|
||
}
|
||
queryVectors, err := r.embedder.Embed(ctx, []string{query})
|
||
if err != nil || len(queryVectors) == 0 {
|
||
return scores
|
||
}
|
||
qvec := queryVectors[0]
|
||
for i, vec := range vectors {
|
||
if i >= len(items) {
|
||
break
|
||
}
|
||
sim := cosineSim(qvec, vec)
|
||
if sim > 0 {
|
||
scores[items[i].ID] = sim
|
||
}
|
||
}
|
||
return scores
|
||
}
|
||
|
||
// vectorIndexScores answers semantic relevance from the persistent vector index:
|
||
// it embeds the query with the index, searches for the top similar ids, and
|
||
// returns their cosine similarity, normalized to [0,1] by the top hit. Because
|
||
// idx.Search ranks over all stored ids, an item absent from the index carries no
|
||
// semantic score. A blank query or an index error degrades to an empty map.
|
||
func (r *Retriever) vectorIndexScores(ctx context.Context, items []MemoryItem, query string) map[string]float64 {
|
||
scores := make(map[string]float64, len(items))
|
||
if strings.TrimSpace(query) == "" {
|
||
return scores
|
||
}
|
||
qvecs, err := r.vindex.Embed(ctx, []string{query})
|
||
if err != nil || len(qvecs) == 0 {
|
||
return scores
|
||
}
|
||
hits, err := r.vindex.Search(ctx, qvecs[0], len(items))
|
||
if err != nil || len(hits) == 0 {
|
||
return scores
|
||
}
|
||
max := hits[0].Score
|
||
for _, h := range hits {
|
||
if max > 0 {
|
||
scores[h.ID] = h.Score / max
|
||
} else {
|
||
scores[h.ID] = h.Score
|
||
}
|
||
}
|
||
return scores
|
||
}
|
||
|
||
// projectFor returns the project-index entry linked to a project-scoped item, or
|
||
// nil when the retriever has no project-index store, the item is not
|
||
// project-scoped, or it carries no ProjectID. A load failure is treated as "no
|
||
// project" rather than failing the whole retrieval (design §3 项目索引各角色可查).
|
||
func (r *Retriever) projectFor(ctx context.Context, it MemoryItem) *projectindex.Project {
|
||
if r.projectStore == nil || it.Scope != ScopeProject || strings.TrimSpace(it.ProjectID) == "" {
|
||
return nil
|
||
}
|
||
p, err := r.projectStore.GetProject(ctx, it.ProjectID)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
return p
|
||
}
|
||
|
||
// keywordText is the text indexed for keyword matching: content plus tags.
|
||
func keywordText(it MemoryItem) string {
|
||
if len(it.Tags) == 0 {
|
||
return it.Content
|
||
}
|
||
return it.Content + " " + strings.Join(it.Tags, " ")
|
||
}
|
||
|
||
// cosineSim computes the cosine similarity of two vectors, clamped to [0,1] by
|
||
// the caller. Zero-length or mismatched vectors yield 0.
|
||
func cosineSim(a, b embedding.Vector) float64 {
|
||
if len(a) == 0 || len(b) == 0 || len(a) != len(b) {
|
||
return 0
|
||
}
|
||
var dot, na, nb float64
|
||
for i := range a {
|
||
dot += float64(a[i]) * float64(b[i])
|
||
na += float64(a[i]) * float64(a[i])
|
||
nb += float64(b[i]) * float64(b[i])
|
||
}
|
||
if na == 0 || nb == 0 {
|
||
return 0
|
||
}
|
||
return dot / (math.Sqrt(na) * math.Sqrt(nb))
|
||
}
|
||
|
||
// Inject retrieves up to topK relevant memories for personaID and formats them
|
||
// as a compact context block ready to be injected into the prompt. It returns an
|
||
// empty string when nothing is retrieved, so callers can ignore the result when
|
||
// there is no relevant memory.
|
||
func (r *Retriever) Inject(ctx context.Context, personaID, query string, topK int) (string, error) {
|
||
results, err := r.Retrieve(ctx, personaID, query, topK)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if len(results) == 0 {
|
||
return "", nil
|
||
}
|
||
|
||
var b strings.Builder
|
||
b.WriteString("相关记忆(按相关度 × 衰减排序):")
|
||
for _, res := range results {
|
||
b.WriteByte('\n')
|
||
b.WriteString("- [")
|
||
b.WriteString(string(res.Item.Kind))
|
||
b.WriteString("] ")
|
||
b.WriteString(res.Item.Content)
|
||
}
|
||
return b.String(), nil
|
||
}
|