6d48480b1c
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>
442 lines
15 KiB
JavaScript
442 lines
15 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { createMemoryV2 } from './memory-v2.mjs';
|
|
import {
|
|
createPgvectorMemoryBackend,
|
|
pgvectorMemoryBackendInternals,
|
|
} from './memory-v2-pgvector.mjs';
|
|
|
|
test('pgvector backend is disabled by default and does not query storage', async () => {
|
|
let queried = false;
|
|
const backend = createPgvectorMemoryBackend({
|
|
pool: {
|
|
async query() {
|
|
queried = true;
|
|
return { rows: [] };
|
|
},
|
|
},
|
|
});
|
|
|
|
assert.equal(backend.isAvailable(), false);
|
|
assert.deepEqual(await backend.resolve({
|
|
userId: 'user-1',
|
|
embedding: [0.1, 0.2],
|
|
}), {
|
|
memories: [],
|
|
semanticMemories: [],
|
|
});
|
|
assert.equal(queried, false);
|
|
});
|
|
|
|
test('pgvector backend returns empty semantic result when embedding is unavailable', async () => {
|
|
let queried = false;
|
|
const backend = createPgvectorMemoryBackend({
|
|
enabled: true,
|
|
pool: {
|
|
async query() {
|
|
queried = true;
|
|
return { rows: [] };
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await backend.resolve({
|
|
userId: 'user-1',
|
|
query: 'memory-chain',
|
|
});
|
|
|
|
assert.deepEqual(result, {
|
|
memories: [],
|
|
semanticMemories: [],
|
|
});
|
|
assert.equal(queried, false);
|
|
});
|
|
|
|
test('pgvector backend performs parameterized vector lookup when explicitly enabled', async () => {
|
|
const queries = [];
|
|
const backend = createPgvectorMemoryBackend({
|
|
enabled: true,
|
|
tableName: 'memory_embeddings',
|
|
pool: {
|
|
async query(sql, params) {
|
|
queries.push({ sql, params });
|
|
return {
|
|
rows: [
|
|
{
|
|
id: 7,
|
|
content: '用户关注 Memory V2 的 facade 边界',
|
|
type: 'fact',
|
|
score: '0.87',
|
|
created_at: '2026-07-02T00:00:00.000Z',
|
|
},
|
|
],
|
|
};
|
|
},
|
|
},
|
|
embedQuery: async (query) => {
|
|
assert.equal(query, 'memory-chain');
|
|
return [0.25, 0.5, 0.75];
|
|
},
|
|
});
|
|
|
|
assert.equal(backend.isAvailable(), true);
|
|
const result = await backend.resolve({
|
|
userId: 'user-1',
|
|
query: 'memory-chain',
|
|
limit: 5,
|
|
});
|
|
|
|
assert.equal(queries.length, 2);
|
|
assert.match(queries[0].sql, /FROM memory_embeddings/);
|
|
assert.match(queries[0].sql, /WITH top_score/);
|
|
assert.match(queries[0].sql, /vector_candidates/);
|
|
assert.match(queries[0].sql, /recent_candidates/);
|
|
assert.deepEqual(queries[0].params, ['user-1', '[0.25,0.5,0.75]', 50, 0.15, 200]);
|
|
assert.match(queries[1].sql, /ILIKE/);
|
|
assert.deepEqual(result.semanticMemories, ['用户关注 Memory V2 的 facade 边界']);
|
|
assert.deepEqual(result.memories, [
|
|
{
|
|
id: '7',
|
|
label: 'fact',
|
|
text: '用户关注 Memory V2 的 facade 边界',
|
|
score: 0.87,
|
|
createdAt: '2026-07-02T00:00:00.000Z',
|
|
updatedAt: '2026-07-02T00:00:00.000Z',
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('pgvector hybrid ranking recovers a recent Chinese memory missed by vector top-k', async () => {
|
|
const marker = 'MEM-RECALL-NEW';
|
|
const backend = createPgvectorMemoryBackend({
|
|
enabled: true,
|
|
pool: {
|
|
async query() {
|
|
return {
|
|
rows: [
|
|
{
|
|
id: 1,
|
|
content: '用户以前关注贵州旅游攻略',
|
|
type: 'interest',
|
|
score: 0.91,
|
|
created_at: '2026-06-01T00:00:00.000Z',
|
|
updated_at: '2026-06-01T00:00:00.000Z',
|
|
},
|
|
{
|
|
id: 2,
|
|
content: `用户的记忆召回灰度测试代号是 ${marker}`,
|
|
type: 'fact',
|
|
score: -0.49,
|
|
created_at: '2026-07-22T00:00:00.000Z',
|
|
updated_at: '2026-07-22T00:00:00.000Z',
|
|
},
|
|
{
|
|
id: 3,
|
|
content: '用户偏好简洁回答',
|
|
type: 'preference',
|
|
score: 0.3,
|
|
created_at: '2026-07-21T00:00:00.000Z',
|
|
updated_at: '2026-07-21T00:00:00.000Z',
|
|
},
|
|
],
|
|
};
|
|
},
|
|
},
|
|
embedQuery: async () => [0.25, 0.5, 0.75],
|
|
});
|
|
|
|
const result = await backend.resolve({
|
|
userId: 'user-1',
|
|
query: '我之前让你记住的记忆召回灰度测试代号是什么?请只回答完整代号。',
|
|
limit: 3,
|
|
});
|
|
|
|
assert.match(result.memories[0].text, new RegExp(marker));
|
|
});
|
|
|
|
test('recallRankingWeights boosts vector only when semantic spread is visible', () => {
|
|
const { recallRankingWeights } = pgvectorMemoryBackendInternals;
|
|
const flat = recallRankingWeights('Why did Sarah close the curtains?', [
|
|
{ vectorScore: 0.41 },
|
|
{ vectorScore: 0.39 },
|
|
{ vectorScore: 0.38 },
|
|
]);
|
|
assert.deepEqual(flat, { lexical: 1, vector: 1 });
|
|
|
|
const semantic = recallRankingWeights('Why did Sarah close the curtains?', [
|
|
{ vectorScore: 0.82 },
|
|
{ vectorScore: 0.55 },
|
|
{ vectorScore: 0.41 },
|
|
]);
|
|
assert.deepEqual(semantic, { lexical: 0.45, vector: 1.55 });
|
|
});
|
|
|
|
test('pgvector RRF hybrid ranking promotes semantic vector match over topical noise', () => {
|
|
const ranked = pgvectorMemoryBackendInternals.rankHybridCandidates([
|
|
{
|
|
id: 'noise',
|
|
content: 'The curtains the curtains the curtains were recently updated in the living room',
|
|
score: 0.42,
|
|
},
|
|
{
|
|
id: 'gold',
|
|
content: 'Sarah closed the smart curtains to reduce pollen entry',
|
|
score: 0.86,
|
|
},
|
|
], 'Why did Sarah close the curtains?', 1);
|
|
assert.equal(ranked[0].id, 'gold');
|
|
});
|
|
|
|
test('pgvector keyword-only rows do not pollute vector RRF ranks', () => {
|
|
const ranked = pgvectorMemoryBackendInternals.rankHybridCandidates([
|
|
{
|
|
id: 'keyword-noise',
|
|
content: 'The curtains the curtains the curtains were recently updated in the living room',
|
|
score: null,
|
|
},
|
|
{
|
|
id: 'vector-gold',
|
|
content: 'Sarah closed the smart curtains to reduce pollen entry',
|
|
score: 0.86,
|
|
},
|
|
], 'Why did Sarah close the curtains?', 1);
|
|
assert.equal(ranked[0].id, 'vector-gold');
|
|
});
|
|
|
|
test('pgvector semantic spread attenuates zero-overlap vector-only noise', () => {
|
|
const ranked = pgvectorMemoryBackendInternals.rankHybridCandidates([
|
|
{
|
|
id: 'vector-noise',
|
|
content: 'Ambient living room humidity sensor calibration report for May',
|
|
score: 0.91,
|
|
},
|
|
{
|
|
id: 'weak-overlap-gold',
|
|
content: 'David reported his back felt sore after the morning stretch routine',
|
|
score: 0.68,
|
|
},
|
|
], "How was David's back today?", 1);
|
|
assert.equal(ranked[0].id, 'weak-overlap-gold');
|
|
});
|
|
|
|
test('pgvector hybrid ranking keeps vector order when query has no lexical overlap', () => {
|
|
const ranked = pgvectorMemoryBackendInternals.rankHybridCandidates([
|
|
{ id: 1, content: 'alpha', score: 0.2 },
|
|
{ id: 2, content: 'beta', score: 0.8 },
|
|
], '完全无关的中文查询', 2);
|
|
assert.equal(ranked[0].id, '2');
|
|
assert.equal(ranked[1].id, '1');
|
|
});
|
|
|
|
test('pgvector keyword fallback ranks ILIKE matches by lexical coverage, not recency', async () => {
|
|
const backend = createPgvectorMemoryBackend({
|
|
enabled: true,
|
|
pool: {
|
|
async query(sql) {
|
|
if (String(sql).includes('ILIKE')) {
|
|
assert.doesNotMatch(String(sql), /ORDER BY updated_at DESC/i);
|
|
return {
|
|
rows: [
|
|
{
|
|
id: 902,
|
|
content: 'Recent but weak match for curtains only',
|
|
type: 'noise',
|
|
score: 1,
|
|
created_at: '2026-09-01T00:00:00.000Z',
|
|
updated_at: '2026-09-01T00:00:00.000Z',
|
|
},
|
|
{
|
|
id: 901,
|
|
content: 'Sarah closed the smart curtains to reduce pollen entry',
|
|
type: 'fact',
|
|
score: 1,
|
|
created_at: '2026-05-01T00:00:00.000Z',
|
|
updated_at: '2026-05-01T00:00:00.000Z',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
return { rows: [] };
|
|
},
|
|
},
|
|
embedQuery: async () => [0.25, 0.5, 0.75],
|
|
});
|
|
|
|
const result = await backend.resolve({
|
|
userId: 'user-1',
|
|
query: 'Why did Sarah close the curtains?',
|
|
limit: 1,
|
|
});
|
|
|
|
assert.match(result.memories[0].text, /Sarah closed the smart curtains/);
|
|
});
|
|
|
|
test('pgvector keyword fallback retrieves topic memories missed by vector top-k', async () => {
|
|
const queries = [];
|
|
const backend = createPgvectorMemoryBackend({
|
|
enabled: true,
|
|
pool: {
|
|
async query(sql, params) {
|
|
queries.push({ sql, params });
|
|
if (String(sql).includes('ILIKE')) {
|
|
return {
|
|
rows: [{
|
|
id: 901,
|
|
content: '用户对日本战国人物德川家康感兴趣,并希望继续深入讨论',
|
|
type: 'interest',
|
|
score: 1,
|
|
created_at: '2026-08-01T13:57:00.000Z',
|
|
updated_at: '2026-08-01T13:57:00.000Z',
|
|
}],
|
|
};
|
|
}
|
|
return {
|
|
rows: [{
|
|
id: 1,
|
|
content: '用户以后只要说“帮我搜索今天国际国内热门新闻和小知识,做成页面”',
|
|
type: 'preference',
|
|
score: 0.91,
|
|
created_at: '2026-07-31T00:00:00.000Z',
|
|
updated_at: '2026-07-31T00:00:00.000Z',
|
|
}],
|
|
};
|
|
},
|
|
},
|
|
embedQuery: async () => [0.25, 0.5, 0.75],
|
|
});
|
|
|
|
const result = await backend.resolve({
|
|
userId: 'user-tang',
|
|
query: '我们继续聊聊德川家康',
|
|
limit: 2,
|
|
});
|
|
|
|
assert.match(result.memories[0].text, /德川家康/);
|
|
assert.equal(queries.some((entry) => String(entry.sql).includes('ILIKE')), true);
|
|
});
|
|
|
|
test('extractKeywordTerms keeps topic phrases and drops recall boilerplate', () => {
|
|
const terms = pgvectorMemoryBackendInternals.extractKeywordTerms('我们之前有聊过,你记得吗');
|
|
assert.equal(terms.includes('记得'), false);
|
|
assert.equal(terms.includes('我们'), false);
|
|
const topicTerms = pgvectorMemoryBackendInternals.extractKeywordTerms('我们继续聊聊德川家康');
|
|
assert.equal(topicTerms.some((term) => term.includes('德川')), true);
|
|
});
|
|
|
|
test('latinWordQueryCoverage ranks topical English content over bigram noise', () => {
|
|
const { latinWordQueryCoverage, queryScriptProfile } = pgvectorMemoryBackendInternals;
|
|
const query = 'Why did Sarah close the curtains?';
|
|
assert.equal(queryScriptProfile(query), 'latin');
|
|
const gold = 'Sarah closed the smart curtains to reduce pollen entry';
|
|
const noise = 'The curtains the curtains the curtains were recently updated';
|
|
assert.ok(
|
|
latinWordQueryCoverage(query, gold) > latinWordQueryCoverage(query, noise),
|
|
);
|
|
});
|
|
|
|
test('extractKeywordTerms drops English recall boilerplate', () => {
|
|
const terms = pgvectorMemoryBackendInternals.extractKeywordTerms('Why did Sarah close the curtains?');
|
|
assert.equal(terms.includes('why'), false);
|
|
assert.equal(terms.includes('did'), false);
|
|
assert.equal(terms.includes('the'), false);
|
|
assert.equal(terms.includes('sarah'), true);
|
|
assert.equal(terms.includes('curtains'), true);
|
|
});
|
|
|
|
test('extractKeywordTerms prioritizes proper nouns over generic English terms', () => {
|
|
const terms = pgvectorMemoryBackendInternals.extractKeywordTerms(
|
|
'Can you piece together what happened with Ethan from soccer practice until he got home?',
|
|
);
|
|
assert.equal(terms[0], 'ethan');
|
|
assert.equal(terms.includes('together'), false);
|
|
assert.equal(terms.includes('happened'), false);
|
|
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', () => {
|
|
const { selectKeywordCandidateRows, extractKeywordTerms } = pgvectorMemoryBackendInternals;
|
|
const query = 'Why did David promise Ethan extra LEGO time on weekends?';
|
|
const terms = extractKeywordTerms(query);
|
|
const goldId = 'gold-lego';
|
|
const rows = [
|
|
{ id: goldId, content: 'David promised Ethan extra LEGO time on weekends during recovery' },
|
|
...Array.from({ length: 900 }, (_entry, index) => ({
|
|
id: `noise-${index}`,
|
|
content: `David mentioned schedule item ${index} for the household calendar update`,
|
|
})),
|
|
];
|
|
const selected = selectKeywordCandidateRows(rows, query, terms, { returnLimit: 50 });
|
|
assert.ok(selected.some((row) => row.id === goldId));
|
|
});
|
|
|
|
test('selectVectorCandidateRows expands margin band beyond fixed top-N', () => {
|
|
const { selectVectorCandidateRows } = pgvectorMemoryBackendInternals;
|
|
const scored = [
|
|
{ row: { id: 'top' }, score: 0.9 },
|
|
...Array.from({ length: 120 }, (_entry, index) => ({
|
|
row: { id: `filler-${index}` },
|
|
score: 0.85 - index * 0.001,
|
|
})),
|
|
{ row: { id: 'near-gold' }, score: 0.79 },
|
|
];
|
|
const selected = selectVectorCandidateRows(scored, { baseLimit: 100, margin: 0.12, expandCap: 200 });
|
|
const ids = selected.map((entry) => entry.row.id);
|
|
assert.ok(ids.includes('near-gold'));
|
|
assert.ok(!ids.includes('filler-119') || ids.includes('near-gold'));
|
|
});
|
|
|
|
test('pgvector backend validates table names before building SQL', () => {
|
|
assert.throws(
|
|
() => createPgvectorMemoryBackend({ tableName: 'memory_embeddings;DROP TABLE users' }),
|
|
/Invalid pgvector table name/,
|
|
);
|
|
});
|
|
|
|
test('Memory V2 can expose pgvector as unavailable plugin without selecting it', async () => {
|
|
const memory = createMemoryV2({
|
|
logger: { warn() {} },
|
|
backends: [
|
|
createPgvectorMemoryBackend({ enabled: false }),
|
|
{
|
|
name: 'legacy-conversation-memory',
|
|
async resolve() {
|
|
return { memories: [{ label: 'fact', text: 'legacy survives' }] };
|
|
},
|
|
},
|
|
],
|
|
env: {
|
|
MEMORY_ENABLED: '1',
|
|
MEMORY_BACKEND: 'pgvector',
|
|
MEMORY_VECTOR_ENABLED: '1',
|
|
},
|
|
});
|
|
|
|
const status = memory.getStatus();
|
|
const result = await memory.resolve({ userId: 'user-1', query: 'memory-chain' });
|
|
|
|
assert.equal(status.vectorEnabled, true);
|
|
assert.equal(status.selectedBackend, 'legacy-conversation-memory');
|
|
assert.equal(status.backends.find((item) => item.name === 'pgvector')?.available, false);
|
|
assert.deepEqual(result.memories, [{ label: 'fact', text: 'legacy survives' }]);
|
|
});
|