Files
xinmi/internal/application/service/memory/search.go
T
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

128 lines
4.9 KiB
Go

package memory
import (
"context"
"strings"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// searchCandidatePool bounds how many stored items one search ranks over.
//
// It matches the recall pool deliberately. The pool was never what made recall
// miss things — four hundred candidates is more than almost any subject holds
// — the output cap of five items was. Widening the pool here would buy little
// and would put a few thousand embedding reads on a path the user is waiting
// on.
const searchCandidatePool = 400
// MemoryAvailable reports whether this request may read memory at all.
//
// It is deliberately the same predicate SearchMemory itself applies, rather
// than a second reading of the three switches. A caller that decides whether
// to offer a memory feature and the code that answers when it is used must
// never be able to disagree about whether memory is on.
func (s *Service) MemoryAvailable(ctx context.Context) bool {
_, _, ok := s.enabledScope(ctx)
return ok
}
// SearchMemory ranks this user's stored memories against an arbitrary query.
//
// Recall runs once per turn, against the question the user opened with, and
// what it admits is capped hard: five situational items inside a 600-rune
// budget. Two things fall outside that. An agent loop that has spent ten
// iterations moving away from the opening question is now working on something
// that shares no wording with what recall was ranked against; and a subject
// with dozens of stored facts has most of them sitting below the cut with no
// way to reach them. Neither is fixable by enlarging the per-turn budget,
// because that budget is paid on every turn including the ones that need none
// of it.
//
// Only active items are searched. Superseded and archived memories stay out of
// reach on purpose: a statement that a newer one replaced is precisely what
// the supersede machinery exists to keep out of an answer, and surfacing it
// through a side door would undo that.
func (s *Service) SearchMemory(
ctx context.Context, query string, limit int,
) interfaces.MemorySearchResult {
query = strings.TrimSpace(query)
searchCtx, searchSpan := langfuse.GetManager().StartSpan(ctx, langfuse.SpanOptions{
Name: "memory.search",
Input: map[string]interface{}{
"query": langfuse.TruncateRunes(query, recallQueryPreviewRunes),
"limit": limit,
},
})
scope, cfg, ok := s.enabledScope(searchCtx)
if !ok {
reason := s.scopeDisableReason(searchCtx)
logger.Infof(searchCtx, "memory: search skipped (%s)", reason)
searchSpan.Finish(langfuse.SummarizeMemoryRecallOutput(map[string]interface{}{
"outcome": "disabled",
"reason": reason,
}, nil), nil, nil)
return interfaces.MemorySearchResult{}
}
// An empty query reaches here rather than short-circuiting above so that
// "memory is off" still wins over "you asked for nothing": the caller
// needs the disabled answer even when its own arguments were malformed.
if query == "" {
searchSpan.Finish(langfuse.SummarizeMemoryRecallOutput(map[string]interface{}{
"outcome": "empty",
"reason": "blank_query",
}, nil), nil, nil)
return interfaces.MemorySearchResult{Available: true}
}
if limit <= 0 {
limit = types.MemorySearchDefaultItems
}
if limit > types.MemorySearchMaxItems {
limit = types.MemorySearchMaxItems
}
candidates, err := s.repo.ListActiveByKinds(searchCtx, scope, types.MemoryKinds, searchCandidatePool)
if err != nil {
logger.Warnf(searchCtx, "memory: load search candidates failed: %v", err)
searchSpan.Finish(langfuse.SummarizeMemoryRecallOutput(map[string]interface{}{
"outcome": "error",
"error": err.Error(),
}, nil), nil, err)
return interfaces.MemorySearchResult{Available: true}
}
matched, rankTrace := s.selectRecallWithTrace(
searchCtx, scope, cfg, query, candidates, limit, types.MemorySearchRuneBudget)
// A searched memory was read by the model just as surely as an injected
// one, so it counts as used. Without this the items only reachable through
// search would look permanently unused and rank lowest when the capacity
// cap next decides what to archive.
s.touchAsync(searchCtx, scope, matched)
logger.Infof(searchCtx,
"memory: search done subject=%s candidates=%d matched=%d mode=%s",
scope.SubjectID, len(candidates), len(matched), rankTrace.Mode)
searchSpan.Finish(langfuse.SummarizeMemoryRecallOutput(map[string]interface{}{
"outcome": "ok",
"subject_id": scope.SubjectID,
"candidate_count": len(candidates),
"lexical_hits": rankTrace.LexicalHits,
"vector_hits": rankTrace.VectorHits,
"vector_skip": rankTrace.VectorSkipReason,
"ranking_mode": rankTrace.Mode,
"matched_count": len(matched),
}, matched), map[string]interface{}{
"tenant_id": scope.TenantID,
}, nil)
return interfaces.MemorySearchResult{Available: true, Items: matched}
}