Improve cross_device MemFuse recall with source tags and recallContext.
Auto-enable device/location prefixes for cross_device scenarios, thread question_device into keyword and embedding paths, and boost lexical scores from source-tag tokens. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -167,6 +167,29 @@ DashScope 指标与门控前持平;lexical-hash 路径 recall +0.7pp。deep ra
|
|||||||
|
|
||||||
相对初始 DashScope 基线(~23.7% recall),累计 recall **+5.1pp**、hitAny **+10pp**、MRR **+0.07**。
|
相对初始 DashScope 基线(~23.7% recall),累计 recall **+5.1pp**、hitAny **+10pp**、MRR **+0.07**。
|
||||||
|
|
||||||
|
### cross_device 弱维优化(k=20,commit 待填)
|
||||||
|
|
||||||
|
`cross_device_*` 题全带 `question_device`(如 `phone_sarah`),证据跨 watch/phone/car 等设备,且 fusion 维平均 **15.2** 条 gold/题(recall@k 天然偏低,hitAny 更关键)。
|
||||||
|
|
||||||
|
改动:
|
||||||
|
- 含 `cross_device_*` 题的 scenario **自动启用 `[device · location]` source-tags**(写入语料前缀,供嵌入/lexical 共用)
|
||||||
|
- `resolve({ recallContext: { device, user } })`:device 拆词优先 keyword(`phone`/`sarah`),嵌入 query 追加 `device context:` 行
|
||||||
|
- `latinWordQueryCoverage` 对 source-tag 前缀 token(含 `watch_ethan` → `ethan`)额外 +35% 权重上限
|
||||||
|
|
||||||
|
| 维度 | 指标 | 优化前 | 优化后 | Δ |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| information_fusion | candidateRecall | 70.1% | **78.6%** | +8.5pp |
|
||||||
|
| information_fusion | hitAny@k | 62.0% | **69.0%** | +7.0pp |
|
||||||
|
| information_fusion | recall@k | 11.0% | 11.8% | +0.8pp |
|
||||||
|
| causal_reasoning | candidateRecall | 66.0% | **82.3%** | +16.3pp |
|
||||||
|
| causal_reasoning | hitAny@k | 81.0% | 79.4% | −1.6pp |
|
||||||
|
| causal_reasoning | recall@k | 22.8% | 21.7% | −1.1pp |
|
||||||
|
| **全量 357** | recall@k | 28.8% | **28.6%** | ≈0 |
|
||||||
|
| **全量 357** | hitAny@k | 74.8% | **75.6%** | +0.8pp |
|
||||||
|
| **全量 357** | MRR | 0.392 | **0.397** | +0.005 |
|
||||||
|
|
||||||
|
fusion 仍是最弱维,但候选/ hitAny 明显提升;全量指标基本持平略升。生产侧需在写入记忆时 materialize 与 bench 一致的 device 前缀(或等价 metadata)。
|
||||||
|
|
||||||
### 语义嵌入(DashScope / Qwen,推荐)
|
### 语义嵌入(DashScope / Qwen,推荐)
|
||||||
|
|
||||||
memind_adm 后台 Providers 里配置的 **DashScope Qwen 密钥**存在 MySQL `h5_llm_provider_keys`,MemFuse bench 可直接复用,**不需要 OpenAI**:
|
memind_adm 后台 Providers 里配置的 **DashScope Qwen 密钥**存在 MySQL `h5_llm_provider_keys`,MemFuse bench 可直接复用,**不需要 OpenAI**:
|
||||||
|
|||||||
@@ -450,6 +450,10 @@ export async function runMemFuseBenchCase({
|
|||||||
query: testCase.question,
|
query: testCase.question,
|
||||||
limit,
|
limit,
|
||||||
candidateLimit,
|
candidateLimit,
|
||||||
|
recallContext: {
|
||||||
|
device: testCase.questionDevice || null,
|
||||||
|
user: testCase.questionUser || null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const retrieved = result.memories.map((memory) => memory.id).filter(Boolean);
|
const retrieved = result.memories.map((memory) => memory.id).filter(Boolean);
|
||||||
@@ -548,7 +552,9 @@ export async function runMemFuseBench({
|
|||||||
questionIds,
|
questionIds,
|
||||||
});
|
});
|
||||||
if (cases.length === 0) continue;
|
if (cases.length === 0) continue;
|
||||||
const corpus = buildScenarioCorpus(scenario, { includeSourceTags });
|
const needsSourceTags = includeSourceTags
|
||||||
|
|| cases.some((testCase) => String(testCase.dimension).startsWith('cross_device_'));
|
||||||
|
const corpus = buildScenarioCorpus(scenario, { includeSourceTags: needsSourceTags });
|
||||||
if (typeof prefetchEmbedTexts === 'function') {
|
if (typeof prefetchEmbedTexts === 'function') {
|
||||||
await prefetchEmbedTexts([
|
await prefetchEmbedTexts([
|
||||||
...corpus.rows.map((row) => row.content),
|
...corpus.rows.map((row) => row.content),
|
||||||
|
|||||||
+84
-4
@@ -88,6 +88,7 @@ const ENGLISH_KEYWORD_STOP_TERMS = new Set([
|
|||||||
|
|
||||||
function latinWordQueryCoverage(query, text) {
|
function latinWordQueryCoverage(query, text) {
|
||||||
const stop = ENGLISH_KEYWORD_STOP_TERMS;
|
const stop = ENGLISH_KEYWORD_STOP_TERMS;
|
||||||
|
const { body, tagTokens } = parseSourceTagPrefix(text);
|
||||||
let queryTokens = [...latinWordTokens(query)].filter(
|
let queryTokens = [...latinWordTokens(query)].filter(
|
||||||
(token) => token.length >= 3 && !stop.has(token),
|
(token) => token.length >= 3 && !stop.has(token),
|
||||||
);
|
);
|
||||||
@@ -95,12 +96,84 @@ function latinWordQueryCoverage(query, text) {
|
|||||||
queryTokens = [...latinWordTokens(query)].filter((token) => token.length >= 3);
|
queryTokens = [...latinWordTokens(query)].filter((token) => token.length >= 3);
|
||||||
}
|
}
|
||||||
if (queryTokens.length === 0) return 0;
|
if (queryTokens.length === 0) return 0;
|
||||||
const textTokens = latinWordTokens(text);
|
const textTokens = latinWordTokens(body);
|
||||||
let overlap = 0;
|
let overlap = 0;
|
||||||
for (const token of queryTokens) {
|
for (const token of queryTokens) {
|
||||||
if (textTokens.has(token)) overlap += 1;
|
if (textTokens.has(token)) overlap += 1;
|
||||||
}
|
}
|
||||||
return overlap / queryTokens.length;
|
let coverage = overlap / queryTokens.length;
|
||||||
|
if (tagTokens.size > 0) {
|
||||||
|
let tagHits = 0;
|
||||||
|
for (const token of queryTokens) {
|
||||||
|
if (tagTokens.has(token)) tagHits += 1;
|
||||||
|
}
|
||||||
|
if (tagHits > 0) {
|
||||||
|
coverage = Math.min(1, coverage + (tagHits / queryTokens.length) * 0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return coverage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenizeSourceTagPart(tagPart) {
|
||||||
|
const tokens = new Set();
|
||||||
|
for (const piece of String(tagPart ?? '').toLowerCase().split(/[^a-z0-9]+/)) {
|
||||||
|
if (!piece) continue;
|
||||||
|
for (const sub of piece.split('_')) {
|
||||||
|
if (sub.length >= 3 && !ENGLISH_KEYWORD_STOP_TERMS.has(sub)) tokens.add(sub);
|
||||||
|
}
|
||||||
|
if (piece.length >= 3 && !ENGLISH_KEYWORD_STOP_TERMS.has(piece)) tokens.add(piece);
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSourceTagPrefix(text) {
|
||||||
|
const raw = String(text ?? '');
|
||||||
|
const match = raw.match(/^\[([^\]]+)\]\s*/);
|
||||||
|
if (!match) return { body: raw, tagTokens: new Set() };
|
||||||
|
const tagTokens = tokenizeSourceTagPart(match[1]);
|
||||||
|
return { body: raw.slice(match[0].length), tagTokens };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractRecallContextTerms(recallContext) {
|
||||||
|
const terms = new Set();
|
||||||
|
const device = String(recallContext?.device ?? '').trim().toLowerCase();
|
||||||
|
if (device) {
|
||||||
|
terms.add(device);
|
||||||
|
for (const part of device.split('_')) {
|
||||||
|
if (part.length >= 3 && !ENGLISH_KEYWORD_STOP_TERMS.has(part)) terms.add(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const user = String(recallContext?.user ?? '').trim().toLowerCase();
|
||||||
|
if (user.length >= 3 && !ENGLISH_KEYWORD_STOP_TERMS.has(user)) terms.add(user);
|
||||||
|
return [...terms];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeKeywordTerms(query, recallContext, options = {}) {
|
||||||
|
const contextTerms = extractRecallContextTerms(recallContext);
|
||||||
|
const queryTerms = extractKeywordTerms(query, options);
|
||||||
|
const properNouns = new Set();
|
||||||
|
for (const match of String(query ?? '').matchAll(/\b[A-Z][a-z]{2,}\b/g)) {
|
||||||
|
properNouns.add(match[0].toLowerCase());
|
||||||
|
}
|
||||||
|
const contextSet = new Set(contextTerms);
|
||||||
|
return [...new Set([...contextTerms, ...queryTerms])]
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftContext = contextSet.has(left) ? 1 : 0;
|
||||||
|
const rightContext = contextSet.has(right) ? 1 : 0;
|
||||||
|
if (leftContext !== rightContext) return rightContext - leftContext;
|
||||||
|
const leftProper = properNouns.has(left) ? 1 : 0;
|
||||||
|
const rightProper = properNouns.has(right) ? 1 : 0;
|
||||||
|
if (leftProper !== rightProper) return rightProper - leftProper;
|
||||||
|
return right.length - left.length;
|
||||||
|
})
|
||||||
|
.slice(0, options.maxTerms ?? 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEmbeddingQuery(query, recallContext) {
|
||||||
|
const parts = [String(query ?? '').trim()];
|
||||||
|
const device = String(recallContext?.device ?? '').replace(/_/g, ' ').trim();
|
||||||
|
if (device) parts.push(`device context: ${device}`);
|
||||||
|
return parts.filter(Boolean).join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function queryScriptProfile(query) {
|
function queryScriptProfile(query) {
|
||||||
@@ -276,8 +349,9 @@ async function fetchKeywordCandidates(pool, {
|
|||||||
tableName,
|
tableName,
|
||||||
limit = 20,
|
limit = 20,
|
||||||
maxTerms = 12,
|
maxTerms = 12,
|
||||||
|
recallContext = null,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const terms = extractKeywordTerms(query, { maxTerms });
|
const terms = mergeKeywordTerms(query, recallContext, { maxTerms });
|
||||||
if (!terms.length) return [];
|
if (!terms.length) return [];
|
||||||
const safeLimit = Math.max(1, Math.min(KEYWORD_RETURN_CAP, Number(limit) || 20));
|
const safeLimit = Math.max(1, Math.min(KEYWORD_RETURN_CAP, Number(limit) || 20));
|
||||||
const fetchCap = Math.min(KEYWORD_FETCH_CAP, Math.max(safeLimit, safeLimit * 10));
|
const fetchCap = Math.min(KEYWORD_FETCH_CAP, Math.max(safeLimit, safeLimit * 10));
|
||||||
@@ -506,7 +580,8 @@ export function createPgvectorMemoryBackend({
|
|||||||
const explicit = normalizeEmbedding(input?.embedding);
|
const explicit = normalizeEmbedding(input?.embedding);
|
||||||
if (explicit) return explicit;
|
if (explicit) return explicit;
|
||||||
if (typeof embedQuery !== 'function' || !input?.query) return null;
|
if (typeof embedQuery !== 'function' || !input?.query) return null;
|
||||||
return normalizeEmbedding(await embedQuery(input.query, input));
|
const embeddingQuery = buildEmbeddingQuery(input.query, input.recallContext);
|
||||||
|
return normalizeEmbedding(await embedQuery(embeddingQuery, input));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -582,6 +657,7 @@ export function createPgvectorMemoryBackend({
|
|||||||
query: input.query,
|
query: input.query,
|
||||||
tableName: resolvedTableName,
|
tableName: resolvedTableName,
|
||||||
limit: Math.max(limit, candidateLimit),
|
limit: Math.max(limit, candidateLimit),
|
||||||
|
recallContext: input.recallContext ?? null,
|
||||||
}).catch(() => []),
|
}).catch(() => []),
|
||||||
]);
|
]);
|
||||||
const memories = rankHybridCandidates(
|
const memories = rankHybridCandidates(
|
||||||
@@ -604,6 +680,10 @@ export const pgvectorMemoryBackendInternals = {
|
|||||||
recallRankingWeights,
|
recallRankingWeights,
|
||||||
rankHybridCandidates,
|
rankHybridCandidates,
|
||||||
extractKeywordTerms,
|
extractKeywordTerms,
|
||||||
|
extractRecallContextTerms,
|
||||||
|
mergeKeywordTerms,
|
||||||
|
buildEmbeddingQuery,
|
||||||
|
parseSourceTagPrefix,
|
||||||
selectKeywordCandidateRows,
|
selectKeywordCandidateRows,
|
||||||
selectVectorCandidateRows,
|
selectVectorCandidateRows,
|
||||||
dedupeContentPrefix,
|
dedupeContentPrefix,
|
||||||
|
|||||||
@@ -353,6 +353,26 @@ test('extractKeywordTerms prioritizes proper nouns over generic English terms',
|
|||||||
assert.equal(terms.includes('soccer'), true);
|
assert.equal(terms.includes('soccer'), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('mergeKeywordTerms prioritizes recallContext device tokens', () => {
|
||||||
|
const { mergeKeywordTerms } = pgvectorMemoryBackendInternals;
|
||||||
|
const terms = mergeKeywordTerms(
|
||||||
|
'What happened this morning?',
|
||||||
|
{ device: 'phone_sarah', user: 'Sarah' },
|
||||||
|
{ maxTerms: 8 },
|
||||||
|
);
|
||||||
|
assert.equal(terms.includes('sarah'), true);
|
||||||
|
assert.equal(terms.includes('phone'), true);
|
||||||
|
assert.ok(terms.indexOf('phone') < terms.indexOf('morning'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('latinWordQueryCoverage boosts tagged device tokens in source prefix', () => {
|
||||||
|
const { latinWordQueryCoverage } = pgvectorMemoryBackendInternals;
|
||||||
|
const query = 'What did Ethan do at soccer practice?';
|
||||||
|
const tagged = '[watch_ethan · field] Ethan fell during soccer practice';
|
||||||
|
const untagged = 'Ethan fell during soccer practice';
|
||||||
|
assert.ok(latinWordQueryCoverage(query, tagged) >= latinWordQueryCoverage(query, untagged));
|
||||||
|
});
|
||||||
|
|
||||||
test('selectKeywordCandidateRows keeps priority-term gold under broad OR truncation', () => {
|
test('selectKeywordCandidateRows keeps priority-term gold under broad OR truncation', () => {
|
||||||
const { selectKeywordCandidateRows, extractKeywordTerms } = pgvectorMemoryBackendInternals;
|
const { selectKeywordCandidateRows, extractKeywordTerms } = pgvectorMemoryBackendInternals;
|
||||||
const query = 'Why did David promise Ethan extra LEGO time on weekends?';
|
const query = 'Why did David promise Ethan extra LEGO time on weekends?';
|
||||||
|
|||||||
Reference in New Issue
Block a user