Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a1225f8e9 | ||
|
|
1b51ad5f89 |
@@ -7,18 +7,6 @@ mode: "wide"
|
|||||||
<Tabs>
|
<Tabs>
|
||||||
<Tab title="Python">
|
<Tab title="Python">
|
||||||
|
|
||||||
<Update label="2026-09-25" description="v2.2.1">
|
|
||||||
|
|
||||||
**Bug Fixes:**
|
|
||||||
- **Core:** `add()` (`Memory` and `AsyncMemory`) no longer reports records the vector store rejected as successful `ADD` events. Only records that were actually inserted are written to history, entity-linked, and returned. If none of the extracted memories could be inserted, `add()` now raises `VectorStoreError` instead of returning memories that were never stored ([#7066](https://github.com/mem0ai/mem0/pull/7066))
|
|
||||||
- **Core:** Restore the context-manager protocol on `Memory` (`with Memory() as m:`) and `AsyncMemory` (`async with AsyncMemory() as m:`), which closes the instance on exit ([#7354](https://github.com/mem0ai/mem0/pull/7354))
|
|
||||||
- **LLMs:** The AWS Bedrock Anthropic path now returns the first Converse content block that carries text instead of always reading `content[0]`. Claude reasoning models can emit a `reasoningContent` block before the text block, which previously made the call fail with a `KeyError` ([#6369](https://github.com/mem0ai/mem0/pull/6369))
|
|
||||||
- **Vector Stores:** Turbopuffer filters now apply every operator (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`). Only `gte` and `lte` were read before, so any other operator was silently dropped and the query returned unfiltered results. An unsupported operator now raises `ValueError` ([#6564](https://github.com/mem0ai/mem0/pull/6564))
|
|
||||||
- **Vector Stores:** Turbopuffer `search()` scores now respect `distance_metric`. With `euclidean_squared`, the unbounded squared distance maps to `1 / (1 + distance)` instead of `1 - distance`, which went negative and inverted ranking for any distance above 1 ([#6559](https://github.com/mem0ai/mem0/pull/6559))
|
|
||||||
- **Vector Stores:** S3 Vectors `search()` scores are now metric-aware. With `euclidean`, the distance maps to `1 / (1 + distance)` instead of `max(0, 1 - distance)`, which collapsed most scores to 0 ([#6547](https://github.com/mem0ai/mem0/pull/6547))
|
|
||||||
|
|
||||||
</Update>
|
|
||||||
|
|
||||||
<Update label="2026-09-23" description="v2.2.0">
|
<Update label="2026-09-23" description="v2.2.0">
|
||||||
|
|
||||||
**New Features:**
|
**New Features:**
|
||||||
@@ -1257,16 +1245,6 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-
|
|||||||
|
|
||||||
<Tab title="TypeScript">
|
<Tab title="TypeScript">
|
||||||
|
|
||||||
<Update label="2026-09-25" description="v3.3.1">
|
|
||||||
|
|
||||||
**Bug Fixes:**
|
|
||||||
- **Config (OSS):** `ConfigManager` no longer injects OpenAI's default `baseURL` and `model` into non-OpenAI LLM providers. The defaults now apply only to `openai` and `openai_structured`, so a DeepSeek, xAI, or other provider config without an explicit `baseURL` or `model` falls back to that provider's own defaults and env vars (`DEEPSEEK_API_BASE`, `XAI_API_BASE`, ...) instead of pointing at OpenAI with an OpenAI model name ([#7350](https://github.com/mem0ai/mem0/pull/7350))
|
|
||||||
- **Vector Stores:** Turbopuffer filters now apply every operator (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`). Only the range operators were read before, so `eq`, `ne`, `in`, and `nin` were silently dropped. A `"*"` value now matches anything instead of nothing, an array value is treated as `in`, and an unsupported operator throws ([#6578](https://github.com/mem0ai/mem0/pull/6578))
|
|
||||||
- **Vector Stores:** Turbopuffer `search()` with `euclidean_squared` now maps the unbounded squared distance to `1 / (1 + distance)` instead of `1 - distance`, which went negative and inverted ranking for any distance above 1 ([#6580](https://github.com/mem0ai/mem0/pull/6580))
|
|
||||||
- **Packaging:** `pg`, `@types/pg`, and `natural` are now optional peer dependencies, and `pg` / `@types/pg` accept caret ranges instead of the exact `8.11.3` / `8.11.0` pins, so installing `mem0ai` no longer pulls in `pg` or conflicts with an app's own `pg` version. The PGVector store now imports `pg` only when it is used, so install it alongside `mem0ai` (`npm install pg`) if you use that store. `@types/jest` moved from peer dependencies to dev dependencies ([#7450](https://github.com/mem0ai/mem0/pull/7450))
|
|
||||||
|
|
||||||
</Update>
|
|
||||||
|
|
||||||
<Update label="2026-09-23" description="v3.3.0">
|
<Update label="2026-09-23" description="v3.3.0">
|
||||||
|
|
||||||
**New Features:**
|
**New Features:**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mem0ai",
|
"name": "mem0ai",
|
||||||
"version": "3.3.1",
|
"version": "3.3.0",
|
||||||
"description": "The Memory Layer For Your AI Apps",
|
"description": "The Memory Layer For Your AI Apps",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.mjs",
|
"module": "./dist/index.mjs",
|
||||||
|
|||||||
@@ -247,55 +247,24 @@ export class TurbopufferDB implements VectorStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maps mem0's universal filter operators to Turbopuffer's filter tokens.
|
|
||||||
private static readonly OPERATOR_MAP: Record<string, string> = {
|
|
||||||
eq: "Eq",
|
|
||||||
ne: "NotEq",
|
|
||||||
gt: "Gt",
|
|
||||||
gte: "Gte",
|
|
||||||
lt: "Lt",
|
|
||||||
lte: "Lte",
|
|
||||||
in: "In",
|
|
||||||
nin: "NotIn",
|
|
||||||
};
|
|
||||||
|
|
||||||
private convertFilters(filters?: SearchFilters): any {
|
private convertFilters(filters?: SearchFilters): any {
|
||||||
if (!filters || Object.keys(filters).length === 0) return null;
|
if (!filters || Object.keys(filters).length === 0) return null;
|
||||||
|
|
||||||
const conditions: any[] = [];
|
const conditions: any[] = [];
|
||||||
for (const [key, value] of Object.entries(filters)) {
|
for (const [key, value] of Object.entries(filters)) {
|
||||||
// "*" is a match-any wildcard: it must not constrain the query. The old
|
if (
|
||||||
// code turned it into `[key, "Eq", "*"]`, matching nothing.
|
typeof value === "object" &&
|
||||||
if (value === "*") {
|
value !== null &&
|
||||||
continue;
|
!Array.isArray(value)
|
||||||
}
|
) {
|
||||||
|
if ("gte" in value) conditions.push([key, "Gte", value.gte]);
|
||||||
// Array shorthand: { key: [a, b] } means "in".
|
if ("lte" in value) conditions.push([key, "Lte", value.lte]);
|
||||||
if (Array.isArray(value)) {
|
if ("gt" in value) conditions.push([key, "Gt", value.gt]);
|
||||||
conditions.push([key, "In", value]);
|
if ("lt" in value) conditions.push([key, "Lt", value.lt]);
|
||||||
continue;
|
} else {
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === "object" && value !== null) {
|
|
||||||
// Operator dict: every operator present must hold. Previously only
|
|
||||||
// `gte` and `lte` were read, so `gt`/`lt`/`ne`/`eq`/`in`/`nin` were
|
|
||||||
// silently dropped and the filter returned unfiltered results.
|
|
||||||
for (const [op, operand] of Object.entries(value)) {
|
|
||||||
const token = TurbopufferDB.OPERATOR_MAP[op];
|
|
||||||
if (!token) {
|
|
||||||
throw new Error(
|
|
||||||
`Unsupported Turbopuffer filter operator '${op}' for field '${key}'. ` +
|
|
||||||
`Supported operators: ${Object.keys(TurbopufferDB.OPERATOR_MAP).join(", ")}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
conditions.push([key, token, operand]);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scalar shorthand: equality.
|
|
||||||
conditions.push([key, "Eq", value]);
|
conditions.push([key, "Eq", value]);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (conditions.length === 0) return null;
|
if (conditions.length === 0) return null;
|
||||||
if (conditions.length === 1) return conditions[0];
|
if (conditions.length === 1) return conditions[0];
|
||||||
@@ -305,18 +274,7 @@ export class TurbopufferDB implements VectorStore {
|
|||||||
private parseRows(rows: any[]): VectorStoreResult[] {
|
private parseRows(rows: any[]): VectorStoreResult[] {
|
||||||
return rows.map((row) => {
|
return rows.map((row) => {
|
||||||
const { id, $dist, vector, ...rest } = row;
|
const { id, $dist, vector, ...rest } = row;
|
||||||
let score: number | undefined;
|
const score = $dist != null ? 1 - $dist : undefined;
|
||||||
if ($dist == null) {
|
|
||||||
score = undefined;
|
|
||||||
} else if (this.distanceMetric === "euclidean_squared") {
|
|
||||||
// euclidean_squared $dist is an unbounded squared distance, so 1 - $dist
|
|
||||||
// goes negative for any $dist > 1 and inverts ranking. Convert it to a
|
|
||||||
// bounded higher-is-better score, mirroring the milvus/baidu stores.
|
|
||||||
score = 1 / (1 + $dist);
|
|
||||||
} else {
|
|
||||||
// Cosine distance is in [0, 2]; 1 - $dist stays a meaningful similarity.
|
|
||||||
score = 1 - $dist;
|
|
||||||
}
|
|
||||||
return { id: String(id), payload: rest, score };
|
return { id: String(id), payload: rest, score };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
/// <reference types="jest" />
|
|
||||||
/**
|
|
||||||
* Turbopuffer vector store — score conversion unit tests.
|
|
||||||
*
|
|
||||||
* Drives parseRows() through the public search() API with a mocked
|
|
||||||
* @turbopuffer/turbopuffer peer, asserting the score returned per metric.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const mockQuery = jest.fn();
|
|
||||||
|
|
||||||
jest.mock("@turbopuffer/turbopuffer", () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: class {
|
|
||||||
namespace() {
|
|
||||||
return { query: mockQuery };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { TurbopufferDB } from "../src/vector_stores/turbopuffer";
|
|
||||||
|
|
||||||
function makeStore(distanceMetric?: string) {
|
|
||||||
return new TurbopufferDB({
|
|
||||||
apiKey: "test-key",
|
|
||||||
collectionName: "mem0",
|
|
||||||
...(distanceMetric ? { distanceMetric } : {}),
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function scoreFor(
|
|
||||||
distanceMetric: string | undefined,
|
|
||||||
row: Record<string, any>,
|
|
||||||
): Promise<number | undefined> {
|
|
||||||
mockQuery.mockResolvedValueOnce({ rows: [row] });
|
|
||||||
const results = await makeStore(distanceMetric).search([0.1, 0.2, 0.3], 5);
|
|
||||||
return results[0].score;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("TurbopufferDB score conversion", () => {
|
|
||||||
it("keeps cosine distance as 1 - dist (default metric)", async () => {
|
|
||||||
// cosine_distance is the default; a distance of 0.25 -> similarity 0.75.
|
|
||||||
expect(await scoreFor(undefined, { id: "a", $dist: 0.25 })).toBeCloseTo(
|
|
||||||
0.75,
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("bounds an unbounded euclidean_squared distance to a 0..1 similarity", async () => {
|
|
||||||
// $dist = 4.0 is a squared distance. 1 - 4.0 = -3.0 would invert ranking;
|
|
||||||
// 1 / (1 + 4.0) = 0.2 keeps it higher-is-better and in range.
|
|
||||||
const score = await scoreFor("euclidean_squared", { id: "a", $dist: 4.0 });
|
|
||||||
expect(score).toBeCloseTo(0.2, 10);
|
|
||||||
expect(score!).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(score!).toBeLessThanOrEqual(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ranks a nearer euclidean_squared hit above a farther one", async () => {
|
|
||||||
mockQuery.mockResolvedValueOnce({
|
|
||||||
rows: [
|
|
||||||
{ id: "near", $dist: 1.0 },
|
|
||||||
{ id: "far", $dist: 9.0 },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const results = await makeStore("euclidean_squared").search(
|
|
||||||
[0.1, 0.2, 0.3],
|
|
||||||
5,
|
|
||||||
);
|
|
||||||
const near = results.find((r) => r.id === "near")!;
|
|
||||||
const far = results.find((r) => r.id === "far")!;
|
|
||||||
expect(near.score!).toBeGreaterThan(far.score!);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves an undefined score when the row has no distance", async () => {
|
|
||||||
expect(await scoreFor("euclidean_squared", { id: "a" })).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
/// <reference types="jest" />
|
|
||||||
/**
|
|
||||||
* Turbopuffer vector store — filter translation unit tests.
|
|
||||||
*
|
|
||||||
* Drives the private convertFilters() through the public search() API with a
|
|
||||||
* mocked @turbopuffer/turbopuffer peer, and asserts the filter tuple handed to
|
|
||||||
* ns.query().
|
|
||||||
*/
|
|
||||||
|
|
||||||
const mockQuery = jest.fn().mockResolvedValue({ rows: [] });
|
|
||||||
|
|
||||||
// createClient() does `new sdk.default({...})`, whose namespace() returns the
|
|
||||||
// object search() calls query() on.
|
|
||||||
jest.mock("@turbopuffer/turbopuffer", () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: class {
|
|
||||||
namespace() {
|
|
||||||
return { query: mockQuery };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { TurbopufferDB } from "../src/vector_stores/turbopuffer";
|
|
||||||
|
|
||||||
function makeStore() {
|
|
||||||
return new TurbopufferDB({
|
|
||||||
apiKey: "test-key",
|
|
||||||
collectionName: "mem0",
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function filterFor(filters: any): Promise<any> {
|
|
||||||
mockQuery.mockClear();
|
|
||||||
await makeStore().search([0.1, 0.2, 0.3], 5, filters);
|
|
||||||
return mockQuery.mock.calls[0][0].filters;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("TurbopufferDB convertFilters", () => {
|
|
||||||
it("maps every operator, not just gte/lte", async () => {
|
|
||||||
expect(await filterFor({ age: { gt: 18 } })).toEqual(["age", "Gt", 18]);
|
|
||||||
expect(await filterFor({ age: { lt: 65 } })).toEqual(["age", "Lt", 65]);
|
|
||||||
expect(await filterFor({ age: { ne: 40 } })).toEqual(["age", "NotEq", 40]);
|
|
||||||
expect(await filterFor({ role: { eq: "admin" } })).toEqual([
|
|
||||||
"role",
|
|
||||||
"Eq",
|
|
||||||
"admin",
|
|
||||||
]);
|
|
||||||
expect(await filterFor({ tier: { in: ["a", "b"] } })).toEqual([
|
|
||||||
"tier",
|
|
||||||
"In",
|
|
||||||
["a", "b"],
|
|
||||||
]);
|
|
||||||
expect(await filterFor({ tier: { nin: ["x"] } })).toEqual([
|
|
||||||
"tier",
|
|
||||||
"NotIn",
|
|
||||||
["x"],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies every operator in a compound range (AND), not just the first", async () => {
|
|
||||||
const filter = await filterFor({ age: { gt: 18, lt: 65 } });
|
|
||||||
expect(filter[0]).toBe("And");
|
|
||||||
expect(filter[1]).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
["age", "Gt", 18],
|
|
||||||
["age", "Lt", 65],
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a bare array value as an 'in' filter", async () => {
|
|
||||||
expect(await filterFor({ tier: ["gold", "silver"] })).toEqual([
|
|
||||||
"tier",
|
|
||||||
"In",
|
|
||||||
["gold", "silver"],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps scalar equality working", async () => {
|
|
||||||
expect(await filterFor({ user_id: "u1" })).toEqual(["user_id", "Eq", "u1"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips a '*' wildcard value instead of matching it literally", async () => {
|
|
||||||
// Only the real agent_id clause survives; user_id: "*" contributes nothing.
|
|
||||||
expect(await filterFor({ user_id: "*", agent_id: "a1" })).toEqual([
|
|
||||||
"agent_id",
|
|
||||||
"Eq",
|
|
||||||
"a1",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws on an unsupported operator rather than silently dropping it", async () => {
|
|
||||||
await expect(
|
|
||||||
makeStore().search([0.1, 0.2, 0.3], 5, { name: { startsWith: "a" } }),
|
|
||||||
).rejects.toThrow(/Unsupported Turbopuffer filter operator 'startsWith'/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -579,20 +579,11 @@ class AWSBedrockLLM(LLMBase):
|
|||||||
# Use converse API for Anthropic models
|
# Use converse API for Anthropic models
|
||||||
response = self.client.converse(**converse_params)
|
response = self.client.converse(**converse_params)
|
||||||
|
|
||||||
# Parse Converse API response. Claude reasoning models can emit a
|
# Parse Converse API response
|
||||||
# `reasoningContent` block before the `text` block, so iterate to
|
|
||||||
# find the first block that carries text instead of indexing
|
|
||||||
# content[0] (same approach as the MiniMax branch below).
|
|
||||||
if hasattr(response, 'output') and hasattr(response.output, 'message'):
|
if hasattr(response, 'output') and hasattr(response.output, 'message'):
|
||||||
for block in response.output.message.content:
|
return response.output.message.content[0].text
|
||||||
if hasattr(block, 'text'):
|
|
||||||
return block.text
|
|
||||||
return ""
|
|
||||||
elif 'output' in response and 'message' in response['output']:
|
elif 'output' in response and 'message' in response['output']:
|
||||||
for block in response['output']['message']['content']:
|
return response['output']['message']['content'][0]['text']
|
||||||
if 'text' in block:
|
|
||||||
return block['text']
|
|
||||||
return ""
|
|
||||||
else:
|
else:
|
||||||
return str(response)
|
return str(response)
|
||||||
|
|
||||||
|
|||||||
@@ -69,16 +69,6 @@ class S3Vectors(VectorStoreBase):
|
|||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def _distance_to_score(self, raw_distance: Optional[float]) -> Optional[float]:
|
|
||||||
if raw_distance is None:
|
|
||||||
return None
|
|
||||||
# Euclidean distance is unbounded, so 1 - distance would collapse most
|
|
||||||
# scores to 0. Use a bounded monotonic map instead. Cosine distance is
|
|
||||||
# in [0, 2], where 1 - distance is already a valid similarity.
|
|
||||||
if self.distance_metric == "euclidean":
|
|
||||||
return 1.0 / (1.0 + raw_distance)
|
|
||||||
return max(0.0, 1.0 - raw_distance)
|
|
||||||
|
|
||||||
def _parse_output(self, vectors: List[Dict]) -> List[OutputData]:
|
def _parse_output(self, vectors: List[Dict]) -> List[OutputData]:
|
||||||
results = []
|
results = []
|
||||||
for v in vectors:
|
for v in vectors:
|
||||||
@@ -91,7 +81,7 @@ class S3Vectors(VectorStoreBase):
|
|||||||
logger.warning(f"Failed to parse metadata for key {v.get('key')}")
|
logger.warning(f"Failed to parse metadata for key {v.get('key')}")
|
||||||
payload = {}
|
payload = {}
|
||||||
raw_distance = v.get("distance")
|
raw_distance = v.get("distance")
|
||||||
score = self._distance_to_score(raw_distance)
|
score = max(0.0, 1.0 - raw_distance) if raw_distance is not None else None
|
||||||
results.append(OutputData(id=v.get("key"), score=score, payload=payload))
|
results.append(OutputData(id=v.get("key"), score=score, payload=payload))
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|||||||
@@ -122,14 +122,7 @@ class TurbopufferDB(VectorStoreBase):
|
|||||||
dist = row_dict.pop("$dist", None)
|
dist = row_dict.pop("$dist", None)
|
||||||
row_dict.pop("vector", None)
|
row_dict.pop("vector", None)
|
||||||
|
|
||||||
if dist is None:
|
score = 1 - dist if dist is not None else None
|
||||||
score = None
|
|
||||||
elif self.distance_metric == "euclidean_squared":
|
|
||||||
# $dist is unbounded squared-L2 (lower = closer); map to a bounded
|
|
||||||
# higher-is-better score, mirroring milvus/baidu. Cosine returns 1 - dist.
|
|
||||||
score = 1.0 / (1.0 + dist)
|
|
||||||
else:
|
|
||||||
score = 1 - dist
|
|
||||||
|
|
||||||
results.append(OutputData(
|
results.append(OutputData(
|
||||||
id=row_id,
|
id=row_id,
|
||||||
@@ -138,18 +131,6 @@ class TurbopufferDB(VectorStoreBase):
|
|||||||
))
|
))
|
||||||
return results
|
return results
|
||||||
|
|
||||||
# Maps mem0 filter operators to their Turbopuffer equivalents.
|
|
||||||
OPERATOR_MAP = {
|
|
||||||
"eq": "Eq",
|
|
||||||
"ne": "NotEq",
|
|
||||||
"gt": "Gt",
|
|
||||||
"gte": "Gte",
|
|
||||||
"lt": "Lt",
|
|
||||||
"lte": "Lte",
|
|
||||||
"in": "In",
|
|
||||||
"nin": "NotIn",
|
|
||||||
}
|
|
||||||
|
|
||||||
def _convert_filters(self, filters: Optional[Dict]):
|
def _convert_filters(self, filters: Optional[Dict]):
|
||||||
"""
|
"""
|
||||||
Convert mem0 filters to Turbopuffer filter format.
|
Convert mem0 filters to Turbopuffer filter format.
|
||||||
@@ -162,14 +143,10 @@ class TurbopufferDB(VectorStoreBase):
|
|||||||
conditions = []
|
conditions = []
|
||||||
for key, value in filters.items():
|
for key, value in filters.items():
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
for op, operand in value.items():
|
if "gte" in value:
|
||||||
tpuf_op = self.OPERATOR_MAP.get(op)
|
conditions.append((key, "Gte", value["gte"]))
|
||||||
if tpuf_op is None:
|
if "lte" in value:
|
||||||
raise ValueError(
|
conditions.append((key, "Lte", value["lte"]))
|
||||||
f"Unsupported filter operator '{op}' for field '{key}'. "
|
|
||||||
f"Supported operators: {sorted(self.OPERATOR_MAP)}"
|
|
||||||
)
|
|
||||||
conditions.append((key, tpuf_op, operand))
|
|
||||||
else:
|
else:
|
||||||
conditions.append((key, "Eq", value))
|
conditions.append((key, "Eq", value))
|
||||||
|
|
||||||
|
|||||||
+2
-10
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "mem0ai"
|
name = "mem0ai"
|
||||||
version = "2.2.1"
|
version = "2.2.0"
|
||||||
description = "Long-term memory for AI Agents"
|
description = "Long-term memory for AI Agents"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Mem0", email = "support@mem0.ai" }
|
{ name = "Mem0", email = "support@mem0.ai" }
|
||||||
@@ -83,7 +83,7 @@ test = [
|
|||||||
dev = [
|
dev = [
|
||||||
"ruff==0.16.0",
|
"ruff==0.16.0",
|
||||||
"isort>=5.13.2",
|
"isort>=5.13.2",
|
||||||
"pre-commit>=3.5.0",
|
"pytest>=8.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
@@ -111,7 +111,6 @@ features = [
|
|||||||
"vector-stores",
|
"vector-stores",
|
||||||
"llms",
|
"llms",
|
||||||
"extras",
|
"extras",
|
||||||
"dev",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.hatch.envs.dev_py_3_11]
|
[tool.hatch.envs.dev_py_3_11]
|
||||||
@@ -121,7 +120,6 @@ features = [
|
|||||||
"vector-stores",
|
"vector-stores",
|
||||||
"llms",
|
"llms",
|
||||||
"extras",
|
"extras",
|
||||||
"dev",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.hatch.envs.dev_py_3_12]
|
[tool.hatch.envs.dev_py_3_12]
|
||||||
@@ -131,12 +129,6 @@ features = [
|
|||||||
"vector-stores",
|
"vector-stores",
|
||||||
"llms",
|
"llms",
|
||||||
"extras",
|
"extras",
|
||||||
"dev",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.hatch.envs.default]
|
|
||||||
features = [
|
|
||||||
"dev",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.hatch.envs.default.scripts]
|
[tool.hatch.envs.default.scripts]
|
||||||
|
|||||||
@@ -506,51 +506,3 @@ class TestParseResponseLegacy:
|
|||||||
response = {"body": body}
|
response = {"body": body}
|
||||||
result = llm._parse_response(response, tools=None)
|
result = llm._parse_response(response, tools=None)
|
||||||
assert result == "hello from ai21"
|
assert result == "hello from ai21"
|
||||||
|
|
||||||
|
|
||||||
class TestAnthropicConverseContentParsing:
|
|
||||||
"""The Anthropic Converse branch must not assume content[0] is the text
|
|
||||||
block: Claude reasoning models emit a reasoningContent block before the
|
|
||||||
text block, and some stop conditions produce an empty content array. The
|
|
||||||
parser iterates for the first block carrying text, like the MiniMax branch.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_text_after_reasoning_content_block(self, mock_boto3):
|
|
||||||
mock_boto3.converse.return_value = {
|
|
||||||
"output": {
|
|
||||||
"message": {
|
|
||||||
"content": [
|
|
||||||
{"reasoningContent": {"reasoningText": {"text": "step by step..."}}},
|
|
||||||
{"text": "final answer"},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3)
|
|
||||||
|
|
||||||
assert llm.generate_response(MESSAGES) == "final answer"
|
|
||||||
|
|
||||||
def test_empty_content_returns_empty_string(self, mock_boto3):
|
|
||||||
mock_boto3.converse.return_value = {"output": {"message": {"content": []}}}
|
|
||||||
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3)
|
|
||||||
|
|
||||||
assert llm.generate_response(MESSAGES) == ""
|
|
||||||
|
|
||||||
def test_plain_text_content_still_returned(self, mock_boto3):
|
|
||||||
mock_boto3.converse.return_value = _converse_response("plain answer")
|
|
||||||
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3)
|
|
||||||
|
|
||||||
assert llm.generate_response(MESSAGES) == "plain answer"
|
|
||||||
|
|
||||||
def test_object_style_response_iterates_blocks(self, mock_boto3):
|
|
||||||
# Defensive attr-style branch: object wrapper with a reasoning block first.
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
reasoning_block = SimpleNamespace(reasoningContent={"reasoningText": {"text": "hmm"}})
|
|
||||||
text_block = SimpleNamespace(text="object answer")
|
|
||||||
mock_boto3.converse.return_value = SimpleNamespace(
|
|
||||||
output=SimpleNamespace(message=SimpleNamespace(content=[reasoning_block, text_block]))
|
|
||||||
)
|
|
||||||
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3)
|
|
||||||
|
|
||||||
assert llm.generate_response(MESSAGES) == "object answer"
|
|
||||||
|
|||||||
@@ -186,45 +186,6 @@ def test_search(mock_boto_client):
|
|||||||
assert results[0].score == pytest.approx(0.1)
|
assert results[0].score == pytest.approx(0.1)
|
||||||
|
|
||||||
|
|
||||||
def test_search_score_cosine_uses_one_minus_distance(mock_boto_client):
|
|
||||||
"""Cosine metric keeps the 1 - distance similarity mapping."""
|
|
||||||
mock_boto_client.query_vectors.return_value = {"vectors": [{"key": "id1", "distance": 0.25, "metadata": {}}]}
|
|
||||||
store = S3Vectors(
|
|
||||||
vector_bucket_name=BUCKET_NAME,
|
|
||||||
collection_name=INDEX_NAME,
|
|
||||||
embedding_model_dims=EMBEDDING_DIMS,
|
|
||||||
distance_metric="cosine",
|
|
||||||
)
|
|
||||||
|
|
||||||
results = store.search(query="test", vectors=[0.1, 0.2], top_k=1)
|
|
||||||
|
|
||||||
assert results[0].score == pytest.approx(0.75)
|
|
||||||
|
|
||||||
|
|
||||||
def test_search_score_euclidean_uses_bounded_map(mock_boto_client):
|
|
||||||
"""Euclidean distance is unbounded; scores must not collapse to 0."""
|
|
||||||
mock_boto_client.query_vectors.return_value = {
|
|
||||||
"vectors": [
|
|
||||||
{"key": "near", "distance": 0.5, "metadata": {}},
|
|
||||||
{"key": "far", "distance": 4.0, "metadata": {}},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
store = S3Vectors(
|
|
||||||
vector_bucket_name=BUCKET_NAME,
|
|
||||||
collection_name=INDEX_NAME,
|
|
||||||
embedding_model_dims=EMBEDDING_DIMS,
|
|
||||||
distance_metric="euclidean",
|
|
||||||
)
|
|
||||||
|
|
||||||
results = store.search(query="test", vectors=[0.1, 0.2], top_k=2)
|
|
||||||
|
|
||||||
# 1 / (1 + d): a distance > 1 would give a negative (clamped 0) score under
|
|
||||||
# the old cosine-only formula; the bounded map keeps ranking intact.
|
|
||||||
assert results[0].score == pytest.approx(1.0 / 1.5)
|
|
||||||
assert results[1].score == pytest.approx(1.0 / 5.0)
|
|
||||||
assert results[0].score > results[1].score > 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_get(mock_boto_client):
|
def test_get(mock_boto_client):
|
||||||
"""Test retrieving a vector by ID."""
|
"""Test retrieving a vector by ID."""
|
||||||
mock_boto_client.get_vectors.return_value = {
|
mock_boto_client.get_vectors.return_value = {
|
||||||
|
|||||||
@@ -232,35 +232,6 @@ class TestParseOutput:
|
|||||||
def test_parse_empty_rows(self, db):
|
def test_parse_empty_rows(self, db):
|
||||||
assert db._parse_output([]) == []
|
assert db._parse_output([]) == []
|
||||||
|
|
||||||
def test_parse_cosine_score_is_one_minus_dist(self, db):
|
|
||||||
# Default cosine metric: score = 1 - dist, unchanged by the metric fix.
|
|
||||||
results = db._parse_output([_make_row("id1", dist=0.25)])
|
|
||||||
assert results[0].score == pytest.approx(0.75)
|
|
||||||
|
|
||||||
def test_parse_euclidean_squared_score_is_bounded(self, mock_client):
|
|
||||||
# euclidean_squared $dist is unbounded (e.g. 4.0). 1 - dist would give -3.0,
|
|
||||||
# violating the higher-is-better contract; map it to 1/(1+dist) instead.
|
|
||||||
db = TurbopufferDB(
|
|
||||||
collection_name="test_ns",
|
|
||||||
embedding_model_dims=4,
|
|
||||||
api_key="tpuf_test_key",
|
|
||||||
region="gcp-us-central1",
|
|
||||||
distance_metric="euclidean_squared",
|
|
||||||
)
|
|
||||||
results = db._parse_output([_make_row("id1", dist=4.0)])
|
|
||||||
assert results[0].score == pytest.approx(0.2)
|
|
||||||
assert 0.0 <= results[0].score <= 1.0
|
|
||||||
|
|
||||||
def test_parse_euclidean_squared_preserves_none(self, mock_client):
|
|
||||||
db = TurbopufferDB(
|
|
||||||
collection_name="test_ns",
|
|
||||||
embedding_model_dims=4,
|
|
||||||
api_key="tpuf_test_key",
|
|
||||||
region="gcp-us-central1",
|
|
||||||
distance_metric="euclidean_squared",
|
|
||||||
)
|
|
||||||
assert db._parse_output([_make_row("id1")])[0].score is None
|
|
||||||
|
|
||||||
|
|
||||||
# ── _convert_filters ─────────────────────────────────────────────────
|
# ── _convert_filters ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -305,40 +276,6 @@ class TestConvertFilters:
|
|||||||
assert ("user_id", "Eq", "u1") in conditions
|
assert ("user_id", "Eq", "u1") in conditions
|
||||||
assert ("score", "Gte", 0.5) in conditions
|
assert ("score", "Gte", 0.5) in conditions
|
||||||
|
|
||||||
def test_gt_operator_not_dropped(self, db):
|
|
||||||
"""Regression: {"gt": ...} was silently dropped, returning unfiltered results."""
|
|
||||||
result = db._convert_filters({"age": {"gt": 18}})
|
|
||||||
assert result == ("age", "Gt", 18)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"op,expected_token",
|
|
||||||
[
|
|
||||||
("eq", "Eq"),
|
|
||||||
("ne", "NotEq"),
|
|
||||||
("gt", "Gt"),
|
|
||||||
("gte", "Gte"),
|
|
||||||
("lt", "Lt"),
|
|
||||||
("lte", "Lte"),
|
|
||||||
("in", "In"),
|
|
||||||
("nin", "NotIn"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_all_operators_mapped(self, db, op, expected_token):
|
|
||||||
operand = [1, 2] if op in ("in", "nin") else 5
|
|
||||||
result = db._convert_filters({"age": {op: operand}})
|
|
||||||
assert result == ("age", expected_token, operand)
|
|
||||||
|
|
||||||
def test_multiple_operators_on_one_field(self, db):
|
|
||||||
result = db._convert_filters({"age": {"gt": 18, "lt": 65}})
|
|
||||||
assert result[0] == "And"
|
|
||||||
conditions = result[1]
|
|
||||||
assert ("age", "Gt", 18) in conditions
|
|
||||||
assert ("age", "Lt", 65) in conditions
|
|
||||||
|
|
||||||
def test_unknown_operator_raises(self, db):
|
|
||||||
with pytest.raises(ValueError, match="Unsupported filter operator"):
|
|
||||||
db._convert_filters({"age": {"between": [1, 2]}})
|
|
||||||
|
|
||||||
|
|
||||||
# ── search ───────────────────────────────────────────────────────────
|
# ── search ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user