Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 98 additions & 6 deletions mem0-ts/src/oss/src/memory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,42 @@ import { logger } from "../utils/logger";
import { normalizeExpirationDate, payloadIsExpired } from "../utils/expiration";
import { getOrCreateMem0UserId } from "../../../client/config";

function matchesSimpleRescueFilters(
payload: Record<string, any>,
filters: Record<string, any>,
): boolean {
if (
!payload ||
typeof payload !== "object" ||
!filters ||
typeof filters !== "object"
) {
return false;
}

return Object.entries(filters).every(([key, expected]) => {
if (
key === "$or" ||
key === "$and" ||
key === "$not" ||
key === "OR" ||
key === "AND" ||
key === "NOT"
)
return false;
if (
expected === "*" ||
Array.isArray(expected) ||
(expected !== null && typeof expected === "object")
)
return false;
return (
Object.prototype.hasOwnProperty.call(payload, key) &&
payload[key] === expected
);
});
}

export class LLMError extends Error {
readonly cause?: unknown;

Expand Down Expand Up @@ -1548,13 +1584,69 @@ export class Memory {
}

// Step 7: Build candidate set from semantic results
const candidates = semanticResults
.filter((mem) => showExpired || !payloadIsExpired(mem.payload))
.map((mem) => ({
id: String(mem.id),
score: mem.score ?? 0,
const candidates: Array<{
id: string;
score?: number;
payload: Record<string, any>;
isEntityRescue?: boolean;
}> = [];
const candidateIds = new Set<string>();
for (const mem of semanticResults) {
const id = String(mem.id);
if (
candidateIds.has(id) ||
(!showExpired && payloadIsExpired(mem.payload))
) {
continue;
}
candidateIds.add(id);
candidates.push({
id,
score: mem.score,
payload: mem.payload || {},
}));
});
}

const rescueIds = Object.entries(entityBoosts)
.filter(([id, boost]) => boost > 0 && !candidateIds.has(id))
.sort(([leftId, leftBoost], [rightId, rightBoost]) => {
return rightBoost - leftBoost || leftId.localeCompare(rightId);
})
.slice(0, Math.min(internalLimit, 60))
.map(([id]) => id);

for (const memoryId of rescueIds) {
let fetched;
try {
fetched = await this.vectorStore.get(memoryId);
} catch (error) {
console.warn(
`Entity-linked point fetch failed for ${memoryId}:`,
error,
);
continue;
}

const payload = fetched?.payload;
if (
!payload ||
typeof payload !== "object" ||
typeof payload.data !== "string" ||
!payload.data ||
!matchesSimpleRescueFilters(payload, effectiveFilters) ||
(!showExpired && payloadIsExpired(payload))
) {
continue;
}

candidates.push({
id: memoryId,
score: undefined,
payload,
isEntityRescue: true,
});
candidateIds.add(memoryId);
}

// Step 8: Score and rank
const scoredResults = scoreAndRank(
Expand Down
25 changes: 17 additions & 8 deletions mem0-ts/src/oss/src/utils/scoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ export interface ScoredResult {
* For each candidate:
* combined = (semantic + bm25 + entity_boost) / max_possible
*
* Threshold gates the semantic score BEFORE combining -- candidates
* below the threshold are excluded even if BM25/entity would boost them.
* Threshold gates numeric semantic scores before combining. Candidates with
* no semantic score require a positive entity boost to enter this scorer.
*
* The divisor adapts based on which signals are active:
* - Semantic only: max_possible = 1.0
Expand All @@ -99,8 +99,9 @@ export interface ScoredResult {
export function scoreAndRank(
semanticResults: Array<{
id: string;
score: number;
score?: number;
payload: Record<string, any>;
isEntityRescue?: boolean;
}>,
bm25Scores: Record<string, number>,
entityBoosts: Record<string, number>,
Expand All @@ -127,14 +128,22 @@ export function scoreAndRank(
continue;
}

const semanticScore = result.score ?? 0.0;
if (semanticScore < threshold) {
continue;
}

const memIdStr = String(memId);
const bm25Score = bm25Scores[memIdStr] ?? 0.0;
const entityBoost = entityBoosts[memIdStr] ?? 0.0;
const rawSemanticScore = result.score;
const semanticScore = rawSemanticScore ?? 0.0;
const isEntityRescue = result.isEntityRescue === true;
if (isEntityRescue) {
if (
entityBoost <= 0 ||
(rawSemanticScore != null && semanticScore < threshold)
) {
continue;
}
} else if (semanticScore < threshold) {
continue;
}

const rawCombined = semanticScore + bm25Score + entityBoost;
const combined = Math.min(rawCombined / maxPossible, 1.0);
Expand Down
168 changes: 168 additions & 0 deletions mem0-ts/src/oss/tests/memory.entity-boost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,174 @@ describe("Entity boost parallelism (#5214)", () => {
warnSpy.mockRestore();
});

it("should rescue linked points outside the semantic pool and fail closed", async () => {
const m = memory as any;
await m._ensureInitialized();

m._entityStore = {
search: jest
.fn()
.mockResolvedValue([
makeMatch("e-alice", 0.9, [
"mem-primary",
"mem-rescued",
"mem-rescued",
"mem-wrong-scope",
"mem-wrong-filter",
"mem-expired",
"mem-malformed",
"mem-missing",
"mem-throws",
]),
]),
initialize: jest.fn().mockResolvedValue(undefined),
};
m.embedder = {
embed: jest.fn().mockResolvedValue(mockEmbedding),
embedBatch: jest
.fn()
.mockImplementation((texts: string[]) =>
Promise.resolve(texts.map(() => mockEmbedding)),
),
};
m.vectorStore.search = jest.fn().mockResolvedValue([
{
id: "mem-primary",
score: 0.8,
payload: { data: "primary", user_id: "u1", topic: "keep" },
},
{
id: "mem-primary",
score: 0.8,
payload: { data: "primary", user_id: "u1", topic: "keep" },
},
]);
m.vectorStore.keywordSearch = jest.fn().mockResolvedValue(null);
m.vectorStore.get = jest.fn().mockImplementation(async (id: string) => {
if (id === "mem-throws") throw new Error("point fetch failed");
const payloads: Record<string, Record<string, any> | null> = {
"mem-rescued": {
data: "rescued",
user_id: "u1",
topic: "keep",
memory_type: "procedural_memory",
not: "keep",
rank: true,
},
"mem-wrong-scope": { data: "wrong", user_id: "u2", topic: "keep" },
"mem-wrong-filter": { data: "wrong", user_id: "u1", topic: "drop" },
"mem-expired": {
data: "expired",
user_id: "u1",
topic: "keep",
expiration_date: "2000-01-01",
},
"mem-malformed": null,
};
const payload = payloads[id];
return payload === undefined ? null : { id, payload };
});

const result = await m.search("Alice Smith", {
filters: { user_id: "u1", topic: "keep" },
});
const resultIds = result.results.map((item: { id: string }) => item.id);

expect(resultIds.filter((id: string) => id === "mem-rescued")).toHaveLength(
1,
);
expect(resultIds.filter((id: string) => id === "mem-primary")).toHaveLength(
1,
);
const rescued = result.results.find(
(item: { id: string; metadata?: Record<string, any> }) =>
item.id === "mem-rescued",
);
expect(rescued?.metadata?.memory_type).toBe("procedural_memory");
expect(resultIds).not.toContain("mem-wrong-scope");
expect(resultIds).not.toContain("mem-wrong-filter");
expect(resultIds).not.toContain("mem-expired");
expect(resultIds).not.toContain("mem-malformed");
expect(resultIds).not.toContain("mem-missing");
expect(resultIds).not.toContain("mem-throws");
expect(m.vectorStore.get).not.toHaveBeenCalledWith("mem-primary");

for (const advancedValue of [
{ eq: "keep" },
["keep"],
{ $or: [{ topic: "keep" }] },
"*",
]) {
const advancedResult = await m.search("Alice Smith", {
filters: { user_id: "u1", topic: advancedValue },
});
expect(
advancedResult.results.map((item: { id: string }) => item.id),
).not.toContain("mem-rescued");
}

const metadataKeyResult = await m.search("Alice Smith", {
filters: { user_id: "u1", not: "keep" },
});
expect(
metadataKeyResult.results.map((item: { id: string }) => item.id),
).toContain("mem-rescued");
const typeMismatchResult = await m.search("Alice Smith", {
filters: { user_id: "u1", rank: 1 },
});
expect(
typeMismatchResult.results.map((item: { id: string }) => item.id),
).not.toContain("mem-rescued");
const missingFieldResult = await m.search("Alice Smith", {
filters: { user_id: "u1", missing: "value" },
});
expect(
missingFieldResult.results.map((item: { id: string }) => item.id),
).not.toContain("mem-rescued");
});

it("bounds rescue point fetches independently of topK", async () => {
const m = memory as any;
await m._ensureInitialized();
const linkedIds = Array.from(
{ length: 75 },
(_, index) => `rescued-${index}`,
);
m._entityStore = {
search: jest
.fn()
.mockResolvedValue([makeMatch("e-alice", 0.9, linkedIds)]),
initialize: jest.fn().mockResolvedValue(undefined),
};
m.embedder = {
embed: jest.fn().mockResolvedValue(mockEmbedding),
embedBatch: jest
.fn()
.mockImplementation((texts: string[]) =>
Promise.resolve(texts.map(() => mockEmbedding)),
),
};
m.vectorStore.search = jest.fn().mockResolvedValue([
{
id: "mem-primary",
score: 0.8,
payload: { data: "primary", user_id: "u1" },
},
]);
m.vectorStore.keywordSearch = jest.fn().mockResolvedValue(null);
m.vectorStore.get = jest.fn().mockImplementation(async (id: string) => ({
id,
payload: { data: id, user_id: "u1" },
}));

await m.search("Alice Smith", { filters: { user_id: "u1" }, topK: 100 });

expect(m.vectorStore.get).toHaveBeenCalledTimes(60);
m.vectorStore.get.mockClear();
await m.search("Alice Smith", { filters: { user_id: "u1" }, topK: 1 });
expect(m.vectorStore.get).toHaveBeenCalledTimes(60);
});

it("should call entity searches concurrently, not sequentially", async () => {
const m = memory as any;
await m._ensureInitialized();
Expand Down
Loading
Loading