From 320f4027ec417f225bad32917b25bcf5b7e33eb0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Jul 2026 13:42:29 +0000 Subject: [PATCH 1/5] Expose post timestamps to the LLM in semantic search results Co-authored-by: Christopher Speller --- api/api_search.go | 2 ++ format/format.go | 13 +++++++++++-- format/format_test.go | 30 +++++++++++++++++++++++++++++ llm/prompts.go | 3 +++ mcpserver/tools/search.go | 1 + mcpserver/tools/search_http.go | 2 ++ mcpserver/tools/search_http_test.go | 3 ++- prompts/search_results.tmpl | 2 +- prompts/search_system.tmpl | 1 + search/search.go | 2 ++ search/search_test.go | 27 ++++++++++++++++++++++++++ 11 files changed, 82 insertions(+), 4 deletions(-) diff --git a/api/api_search.go b/api/api_search.go index 59bcec040..48a0b6266 100644 --- a/api/api_search.go +++ b/api/api_search.go @@ -137,6 +137,7 @@ type RawSearchResult struct { Username string `json:"username"` Content string `json:"content"` Score float32 `json:"score"` + CreateAt int64 `json:"create_at"` // Post creation timestamp (Unix millis) } // RawSearchResponse represents the response body for the raw semantic search endpoint @@ -220,6 +221,7 @@ func (a *API) handleRawSearch(c *gin.Context) { Username: r.Username, Content: r.Content, Score: r.Score, + CreateAt: r.CreateAt, }) } diff --git a/format/format.go b/format/format.go index 4ca9ec790..17503149b 100644 --- a/format/format.go +++ b/format/format.go @@ -104,6 +104,16 @@ func AuthoredPost(post *model.Post, username string) string { return "@" + username + ": " + PostBody(post) } +// TimeFromMillis formats a Unix-milliseconds timestamp as RFC3339 UTC for +// LLM consumption. Returns "" for non-positive values so callers/templates +// can omit the attribute when the timestamp is unknown. +func TimeFromMillis(millis int64) string { + if millis <= 0 { + return "" + } + return time.UnixMilli(millis).UTC().Format(time.RFC3339) +} + // PostEntry holds pre-resolved data for formatting a single post. // Used by MCP tools and other callers that need structured post output. type PostEntry struct { @@ -165,8 +175,7 @@ func WritePost(w *strings.Builder, entry PostEntry) { // Timestamp (only when available) if entry.Post.CreateAt > 0 { - t := time.Unix(entry.Post.CreateAt/1000, (entry.Post.CreateAt%1000)*int64(time.Millisecond)) - fmt.Fprintf(w, "Time: %s\n", t.UTC().Format(time.RFC3339)) + fmt.Fprintf(w, "Time: %s\n", TimeFromMillis(entry.Post.CreateAt)) } fmt.Fprintf(w, "Message: %s\n\n", PostBody(entry.Post)) diff --git a/format/format_test.go b/format/format_test.go index 3bbdd54a3..80f7af422 100644 --- a/format/format_test.go +++ b/format/format_test.go @@ -728,6 +728,36 @@ func TestWritePostInfo(t *testing.T) { assert.Contains(t, out, "You are a member of this team: false") } +func TestTimeFromMillis(t *testing.T) { + tests := []struct { + name string + millis int64 + expected string + }{ + { + name: "formats as RFC3339 UTC", + millis: 1704067200000, + expected: "2024-01-01T00:00:00Z", + }, + { + name: "zero returns empty string", + millis: 0, + expected: "", + }, + { + name: "negative returns empty string", + millis: -1, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, TimeFromMillis(tt.millis)) + }) + } +} + func TestWriteScheduledPost(t *testing.T) { sp := &model.ScheduledPost{ Draft: model.Draft{ChannelId: "chan12345678901234567890ab", Message: "scheduled hello"}, diff --git a/llm/prompts.go b/llm/prompts.go index 9ad4e1daa..749b984ee 100644 --- a/llm/prompts.go +++ b/llm/prompts.go @@ -9,6 +9,8 @@ import ( "io/fs" "strings" "text/template" + + "github.com/mattermost/mattermost-plugin-agents/v2/format" ) type Prompts struct { @@ -28,6 +30,7 @@ func EscapePromptContent(s string) string { func NewPrompts(input fs.FS) (*Prompts, error) { funcMap := template.FuncMap{ "escapeContent": EscapePromptContent, + "formatTime": format.TimeFromMillis, } templates, err := template.New("").Funcs(funcMap).ParseFS(input, "*.tmpl") if err != nil { diff --git a/mcpserver/tools/search.go b/mcpserver/tools/search.go index df6016918..f2cd1775c 100644 --- a/mcpserver/tools/search.go +++ b/mcpserver/tools/search.go @@ -290,6 +290,7 @@ func (p *MattermostToolProvider) executeSemanticSearch(ctx context.Context, clie ChannelId: r.ChannelID, UserId: r.UserID, Message: r.Content, + CreateAt: r.CreateAt, }, ChannelName: r.ChannelName, TeamName: channelTeamCache[r.ChannelID], diff --git a/mcpserver/tools/search_http.go b/mcpserver/tools/search_http.go index 6c0474cc2..de849b2cb 100644 --- a/mcpserver/tools/search_http.go +++ b/mcpserver/tools/search_http.go @@ -54,6 +54,7 @@ type httpSearchResult struct { Username string `json:"username"` Content string `json:"content"` Score float32 `json:"score"` + CreateAt int64 `json:"create_at"` // Post creation timestamp (Unix millis) } // httpSearchResponse represents the response from the plugin endpoint @@ -104,6 +105,7 @@ func (s *HTTPSemanticSearchService) Search(ctx context.Context, query string, op Username: r.Username, Content: r.Content, Score: r.Score, + CreateAt: r.CreateAt, }) } diff --git a/mcpserver/tools/search_http_test.go b/mcpserver/tools/search_http_test.go index e1bcea206..938c79b55 100644 --- a/mcpserver/tools/search_http_test.go +++ b/mcpserver/tools/search_http_test.go @@ -35,7 +35,7 @@ func TestHTTPSemanticSearchService_Search(t *testing.T) { serverHandler: func(w http.ResponseWriter, r *http.Request) { resp := httpSearchResponse{ Results: []httpSearchResult{ - {PostID: "p1", ChannelID: "c1", ChannelName: "General", UserID: "u1", Username: "alice", Content: "hello world", Score: 0.95}, + {PostID: "p1", ChannelID: "c1", ChannelName: "General", UserID: "u1", Username: "alice", Content: "hello world", Score: 0.95, CreateAt: 1700000000000}, {PostID: "p2", ChannelID: "c2", ChannelName: "Dev", UserID: "u2", Username: "bob", Content: "test post", Score: 0.80}, }, } @@ -48,6 +48,7 @@ func TestHTTPSemanticSearchService_Search(t *testing.T) { assert.Equal(t, "p1", results[0].PostID) assert.Equal(t, "alice", results[0].Username) assert.InDelta(t, 0.95, float64(results[0].Score), 0.01) + assert.Equal(t, int64(1700000000000), results[0].CreateAt) assert.Equal(t, "p2", results[1].PostID) }, }, diff --git a/prompts/search_results.tmpl b/prompts/search_results.tmpl index 908bfd713..1a2f5fc19 100644 --- a/prompts/search_results.tmpl +++ b/prompts/search_results.tmpl @@ -1,4 +1,4 @@ -{{range .Parameters.Results}} +{{range .Parameters.Results}} {{escapeContent .Content}} diff --git a/prompts/search_system.tmpl b/prompts/search_system.tmpl index 342987963..9ff8fb125 100644 --- a/prompts/search_system.tmpl +++ b/prompts/search_system.tmpl @@ -8,6 +8,7 @@ Follow these guidelines: {{template "citation_format.tmpl" .}} 5. If the question is ambiguous, interpret it reasonably based on the context. 6. Do not hallucinate information not present in the context. +7. Each message has a time attribute with its creation timestamp. When messages contain conflicting or outdated information, prefer the more recent messages, and mention when relevant information may be outdated. {{template "search_results.tmpl" .}} diff --git a/search/search.go b/search/search.go index fb3a400d0..3e54d64a4 100644 --- a/search/search.go +++ b/search/search.go @@ -52,6 +52,7 @@ type RAGResult struct { Username string `json:"username"` Content string `json:"content"` Score float32 `json:"score"` + CreateAt int64 `json:"createAt"` // Post creation timestamp (Unix millis) } // Options configures a search operation @@ -174,6 +175,7 @@ func (s *Search) enrichResults(searchResults []embeddings.SearchResult) []RAGRes Username: username, Content: content, Score: result.Score, + CreateAt: result.Document.CreateAt, }) } diff --git a/search/search_test.go b/search/search_test.go index 43a039fc2..1bdfaab49 100644 --- a/search/search_test.go +++ b/search/search_test.go @@ -233,6 +233,7 @@ func TestEnrichResults(t *testing.T) { { Document: embeddings.PostDocument{ PostID: "post1", + CreateAt: 1700000000000, ChannelID: "channel1", UserID: "user1", Content: "content 1", @@ -242,6 +243,7 @@ func TestEnrichResults(t *testing.T) { { Document: embeddings.PostDocument{ PostID: "post2", + CreateAt: 1700000060000, ChannelID: "channel2", UserID: "user2", Content: "content 2", @@ -274,9 +276,11 @@ func TestEnrichResults(t *testing.T) { require.Equal(t, "post1", results[0].PostID) require.Equal(t, "Channel One", results[0].ChannelName) require.Equal(t, "user_one", results[0].Username) + require.Equal(t, int64(1700000000000), results[0].CreateAt) require.Equal(t, "post2", results[1].PostID) require.Equal(t, "Channel Two", results[1].ChannelName) require.Equal(t, "user_two", results[1].Username) + require.Equal(t, int64(1700000060000), results[1].CreateAt) }, }, } @@ -479,6 +483,29 @@ func TestBuildPrompt(t *testing.T) { require.Contains(t, req.Posts[0].Message, "important information") }, }, + { + name: "system message contains message timestamps", + query: "search query", + results: []RAGResult{ + // 2024-01-01T00:00:00Z + {PostID: "post1", Content: "dated content", ChannelName: "General", Username: "testuser", Score: 0.95, CreateAt: 1704067200000}, + }, + expectError: false, + validate: func(t *testing.T, req llm.CompletionRequest) { + require.Contains(t, req.Posts[0].Message, `time="2024-01-01T00:00:00Z"`) + }, + }, + { + name: "missing timestamp omits the time attribute", + query: "search query", + results: []RAGResult{ + {PostID: "post1", Content: "undated content", ChannelName: "General", Username: "testuser", Score: 0.95}, + }, + expectError: false, + validate: func(t *testing.T, req llm.CompletionRequest) { + require.NotContains(t, req.Posts[0].Message, "time=") + }, + }, } for _, tc := range tests { From 95057a52e0f385dff4d186526b2a4c7bcb791f7d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Jul 2026 13:42:30 +0000 Subject: [PATCH 2/5] Add recency bias reranking to semantic search Co-authored-by: Christopher Speller --- embeddings/composite.go | 30 +- embeddings/composite_test.go | 133 +++++++- embeddings/embeddings.go | 22 ++ embeddings/integration_test.go | 2 +- embeddings/recency.go | 101 ++++++ embeddings/recency_test.go | 292 ++++++++++++++++++ mcpserver/eval_helpers_test.go | 2 +- search/embeddings.go | 2 +- search/search_eval_test.go | 2 +- .../embedding_search_panel.tsx | 46 ++- .../system_console/embedding_search/types.tsx | 10 + webapp/src/i18n/en.json | 6 + 12 files changed, 633 insertions(+), 15 deletions(-) create mode 100644 embeddings/recency.go create mode 100644 embeddings/recency_test.go diff --git a/embeddings/composite.go b/embeddings/composite.go index 2090cc3f8..51a228b44 100644 --- a/embeddings/composite.go +++ b/embeddings/composite.go @@ -6,6 +6,7 @@ package embeddings import ( "context" "fmt" + "time" "github.com/mattermost/mattermost-plugin-agents/v2/chunking" ) @@ -15,14 +16,16 @@ type CompositeSearch struct { store VectorStore provider EmbeddingProvider options chunking.Options + recency RecencyBiasSettings } // NewCompositeSearch creates a new CompositeSearch with required chunking options -func NewCompositeSearch(store VectorStore, provider EmbeddingProvider, options chunking.Options) *CompositeSearch { +func NewCompositeSearch(store VectorStore, provider EmbeddingProvider, options chunking.Options, recency RecencyBiasSettings) *CompositeSearch { return &CompositeSearch{ store: store, provider: provider, options: options, + recency: recency, } } @@ -77,12 +80,33 @@ func (c *CompositeSearch) Search(ctx context.Context, query string, opts SearchO return nil, err } - // Search for matching chunks - results, err := c.store.Search(ctx, embedding, opts) + if !c.recency.Enabled { + return c.store.Search(ctx, embedding, opts) + } + + // Recency bias: over-fetch candidates by pure similarity (keeps the ANN + // index usable), rerank in memory with the time decay, then apply the + // caller's offset/limit window to the reranked order. + fetchOpts := opts + fetchOpts.Offset = 0 + fetchOpts.Limit = recencyFetchLimit(opts.Limit, opts.Offset) + + results, err := c.store.Search(ctx, embedding, fetchOpts) if err != nil { return nil, err } + rerankByRecency(results, time.Now().UnixMilli(), c.recency) + + if opts.Offset > 0 { + if opts.Offset >= len(results) { + return nil, nil + } + results = results[opts.Offset:] + } + if opts.Limit > 0 && len(results) > opts.Limit { + results = results[:opts.Limit] + } return results, nil } diff --git a/embeddings/composite_test.go b/embeddings/composite_test.go index 13f162b74..6c21b218c 100644 --- a/embeddings/composite_test.go +++ b/embeddings/composite_test.go @@ -7,6 +7,7 @@ import ( "context" "errors" "testing" + "time" "github.com/mattermost/mattermost-plugin-agents/v2/chunking" "github.com/stretchr/testify/assert" @@ -277,7 +278,7 @@ func TestCompositeSearch_Store(t *testing.T) { store := &stubVectorStore{storeFunc: tt.storeFunc} provider := &stubEmbeddingProvider{batchCreateEmbeddingsFunc: tt.batchEmbedFunc} - cs := NewCompositeSearch(store, provider, tt.options) + cs := NewCompositeSearch(store, provider, tt.options, RecencyBiasSettings{}) err := cs.Store(context.Background(), tt.docs) @@ -420,7 +421,7 @@ func TestCompositeSearch_Search(t *testing.T) { store := &stubVectorStore{searchFunc: tt.searchFunc} provider := &stubEmbeddingProvider{createEmbeddingFunc: tt.createEmbedFunc} - cs := NewCompositeSearch(store, provider, defaultOptions) + cs := NewCompositeSearch(store, provider, defaultOptions, RecencyBiasSettings{}) results, err := cs.Search(context.Background(), tt.query, tt.searchOpts) @@ -441,6 +442,126 @@ func TestCompositeSearch_Search(t *testing.T) { } } +func TestCompositeSearch_RecencyBias(t *testing.T) { + defaultOptions := chunking.Options{ + ChunkSize: 1000, + ChunkOverlap: 200, + ChunkingStrategy: "sentences", + } + enabled := RecencyBiasSettings{Enabled: true, HalfLifeDays: 7, Floor: 0.7} + + now := time.Now().UnixMilli() + createAtDaysAgo := func(days int64) int64 { return now - days*millisPerDay } + + // Raw similarity order: old-strong, fresh-mid, fresh-weak. + // Adjusted (half-life 7d, floor 0.7): old-strong 0.80*~0.70=~0.56, + // fresh-mid ~0.78, fresh-weak ~0.60 -> fresh-mid, fresh-weak, old-strong. + defaultStoreResults := func() []SearchResult { + return []SearchResult{ + {Document: PostDocument{PostID: "old-strong", CreateAt: createAtDaysAgo(60)}, Score: 0.80}, + {Document: PostDocument{PostID: "fresh-mid", CreateAt: createAtDaysAgo(0)}, Score: 0.78}, + {Document: PostDocument{PostID: "fresh-weak", CreateAt: createAtDaysAgo(0)}, Score: 0.60}, + } + } + + tests := []struct { + name string + recency RecencyBiasSettings + searchOpts SearchOptions + storeResults func() []SearchResult + expectedFetchLimit int + expectedFetchOffset int + expectedOrder []string + }{ + { + name: "disabled passes options through and preserves store order", + recency: RecencyBiasSettings{}, + searchOpts: SearchOptions{Limit: 5, Offset: 2}, + storeResults: defaultStoreResults, + expectedFetchLimit: 5, + expectedFetchOffset: 2, + expectedOrder: []string{"old-strong", "fresh-mid", "fresh-weak"}, + }, + { + name: "over-fetches, reranks by recency, truncates to limit", + recency: enabled, + searchOpts: SearchOptions{Limit: 2}, + storeResults: defaultStoreResults, + expectedFetchLimit: recencyMinCandidates, + expectedFetchOffset: 0, + expectedOrder: []string{"fresh-mid", "fresh-weak"}, + }, + { + name: "floor keeps old canonical answer above weak fresh result", + recency: enabled, + searchOpts: SearchOptions{Limit: 2}, + storeResults: func() []SearchResult { + return []SearchResult{ + {Document: PostDocument{PostID: "old-canonical", CreateAt: createAtDaysAgo(365)}, Score: 0.90}, + {Document: PostDocument{PostID: "weak-fresh", CreateAt: createAtDaysAgo(0)}, Score: 0.50}, + } + }, + expectedFetchLimit: recencyMinCandidates, + expectedFetchOffset: 0, + expectedOrder: []string{"old-canonical", "weak-fresh"}, + }, + { + name: "offset applies to the reranked order", + recency: enabled, + searchOpts: SearchOptions{Limit: 1, Offset: 1}, + storeResults: defaultStoreResults, + expectedFetchLimit: recencyMinCandidates + 1, + expectedFetchOffset: 0, + expectedOrder: []string{"fresh-weak"}, + }, + { + name: "offset beyond candidates returns no results", + recency: enabled, + searchOpts: SearchOptions{Limit: 5, Offset: 10}, + storeResults: defaultStoreResults, + expectedFetchLimit: recencyMinCandidates + 10, + expectedFetchOffset: 0, + expectedOrder: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rawScores := make(map[string]float32) + for _, r := range tt.storeResults() { + rawScores[r.Document.PostID] = r.Score + } + + store := &stubVectorStore{ + searchFunc: func(ctx context.Context, embedding []float32, opts SearchOptions) ([]SearchResult, error) { + return tt.storeResults(), nil + }, + } + provider := &stubEmbeddingProvider{} + cs := NewCompositeSearch(store, provider, defaultOptions, tt.recency) + + results, err := cs.Search(context.Background(), "query", tt.searchOpts) + require.NoError(t, err) + + require.Len(t, store.searchCalls, 1) + assert.Equal(t, tt.expectedFetchLimit, store.searchCalls[0].opts.Limit, "store fetch limit") + assert.Equal(t, tt.expectedFetchOffset, store.searchCalls[0].opts.Offset, "store fetch offset") + + order := make([]string, 0, len(results)) + for _, r := range results { + order = append(order, r.Document.PostID) + } + assert.Equal(t, tt.expectedOrder, order) + + // The returned Score stays the raw similarity; recency only reorders. + for _, r := range results { + assert.Equal(t, rawScores[r.Document.PostID], r.Score, + "raw score of %s must be preserved", r.Document.PostID) + } + }) + } +} + func TestCompositeSearch_Delete(t *testing.T) { defaultOptions := chunking.Options{ ChunkSize: 1000, @@ -509,7 +630,7 @@ func TestCompositeSearch_Delete(t *testing.T) { store := &stubVectorStore{deleteFunc: tt.deleteFunc} provider := &stubEmbeddingProvider{} - cs := NewCompositeSearch(store, provider, defaultOptions) + cs := NewCompositeSearch(store, provider, defaultOptions, RecencyBiasSettings{}) err := cs.Delete(context.Background(), tt.postIDs) @@ -575,7 +696,7 @@ func TestCompositeSearch_Clear(t *testing.T) { store := &stubVectorStore{clearFunc: tt.clearFunc} provider := &stubEmbeddingProvider{} - cs := NewCompositeSearch(store, provider, defaultOptions) + cs := NewCompositeSearch(store, provider, defaultOptions, RecencyBiasSettings{}) err := cs.Clear(context.Background()) @@ -616,7 +737,7 @@ func TestCompositeSearch_ContextCancellation(t *testing.T) { }, } - cs := NewCompositeSearch(store, provider, defaultOptions) + cs := NewCompositeSearch(store, provider, defaultOptions, RecencyBiasSettings{}) ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately @@ -641,7 +762,7 @@ func TestCompositeSearch_ContextCancellation(t *testing.T) { }, } - cs := NewCompositeSearch(store, provider, defaultOptions) + cs := NewCompositeSearch(store, provider, defaultOptions, RecencyBiasSettings{}) ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately diff --git a/embeddings/embeddings.go b/embeddings/embeddings.go index a4d5e791a..8ab640fc6 100644 --- a/embeddings/embeddings.go +++ b/embeddings/embeddings.go @@ -159,6 +159,9 @@ type EmbeddingSearchConfig struct { ReindexWorkers int `json:"reindexWorkers,omitempty"` ReindexBatchSize int `json:"reindexBatchSize,omitempty"` ReindexIndexStrategy string `json:"reindexIndexStrategy,omitempty"` + RecencyBiasEnabled bool `json:"recencyBiasEnabled,omitempty"` + RecencyHalfLifeDays float64 `json:"recencyHalfLifeDays,omitempty"` + RecencyFloor float64 `json:"recencyFloor,omitempty"` } // GetReindexWorkers returns the configured reindex worker count, clamped to @@ -179,6 +182,25 @@ func (c *EmbeddingSearchConfig) GetReindexBatchSize() int { return min(c.ReindexBatchSize, MaxReindexBatchSize) } +// GetRecencyBiasSettings resolves the recency bias config: unset (<=0) +// half-life and floor fall back to defaults; the floor is clamped to [0, 1]. +// These are query-time-only settings; changing them never requires a reindex. +func (c *EmbeddingSearchConfig) GetRecencyBiasSettings() RecencyBiasSettings { + settings := RecencyBiasSettings{ + Enabled: c.RecencyBiasEnabled, + HalfLifeDays: c.RecencyHalfLifeDays, + Floor: c.RecencyFloor, + } + if settings.HalfLifeDays <= 0 { + settings.HalfLifeDays = DefaultRecencyHalfLifeDays + } + if settings.Floor <= 0 { + settings.Floor = DefaultRecencyFloor + } + settings.Floor = min(settings.Floor, 1) + return settings +} + // EffectiveReindexIndexStrategy: defer if set, otherwise maintain. func (c *EmbeddingSearchConfig) EffectiveReindexIndexStrategy() string { switch c.ReindexIndexStrategy { diff --git a/embeddings/integration_test.go b/embeddings/integration_test.go index 2f5a88f68..9c664173c 100644 --- a/embeddings/integration_test.go +++ b/embeddings/integration_test.go @@ -170,7 +170,7 @@ func createFullSearchSystem(t *testing.T, db *sqlx.DB, dimensions int) embedding ChunkingStrategy: "sentences", } - return embeddings.NewCompositeSearch(vectorStore, provider, chunkingOpts) + return embeddings.NewCompositeSearch(vectorStore, provider, chunkingOpts, embeddings.RecencyBiasSettings{}) } // TestBasicIndexAndSearchMechanics tests that the indexing and search plumbing works diff --git a/embeddings/recency.go b/embeddings/recency.go new file mode 100644 index 000000000..0373ed43f --- /dev/null +++ b/embeddings/recency.go @@ -0,0 +1,101 @@ +// Copyright (c) 2023-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package embeddings + +import ( + "math" + "sort" +) + +// Recency bias defaults, chosen to match common time-decay ranking practice +// (Elasticsearch function_score exp decay, Azure AI Search freshness +// boosting). A 7-day half-life suits workplace chat, where this week's +// messages matter far more than last quarter's. The 0.7 floor bounds the +// worst-case demotion so a strong old match (raw 0.9 -> 0.63) still outranks +// a weak fresh one (raw <0.63), keeping old canonical answers findable. +const ( + DefaultRecencyHalfLifeDays = 7.0 + DefaultRecencyFloor = 0.7 +) + +// Candidate pool sizing for over-fetch + rerank: fetch multiplier x limit +// candidates (bounded) by pure similarity, then rerank in memory. Since decay +// demotes a score by at most (1-floor), only candidates near the top raw +// score can be reordered, so a modest pool suffices. +const ( + recencyCandidateMultiplier = 4 + recencyMinCandidates = 20 + recencyMaxCandidates = 200 +) + +const millisPerDay = 24 * 60 * 60 * 1000 + +// RecencyBiasSettings holds resolved (defaulted and clamped) recency +// reranking parameters used by CompositeSearch. +type RecencyBiasSettings struct { + Enabled bool + HalfLifeDays float64 + // Floor is the minimum decay multiplier in [0, 1]; it bounds the + // worst-case demotion of old results to Floor x their raw score. + Floor float64 +} + +// recencyMultiplier returns the decay multiplier in [floor, 1] for a document +// of the given age: floor + (1-floor) * 0.5^(ageDays/halfLifeDays). +// Non-positive ages (future timestamps, clock skew) return 1. +func recencyMultiplier(ageMillis int64, halfLifeDays, floor float64) float64 { + if ageMillis <= 0 { + return 1 + } + ageDays := float64(ageMillis) / millisPerDay + decay := math.Pow(0.5, ageDays/halfLifeDays) + return floor + (1-floor)*decay +} + +// rerankByRecency reorders results by recency-adjusted score (raw similarity +// x decay multiplier) descending, tie-breaking by CreateAt descending and +// then by the incoming (similarity) order. The Score field is left as the raw +// similarity: MinScore semantics and the relevance surfaced to callers stay +// similarity-based; recency influences ordering only. +func rerankByRecency(results []SearchResult, nowMillis int64, settings RecencyBiasSettings) { + adjusted := make(map[int]float64, len(results)) + order := make([]int, len(results)) + for i, r := range results { + order[i] = i + if r.Document.CreateAt <= 0 { + // Unknown timestamp: treat as fully decayed rather than fresh. + adjusted[i] = float64(r.Score) * settings.Floor + continue + } + age := nowMillis - r.Document.CreateAt + adjusted[i] = float64(r.Score) * recencyMultiplier(age, settings.HalfLifeDays, settings.Floor) + } + + sort.SliceStable(order, func(a, b int) bool { + i, j := order[a], order[b] + if adjusted[i] != adjusted[j] { + return adjusted[i] > adjusted[j] + } + return results[i].Document.CreateAt > results[j].Document.CreateAt + }) + + reordered := make([]SearchResult, len(results)) + for pos, idx := range order { + reordered[pos] = results[idx] + } + copy(results, reordered) +} + +// recencyFetchLimit returns the store-level limit for the candidate +// over-fetch. Offset is folded in because pagination must apply to the +// reranked order, not the raw similarity order. 0 means "store maximum" +// (rerank everything the store would return). +func recencyFetchLimit(limit, offset int) int { + if limit <= 0 { + return 0 + } + pool := min(max(limit*recencyCandidateMultiplier, recencyMinCandidates), recencyMaxCandidates) + pool = max(pool, limit) + return max(offset, 0) + pool +} diff --git a/embeddings/recency_test.go b/embeddings/recency_test.go new file mode 100644 index 000000000..c679ad16c --- /dev/null +++ b/embeddings/recency_test.go @@ -0,0 +1,292 @@ +// Copyright (c) 2023-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package embeddings + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRecencyMultiplier(t *testing.T) { + tests := []struct { + name string + ageMillis int64 + halfLifeDays float64 + floor float64 + expected float64 + }{ + { + name: "zero age has no decay", + ageMillis: 0, + halfLifeDays: 7, + floor: 0.7, + expected: 1, + }, + { + name: "future timestamp (negative age) has no decay", + ageMillis: -5 * millisPerDay, + halfLifeDays: 7, + floor: 0.7, + expected: 1, + }, + { + name: "age of one half-life decays half the boostable range", + ageMillis: 7 * millisPerDay, + halfLifeDays: 7, + floor: 0.7, + expected: 0.85, // 0.7 + 0.3*0.5 + }, + { + name: "age of two half-lives decays to a quarter of the range", + ageMillis: 14 * millisPerDay, + halfLifeDays: 7, + floor: 0.7, + expected: 0.775, // 0.7 + 0.3*0.25 + }, + { + name: "very old age converges to the floor", + ageMillis: 10 * 365 * millisPerDay, + halfLifeDays: 7, + floor: 0.7, + expected: 0.7, + }, + { + name: "zero floor allows full decay", + ageMillis: 1 * millisPerDay, + halfLifeDays: 1, + floor: 0, + expected: 0.5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := recencyMultiplier(tt.ageMillis, tt.halfLifeDays, tt.floor) + assert.InDelta(t, tt.expected, got, 1e-9) + }) + } +} + +func TestRecencyFetchLimit(t *testing.T) { + tests := []struct { + name string + limit int + offset int + expected int + }{ + { + name: "zero limit means store maximum", + limit: 0, + offset: 5, + expected: 0, + }, + { + name: "negative limit means store maximum", + limit: -3, + offset: 0, + expected: 0, + }, + { + name: "small limit clamps pool to minimum", + limit: 3, + offset: 0, + expected: recencyMinCandidates, + }, + { + name: "typical limit multiplies", + limit: 10, + offset: 0, + expected: 40, + }, + { + name: "large limit clamps pool to maximum", + limit: 100, + offset: 0, + expected: recencyMaxCandidates, + }, + { + name: "pool never below limit", + limit: 300, + offset: 0, + expected: 300, + }, + { + name: "offset is folded into the fetch", + limit: 5, + offset: 10, + expected: 30, + }, + { + name: "negative offset is ignored", + limit: 5, + offset: -4, + expected: recencyMinCandidates, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, recencyFetchLimit(tt.limit, tt.offset)) + }) + } +} + +func TestGetRecencyBiasSettings(t *testing.T) { + tests := []struct { + name string + config EmbeddingSearchConfig + expected RecencyBiasSettings + }{ + { + name: "zero config falls back to defaults, disabled", + config: EmbeddingSearchConfig{}, + expected: RecencyBiasSettings{ + Enabled: false, + HalfLifeDays: DefaultRecencyHalfLifeDays, + Floor: DefaultRecencyFloor, + }, + }, + { + name: "explicit values pass through", + config: EmbeddingSearchConfig{ + RecencyBiasEnabled: true, + RecencyHalfLifeDays: 30, + RecencyFloor: 0.5, + }, + expected: RecencyBiasSettings{ + Enabled: true, + HalfLifeDays: 30, + Floor: 0.5, + }, + }, + { + name: "negative values fall back to defaults", + config: EmbeddingSearchConfig{ + RecencyBiasEnabled: true, + RecencyHalfLifeDays: -1, + RecencyFloor: -0.5, + }, + expected: RecencyBiasSettings{ + Enabled: true, + HalfLifeDays: DefaultRecencyHalfLifeDays, + Floor: DefaultRecencyFloor, + }, + }, + { + name: "floor above one is clamped", + config: EmbeddingSearchConfig{ + RecencyBiasEnabled: true, + RecencyFloor: 1.5, + }, + expected: RecencyBiasSettings{ + Enabled: true, + HalfLifeDays: DefaultRecencyHalfLifeDays, + Floor: 1, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.config.GetRecencyBiasSettings()) + }) + } +} + +func TestRerankByRecency(t *testing.T) { + const now = int64(1_700_000_000_000) + defaultSettings := RecencyBiasSettings{Enabled: true, HalfLifeDays: 7, Floor: 0.7} + + result := func(postID string, score float32, ageDays int64) SearchResult { + createAt := int64(0) + if ageDays >= 0 { + createAt = now - ageDays*millisPerDay + } + return SearchResult{ + Document: PostDocument{PostID: postID, CreateAt: createAt}, + Score: score, + } + } + + tests := []struct { + name string + results []SearchResult + settings RecencyBiasSettings + expectedOrder []string + }{ + { + name: "fresh result overtakes decayed stronger match", + results: []SearchResult{ + // old: 0.85 * (0.7 + 0.3*0.5^(30/7)) ~= 0.61 < fresh: 0.80 + result("old", 0.85, 30), + result("fresh", 0.80, 0), + }, + settings: defaultSettings, + expectedOrder: []string{"fresh", "old"}, + }, + { + name: "floor keeps old canonical answer above weak fresh result", + results: []SearchResult{ + result("weak-fresh", 0.50, 0), + // old: 0.9 * ~0.7 = ~0.63 > 0.50 + result("old-canonical", 0.90, 365), + }, + settings: defaultSettings, + expectedOrder: []string{"old-canonical", "weak-fresh"}, + }, + { + name: "unknown timestamp is treated as fully decayed", + results: []SearchResult{ + // no timestamp: 0.9 * 0.7 = 0.63 < fresh 0.7 + result("no-timestamp", 0.90, -1), + result("fresh", 0.70, 0), + }, + settings: defaultSettings, + expectedOrder: []string{"fresh", "no-timestamp"}, + }, + { + name: "equal adjusted scores tie-break by recency", + results: []SearchResult{ + result("older", 0.8, 20), + result("newer", 0.8, 2), + }, + // Floor 1 disables decay entirely, so adjusted scores are equal. + settings: RecencyBiasSettings{Enabled: true, HalfLifeDays: 7, Floor: 1}, + expectedOrder: []string{"newer", "older"}, + }, + { + name: "identical results keep input (similarity) order", + results: []SearchResult{ + result("first", 0.8, 3), + result("second", 0.8, 3), + }, + settings: defaultSettings, + expectedOrder: []string{"first", "second"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rawScores := make(map[string]float32, len(tt.results)) + for _, r := range tt.results { + rawScores[r.Document.PostID] = r.Score + } + + rerankByRecency(tt.results, now, tt.settings) + + order := make([]string, len(tt.results)) + for i, r := range tt.results { + order[i] = r.Document.PostID + } + assert.Equal(t, tt.expectedOrder, order) + + // Reranking must not rewrite the raw similarity scores. + for _, r := range tt.results { + assert.Equal(t, rawScores[r.Document.PostID], r.Score, + "raw score of %s must be preserved", r.Document.PostID) + } + }) + } +} diff --git a/mcpserver/eval_helpers_test.go b/mcpserver/eval_helpers_test.go index ff002488f..e0cd2815d 100644 --- a/mcpserver/eval_helpers_test.go +++ b/mcpserver/eval_helpers_test.go @@ -319,7 +319,7 @@ func setupEmbeddingSearch(t *testing.T, data *evalChannelData, serverURL, adminT ChunkSize: 500, ChunkOverlap: 50, ChunkingStrategy: "sentences", - }) + }, embeddings.RecencyBiasSettings{}) // Fetch all posts from Mattermost and index them into PGVector client := model.NewAPIv4Client(serverURL) diff --git a/search/embeddings.go b/search/embeddings.go index 4d08b40d9..736aa9773 100644 --- a/search/embeddings.go +++ b/search/embeddings.go @@ -150,7 +150,7 @@ func InitEmbeddingsSearch(db *sqlx.DB, httpClient *http.Client, cfg embeddings.E chunkingOpts = chunking.DefaultOptions() } - return embeddings.NewCompositeSearch(vector, embeddor, chunkingOpts), nil + return embeddings.NewCompositeSearch(vector, embeddor, chunkingOpts, cfg.GetRecencyBiasSettings()), nil } return nil, fmt.Errorf("unsupported search type: %s", cfg.Type) diff --git a/search/search_eval_test.go b/search/search_eval_test.go index 45d137bc1..8fc4a716f 100644 --- a/search/search_eval_test.go +++ b/search/search_eval_test.go @@ -172,7 +172,7 @@ func createRealEmbeddingSearch(t *testing.T, db *sqlx.DB, apiKey string) embeddi ChunkingStrategy: "sentences", } - return embeddings.NewCompositeSearch(vectorStore, provider, chunkingOpts) + return embeddings.NewCompositeSearch(vectorStore, provider, chunkingOpts, embeddings.RecencyBiasSettings{}) } // TestSemanticSearchRelevance tests that semantically similar content ranks higher diff --git a/webapp/src/components/system_console/embedding_search/embedding_search_panel.tsx b/webapp/src/components/system_console/embedding_search/embedding_search_panel.tsx index 1f634abc5..9d788a434 100644 --- a/webapp/src/components/system_console/embedding_search/embedding_search_panel.tsx +++ b/webapp/src/components/system_console/embedding_search/embedding_search_panel.tsx @@ -11,9 +11,9 @@ import {Pill} from '../../pill'; import EnterpriseChip from '../enterprise_chip'; import Panel from '../panel'; import {BooleanItem, ItemList, SelectionItem, SelectionItemOption} from '../item'; -import {IntItem} from '../number_items'; +import {FloatItem, IntItem} from '../number_items'; -import {EmbeddingSearchConfig, REINDEX_DEFAULTS, REINDEX_INDEX_STRATEGY, ReindexIndexStrategy} from './types'; +import {EmbeddingSearchConfig, RECENCY_DEFAULTS, REINDEX_DEFAULTS, REINDEX_INDEX_STRATEGY, ReindexIndexStrategy} from './types'; import {OpenAIProviderConfig, OpenAICompatibleProviderConfig} from './provider_configs'; import {ChunkingOptionsConfig} from './chunking_options'; import {ReindexSection} from './reindex_section'; @@ -45,6 +45,18 @@ const normalizeReindexIndexStrategy = (value: string | undefined): ReindexIndexS return REINDEX_INDEX_STRATEGY.maintain; }; +// Mirror the server's GetRecencyBiasSettings normalization: unset or +// non-positive falls back to the default, oversized is clamped. +const normalizeRecencyValue = (value: number | undefined, fallback: number, max?: number): number => { + if (typeof value !== 'number' || isNaN(value) || value <= 0) { + return fallback; + } + if (typeof max === 'number') { + return Math.min(value, max); + } + return value; +}; + interface Props { value: EmbeddingSearchConfig; onChange: (config: EmbeddingSearchConfig) => void; @@ -238,6 +250,36 @@ const EmbeddingSearchPanel = ({value, onChange}: Props) => { onChange={onChange} /> + onChange({...value, recencyBiasEnabled})} + helpText={intl.formatMessage({defaultMessage: 'Rank more recent messages higher in semantic search results. Results are still selected by relevance; recency influences their ordering.'})} + /> + + {value.recencyBiasEnabled && ( + <> + onChange({...value, recencyHalfLifeDays})} + min={0.1} + helptext={intl.formatMessage({defaultMessage: 'How quickly the recency boost fades. A message this many days old loses half of its recency boost. Lower values favor newer messages more strongly.'})} + /> + + onChange({...value, recencyFloor})} + min={0.01} + max={1} + helptext={intl.formatMessage({defaultMessage: 'Minimum score multiplier for old messages (0-1). Higher values preserve old but highly relevant results; 1 disables the recency effect.'})} + /> + + )} + Date: Thu, 23 Jul 2026 14:30:45 +0000 Subject: [PATCH 3/5] Add end-to-end integration test for recency bias reranking Co-authored-by: Christopher Speller --- embeddings/integration_test.go | 53 +++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/embeddings/integration_test.go b/embeddings/integration_test.go index 9c664173c..cd7c5c521 100644 --- a/embeddings/integration_test.go +++ b/embeddings/integration_test.go @@ -156,6 +156,11 @@ func addTestPost(t *testing.T, db *sqlx.DB, postID, userID, channelID, message s // createFullSearchSystem creates a CompositeSearch with mock provider and real PGVector func createFullSearchSystem(t *testing.T, db *sqlx.DB, dimensions int) embeddings.EmbeddingSearch { + return createFullSearchSystemWithRecency(t, db, dimensions, embeddings.RecencyBiasSettings{}) +} + +// createFullSearchSystemWithRecency is createFullSearchSystem with recency bias settings. +func createFullSearchSystemWithRecency(t *testing.T, db *sqlx.DB, dimensions int, recency embeddings.RecencyBiasSettings) embeddings.EmbeddingSearch { provider := embeddings.NewMockEmbeddingProvider(dimensions) pgVectorConfig := postgres.PGVectorConfig{ @@ -170,7 +175,7 @@ func createFullSearchSystem(t *testing.T, db *sqlx.DB, dimensions int) embedding ChunkingStrategy: "sentences", } - return embeddings.NewCompositeSearch(vectorStore, provider, chunkingOpts, embeddings.RecencyBiasSettings{}) + return embeddings.NewCompositeSearch(vectorStore, provider, chunkingOpts, recency) } // TestBasicIndexAndSearchMechanics tests that the indexing and search plumbing works @@ -243,6 +248,52 @@ func TestBasicIndexAndSearchMechanics(t *testing.T) { } } +// TestRecencyBiasEndToEnd verifies the over-fetch + rerank path against real +// pgvector: two posts with identical content produce identical similarity +// (the mock provider is deterministic), so with recency bias enabled the +// ordering can only come from the time decay. +func TestRecencyBiasEndToEnd(t *testing.T) { + db := testDB(t) + defer cleanupDB(t, db) + + const dimensions = 64 + search := createFullSearchSystemWithRecency(t, db, dimensions, embeddings.RecencyBiasSettings{ + Enabled: true, + HalfLifeDays: 7, + Floor: 0.7, + }) + ctx := context.Background() + + addTestChannel(t, db, "channel1", "team1", "O", []string{"user1"}) + + const content = "How do I configure the deployment pipeline for staging?" + now := model.GetMillis() + oldCreateAt := now - 90*24*60*60*1000 // 90 days ago + freshCreateAt := now - 60*60*1000 // 1 hour ago + + addTestPost(t, db, "old_post", "user1", "channel1", content, oldCreateAt) + addTestPost(t, db, "fresh_post", "user1", "channel1", content, freshCreateAt) + + err := search.Store(ctx, []embeddings.PostDocument{ + {PostID: "old_post", CreateAt: oldCreateAt, TeamID: "team1", ChannelID: "channel1", UserID: "user1", Content: content}, + {PostID: "fresh_post", CreateAt: freshCreateAt, TeamID: "team1", ChannelID: "channel1", UserID: "user1", Content: content}, + }) + require.NoError(t, err) + + results, err := search.Search(ctx, content, embeddings.SearchOptions{ + Limit: 2, + UserID: "user1", + }) + require.NoError(t, err) + require.Len(t, results, 2) + + // Identical content means identical raw similarity scores... + assert.InDelta(t, float64(results[0].Score), float64(results[1].Score), 1e-6) + // ...so the newer post ranking first proves the recency rerank worked. + assert.Equal(t, "fresh_post", results[0].Document.PostID) + assert.Equal(t, "old_post", results[1].Document.PostID) +} + // TestReindexWithDimensionMismatch verifies Full Reindex Clear recreates the // pgvector table when configured dimensions change. func TestReindexWithDimensionMismatch(t *testing.T) { From 9fa20aef602a2b69fe46a99dbd7d029c25cbfc0e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 13:00:31 +0000 Subject: [PATCH 4/5] Fall back to store pagination when recency window exceeds the search cap Co-authored-by: Christopher Speller --- embeddings/composite.go | 8 ++++++++ embeddings/composite_test.go | 30 ++++++++++++++++++++++++++++++ embeddings/embeddings.go | 4 ++++ embeddings/recency.go | 5 ++++- postgres/pgvector.go | 7 +++---- 5 files changed, 49 insertions(+), 5 deletions(-) diff --git a/embeddings/composite.go b/embeddings/composite.go index 51a228b44..b5c3522ab 100644 --- a/embeddings/composite.go +++ b/embeddings/composite.go @@ -91,6 +91,14 @@ func (c *CompositeSearch) Search(ctx context.Context, query string, opts SearchO fetchOpts.Offset = 0 fetchOpts.Limit = recencyFetchLimit(opts.Limit, opts.Offset) + // The store caps a single search at MaxSearchResults rows, so a window + // that extends past the cap cannot be served from the reranked candidate + // pool. Fall back to store-side pagination in raw similarity order rather + // than silently truncating deep pages. + if fetchOpts.Limit > MaxSearchResults || (fetchOpts.Limit == 0 && opts.Offset > 0) { + return c.store.Search(ctx, embedding, opts) + } + results, err := c.store.Search(ctx, embedding, fetchOpts) if err != nil { return nil, err diff --git a/embeddings/composite_test.go b/embeddings/composite_test.go index 6c21b218c..3009eab8e 100644 --- a/embeddings/composite_test.go +++ b/embeddings/composite_test.go @@ -523,6 +523,36 @@ func TestCompositeSearch_RecencyBias(t *testing.T) { expectedFetchOffset: 0, expectedOrder: []string{}, }, + { + // The window extends past the store's hard cap, so reranking + // cannot serve it; pagination must fall through to the store + // (raw similarity order) instead of returning a truncated page. + name: "window beyond store cap falls back to store pagination", + recency: enabled, + searchOpts: SearchOptions{Limit: 5, Offset: MaxSearchResults}, + storeResults: defaultStoreResults, + expectedFetchLimit: 5, + expectedFetchOffset: MaxSearchResults, + expectedOrder: []string{"old-strong", "fresh-mid", "fresh-weak"}, + }, + { + name: "unbounded limit with offset falls back to store pagination", + recency: enabled, + searchOpts: SearchOptions{Limit: 0, Offset: 5}, + storeResults: defaultStoreResults, + expectedFetchLimit: 0, + expectedFetchOffset: 5, + expectedOrder: []string{"old-strong", "fresh-mid", "fresh-weak"}, + }, + { + name: "unbounded limit without offset reranks everything", + recency: enabled, + searchOpts: SearchOptions{Limit: 0}, + storeResults: defaultStoreResults, + expectedFetchLimit: 0, + expectedFetchOffset: 0, + expectedOrder: []string{"fresh-mid", "fresh-weak", "old-strong"}, + }, } for _, tt := range tests { diff --git a/embeddings/embeddings.go b/embeddings/embeddings.go index 8ab640fc6..5ada9e002 100644 --- a/embeddings/embeddings.go +++ b/embeddings/embeddings.go @@ -47,6 +47,10 @@ type SearchResult struct { Score float32 } +// MaxSearchResults is the hard cap on rows a vector store returns for a +// single search; stores clamp unset (<=0) or larger limits to this value. +const MaxSearchResults = 1000 + // SearchOptions contains parameters for search operations type SearchOptions struct { Limit int diff --git a/embeddings/recency.go b/embeddings/recency.go index 0373ed43f..bb6f2af2b 100644 --- a/embeddings/recency.go +++ b/embeddings/recency.go @@ -90,7 +90,10 @@ func rerankByRecency(results []SearchResult, nowMillis int64, settings RecencyBi // recencyFetchLimit returns the store-level limit for the candidate // over-fetch. Offset is folded in because pagination must apply to the // reranked order, not the raw similarity order. 0 means "store maximum" -// (rerank everything the store would return). +// (rerank everything the store would return). The result may exceed +// MaxSearchResults; the caller detects that and falls back to store-side +// pagination, since the store would silently clamp the fetch and drop the +// tail of the window. func recencyFetchLimit(limit, offset int) int { if limit <= 0 { return 0 diff --git a/postgres/pgvector.go b/postgres/pgvector.go index dffca519b..ee53bc98d 100644 --- a/postgres/pgvector.go +++ b/postgres/pgvector.go @@ -340,11 +340,10 @@ func (pv *PGVector) Search(ctx context.Context, embedding []float32, opts embedd queryBuilder = queryBuilder.OrderBy("similarity ASC") - // Apply limit with sensible default/max - const maxSearchLimit = 1000 + // Apply limit with sensible default/max (shared store cap) limit := opts.Limit - if limit <= 0 || limit > maxSearchLimit { - limit = maxSearchLimit + if limit <= 0 || limit > embeddings.MaxSearchResults { + limit = embeddings.MaxSearchResults } queryBuilder = queryBuilder.Limit(uint64(limit)) //nolint:gosec From 0c325b0c007b8a1da87236457b6df238fa9f39a8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 13:00:32 +0000 Subject: [PATCH 5/5] Gate prompt time attribute on formatted timestamp; mark timestamps optional Co-authored-by: Christopher Speller --- prompts/search_results.tmpl | 2 +- prompts/search_system.tmpl | 2 +- search/search_test.go | 11 +++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/prompts/search_results.tmpl b/prompts/search_results.tmpl index 1a2f5fc19..b0150c17e 100644 --- a/prompts/search_results.tmpl +++ b/prompts/search_results.tmpl @@ -1,4 +1,4 @@ -{{range .Parameters.Results}} +{{range .Parameters.Results}} {{escapeContent .Content}} diff --git a/prompts/search_system.tmpl b/prompts/search_system.tmpl index 9ff8fb125..f771f1e86 100644 --- a/prompts/search_system.tmpl +++ b/prompts/search_system.tmpl @@ -8,7 +8,7 @@ Follow these guidelines: {{template "citation_format.tmpl" .}} 5. If the question is ambiguous, interpret it reasonably based on the context. 6. Do not hallucinate information not present in the context. -7. Each message has a time attribute with its creation timestamp. When messages contain conflicting or outdated information, prefer the more recent messages, and mention when relevant information may be outdated. +7. When available, each message has a time attribute with its creation timestamp. If a message has no timestamp, do not infer its recency. When messages contain conflicting or outdated information, prefer the more recent messages, and mention when relevant information may be outdated. {{template "search_results.tmpl" .}} diff --git a/search/search_test.go b/search/search_test.go index 1bdfaab49..7a3020d45 100644 --- a/search/search_test.go +++ b/search/search_test.go @@ -506,6 +506,17 @@ func TestBuildPrompt(t *testing.T) { require.NotContains(t, req.Posts[0].Message, "time=") }, }, + { + name: "negative timestamp omits the time attribute", + query: "search query", + results: []RAGResult{ + {PostID: "post1", Content: "undated content", ChannelName: "General", Username: "testuser", Score: 0.95, CreateAt: -1}, + }, + expectError: false, + validate: func(t *testing.T, req llm.CompletionRequest) { + require.NotContains(t, req.Posts[0].Message, "time=") + }, + }, } for _, tc := range tests {