Skip to content

Commit 03284c8

Browse files
committed
feat(openviking-memory): implement rankForInjection memory ranking strategy
Replace simple recallMemories delegation with a full sorting pipeline for context injection: query intent analysis, multi-boost scoring, dedup, and leaf-first selection. - Add ranking.ts module with FindResultItem/RecallQueryProfile types - Implement buildRecallQueryProfile for token extraction and intent detection - Implement rankForInjection with leafBoost, eventBoost, preferenceBoost, and lexicalOverlapBoost scoring components - Implement pickMemoriesForInjection entry: rank to sort to dedup to leaf-first - Update recallMemories to fetch 3x candidates and apply ranking pipeline - Add recallCandidateMultiplier property to OpenVikingClient interface
1 parent e6cf0d3 commit 03284c8

2 files changed

Lines changed: 177 additions & 1 deletion

File tree

apps/stage-tamagotchi/src/main/services/airi/plugins/examples/openviking-memory/src/openviking.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import type { FindResultItem } from './ranking'
2+
3+
import { pickMemoriesForInjection } from './ranking'
4+
15
export interface Memory {
26
id: string
37
content: string
@@ -37,6 +41,7 @@ export interface OpenVikingClientConfig {
3741
export interface OpenVikingClient {
3842
searchMemories: (query: string, limit?: number) => Promise<Record<string, unknown>[]>
3943
recallMemories: (query: string, limit?: number) => Promise<Record<string, unknown>[]>
44+
recallCandidateMultiplier: number
4045
readMemory: (uri: string) => Promise<{ uri: string, content: string }>
4146
addSessionMessage: (sessionId: string, role: string, content: string, createdAt?: string) => Promise<void>
4247
getSession: (sessionId: string) => Promise<SessionInfo>
@@ -96,6 +101,7 @@ export function createOpenVikingClient(config: OpenVikingClientConfig): OpenViki
96101
const { baseUrl, apiKey } = config
97102
const commitTokenThreshold = config.commitTokenThreshold ?? 20_000
98103
const commitKeepRecentCount = config.commitKeepRecentCount ?? 10
104+
const recallCandidateMultiplier = 3
99105
const headers: Record<string, string> = {
100106
'Content-Type': 'application/json',
101107
}
@@ -115,6 +121,7 @@ export function createOpenVikingClient(config: OpenVikingClientConfig): OpenViki
115121
}
116122

117123
return {
124+
recallCandidateMultiplier,
118125
async searchMemories(query: string, limit = 5): Promise<Record<string, unknown>[]> {
119126
const response = await apiFetch('/api/v1/search/find', {
120127
method: 'POST',
@@ -134,7 +141,8 @@ export function createOpenVikingClient(config: OpenVikingClientConfig): OpenViki
134141
},
135142

136143
async recallMemories(query: string, limit = 5): Promise<Record<string, unknown>[]> {
137-
return await this.searchMemories(query, limit)
144+
const raw = await this.searchMemories(query, limit * this.recallCandidateMultiplier)
145+
return pickMemoriesForInjection(raw as unknown as FindResultItem[], limit, query) as unknown as Record<string, unknown>[]
138146
},
139147

140148
async readMemory(uri: string): Promise<{ uri: string, content: string }> {
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
export interface FindResultItem {
2+
uri: string
3+
score?: number
4+
category?: string
5+
abstract?: string
6+
overview?: string
7+
level?: number
8+
}
9+
10+
export interface RecallQueryProfile {
11+
tokens: string[]
12+
wantsPreference: boolean
13+
wantsTemporal: boolean
14+
}
15+
16+
const LEAF_BOOST = 0.12
17+
const EVENT_BOOST = 0.10
18+
const PREFERENCE_BOOST = 0.08
19+
const OVERLAP_BOOST_MAX = 0.20
20+
const OVERLAP_TOKEN_MAX = 8
21+
const OVERLAP_DENOM_CAP = 4
22+
23+
const PREFERENCE_QUERY_RE = /prefer|favorite|favourite|like||||/i
24+
const TEMPORAL_QUERY_RE = /when|what time|date|day|month|year|yesterday|today|tomorrow|last|next||||||||||||||/i
25+
26+
const TOKEN_REGEX = /[a-z0-9]{2,}/gi
27+
const STOPWORDS = new Set([
28+
'what',
29+
'when',
30+
'where',
31+
'which',
32+
'who',
33+
'whom',
34+
'whose',
35+
'why',
36+
'how',
37+
'did',
38+
'does',
39+
'is',
40+
'are',
41+
'was',
42+
'were',
43+
'the',
44+
'and',
45+
'for',
46+
'with',
47+
'from',
48+
'that',
49+
'this',
50+
'your',
51+
'you',
52+
])
53+
54+
function clampScore(value: number | undefined): number {
55+
if (typeof value !== 'number' || Number.isNaN(value)) {
56+
return 0
57+
}
58+
return Math.max(0, Math.min(1, value))
59+
}
60+
61+
function isLeafLikeMemory(item: FindResultItem): boolean {
62+
return item.level === 2
63+
}
64+
65+
function isEventMemory(item: FindResultItem): boolean {
66+
const category = (item.category ?? '').toLowerCase()
67+
return category === 'events' || item.uri.includes('/events/')
68+
}
69+
70+
function isPreferencesMemory(item: FindResultItem): boolean {
71+
return (
72+
item.category === 'preferences'
73+
|| item.uri.includes('/preferences/')
74+
|| item.uri.endsWith('/preferences')
75+
)
76+
}
77+
78+
function lexicalOverlapBoost(tokens: string[], text: string): number {
79+
if (tokens.length === 0 || !text) {
80+
return 0
81+
}
82+
const haystack = ` ${text.toLowerCase()} `
83+
let matched = 0
84+
for (const token of tokens.slice(0, OVERLAP_TOKEN_MAX)) {
85+
if (haystack.includes(` ${token} `) || haystack.includes(token)) {
86+
matched += 1
87+
}
88+
}
89+
return Math.min(OVERLAP_BOOST_MAX, (matched / Math.min(tokens.length, OVERLAP_DENOM_CAP)) * OVERLAP_BOOST_MAX)
90+
}
91+
92+
export function buildRecallQueryProfile(query: string): RecallQueryProfile {
93+
const lower = query.toLowerCase()
94+
const tokens: string[] = []
95+
const rawTokens = lower.match(TOKEN_REGEX) ?? []
96+
for (const token of rawTokens) {
97+
if (!STOPWORDS.has(token)) {
98+
tokens.push(token)
99+
}
100+
}
101+
return {
102+
tokens,
103+
wantsPreference: PREFERENCE_QUERY_RE.test(query),
104+
wantsTemporal: TEMPORAL_QUERY_RE.test(query),
105+
}
106+
}
107+
108+
export function rankForInjection(item: FindResultItem, profile: RecallQueryProfile): number {
109+
const baseScore = clampScore(item.score)
110+
const leafBoost = isLeafLikeMemory(item) ? LEAF_BOOST : 0
111+
const eventBoost = profile.wantsTemporal && isEventMemory(item) ? EVENT_BOOST : 0
112+
const preferenceBoost = profile.wantsPreference && isPreferencesMemory(item) ? PREFERENCE_BOOST : 0
113+
const abstract = item.abstract ?? item.overview ?? ''
114+
const textForOverlap = `${item.uri} ${abstract}`
115+
const overlapBoost = lexicalOverlapBoost(profile.tokens, textForOverlap)
116+
return baseScore + leafBoost + eventBoost + preferenceBoost + overlapBoost
117+
}
118+
119+
export function pickMemoriesForInjection(
120+
items: FindResultItem[],
121+
limit: number,
122+
queryText: string,
123+
scoreThreshold = 0,
124+
): FindResultItem[] {
125+
if (items.length === 0 || limit <= 0) {
126+
return []
127+
}
128+
const profile = buildRecallQueryProfile(queryText)
129+
const scored: { item: FindResultItem, score: number }[] = []
130+
for (const item of items) {
131+
scored.push({ item, score: rankForInjection(item, profile) })
132+
}
133+
scored.sort((a, b) => b.score - a.score)
134+
const seen = new Set<string>()
135+
const deduped: FindResultItem[] = []
136+
for (const { item } of scored) {
137+
const abstractKey = (item.abstract ?? item.overview ?? '').trim().toLowerCase()
138+
const key = abstractKey || item.uri
139+
if (seen.has(key)) {
140+
continue
141+
}
142+
seen.add(key)
143+
deduped.push(item)
144+
}
145+
const leaves: FindResultItem[] = []
146+
const nonLeaves: FindResultItem[] = []
147+
for (const item of deduped) {
148+
if (isLeafLikeMemory(item)) {
149+
leaves.push(item)
150+
}
151+
else {
152+
nonLeaves.push(item)
153+
}
154+
}
155+
if (leaves.length >= limit) {
156+
return leaves.slice(0, limit)
157+
}
158+
const result = [...leaves]
159+
for (const item of nonLeaves) {
160+
if (result.length >= limit) {
161+
break
162+
}
163+
if (clampScore(item.score) >= scoreThreshold) {
164+
result.push(item)
165+
}
166+
}
167+
return result
168+
}

0 commit comments

Comments
 (0)