Files
silo-server/internal/recommendations/engine.go
QuickandClaude Opus 4.8 13ac4b753c perf(recommendations): split embedding backfill into cheap and text-stale passes
EmbedAll previously ran the full text-staleness CTE on every page, paying
five item_people LATERAL joins per eligible row just to detect whether an
item's canonical text had drifted - even when the real work was embedding
brand-new (missing) items during active backfill.

Restructure EmbedAll into two passes:

- Pass 1 (cheap): drain missing/model-stale items via the repurposed
  ItemsNeedingEmbedding query (single LEFT JOIN, no LATERAL), paged by a
  content_id cursor so a failed/skipped item is retried next run instead of
  stalling the page.
- Pass 2 (expensive): only once Pass 1 fully drains, run one bounded
  ListEmbeddingTextCandidates scan (LIMIT embeddingTextStaleQuotaPerRun=200)
  to re-embed text-drifted items. Re-embedding refreshes canonical_text, so
  handled rows drop out next run - no Pass 2 cursor needed.

Coverage-first tradeoff (documented in EmbedAll): under steady state Pass 1
drains every run so text-stale items stay fresh; only under pathological
continuous heavy ingest does Pass 2 get skipped, deliberately prioritizing
covering new items over re-embedding changed ones.

Supporting changes:
- ItemsNeedingEmbedding gains an afterID cursor + ORDER BY; SQL extracted to
  buildItemsNeedingEmbeddingSQL for a cheap-shape unit test (no item_people /
  LATERAL).
- Add an `embedder` interface seam (Engine.embClient) so EmbedAll is testable
  with a fake; *embeddings.Client still satisfies it.
- Extract embedBatch to DRY both passes, preserving quota/billing early-return,
  single-item fallback, ensureEmbeddingLock-before-upsert, and skip-on-store-error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:02:20 -04:00

132 lines
3.5 KiB
Go

package recommendations
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/recommendations/embeddings"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// embedder is the minimal embedding-client seam the Engine depends on. The
// concrete *embeddings.Client satisfies it; tests substitute a fake so the
// backfill loop (EmbedAll) and query-vector path can run without a real
// embedding API.
type embedder interface {
Embed(ctx context.Context, texts []string) ([][]float32, error)
}
// Engine implements the Recommender interface.
type Engine struct {
repo *Repo
ratingsRepo *catalog.RatingsRepo
itemRepo *catalog.ItemRepository
personRepo *catalog.PersonRepository
storeProvider userstore.UserStoreProvider
signals *SignalReader
embClient embedder
cfg config.RecommendationsConfig
pool *pgxpool.Pool
}
// NewEngine creates a new recommendation Engine.
func NewEngine(
pool *pgxpool.Pool,
ratingsRepo *catalog.RatingsRepo,
itemRepo *catalog.ItemRepository,
personRepo *catalog.PersonRepository,
storeProvider userstore.UserStoreProvider,
cfg config.RecommendationsConfig,
) *Engine {
repo := NewRepo(pool)
embCfg := embeddings.ClientConfig{
BaseURL: cfg.EmbeddingBaseURL,
Model: cfg.EmbeddingModel,
APIKey: cfg.EmbeddingAuthToken,
}
return &Engine{
repo: repo,
ratingsRepo: ratingsRepo,
itemRepo: itemRepo,
personRepo: personRepo,
storeProvider: storeProvider,
signals: NewSignalReader(repo, storeProvider),
embClient: embeddings.NewClient(embCfg),
cfg: cfg,
pool: pool,
}
}
// ActiveEmbeddingModel returns the embedding model currently locked for this
// installation, or "" when no lock is established.
func (e *Engine) ActiveEmbeddingModel(ctx context.Context) (string, error) {
lock, err := e.repo.GetEmbeddingLock(ctx)
if err != nil {
return "", err
}
if lock == nil {
return "", nil
}
return lock.Model, nil
}
func (e *Engine) watchedItemIDSet(ctx context.Context, userID int, profileID string) (map[string]struct{}, error) {
return e.signalReader().WatchedItemIDSet(ctx, userID, profileID)
}
func (e *Engine) signalReader() *SignalReader {
if e.signals != nil {
return e.signals
}
return NewSignalReader(e.repo, e.storeProvider)
}
func (e *Engine) mmrLambda(defaultLambda float64) float64 {
if e == nil {
return defaultLambda
}
if e.cfg.DiversityLambda >= 0 && e.cfg.DiversityLambda <= 1 {
return e.cfg.DiversityLambda
}
return defaultLambda
}
func (e *Engine) profileAccessFilter(ctx context.Context, userID int, profileID string) catalog.AccessFilter {
filter := catalog.AccessFilter{UserID: userID, ProfileID: profileID}
if e == nil || e.storeProvider == nil || profileID == "" {
return filter
}
store, err := e.storeProvider.ForUser(ctx, userID)
if err != nil || store == nil {
return filter
}
profile, err := store.GetProfile(ctx, profileID)
if err != nil || profile == nil {
return filter
}
filter.MaxContentRating = profile.MaxContentRating
if profile.LibraryRestrictionsEnabled {
filter.AllowedLibraryIDs = append([]int(nil), profile.AllowedLibraryIDs...)
}
return filter
}
func scoredItemIDsFromSet(set map[string]struct{}) []string {
if len(set) == 0 {
return nil
}
ids := make([]string, 0, len(set))
for id := range set {
ids = append(ids, id)
}
return ids
}