feat(ts-sdk): add Upstash Vector vector store (#5811)

Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
This commit is contained in:
AxelRay
2026-07-07 01:11:05 +07:00
committed by GitHub
parent c944bed460
commit 9d36b2c94d
12 changed files with 501 additions and 1 deletions
@@ -8,6 +8,10 @@ description: "Use Upstash Vector as a serverless vector database in Mem0 with op
You can enable the built-in embedding models by setting `enable_embeddings` to `True`. This allows you to use Upstash's embedding models for vectorization.
<Note>
Server-side Upstash embeddings (`enable_embeddings`) are available in the Python SDK only. The TypeScript SDK always embeds text with your configured embedder before writing to Upstash, so use the external embedding provider setup below.
</Note>
```python
import os
from mem0 import Memory
@@ -34,7 +38,8 @@ m.add("Likes to play cricket on weekends", user_id="alice", metadata={"category"
### Usage with external embedding providers
```python
<CodeGroup>
```python Python
import os
from mem0 import Memory
@@ -58,6 +63,36 @@ m = Memory.from_config(config)
m.add("Likes to play cricket on weekends", user_id="alice", metadata={"category": "hobbies"})
```
```typescript TypeScript
import { Memory } from "mem0ai/oss";
// Set OPENAI_API_KEY, UPSTASH_VECTOR_REST_URL, and UPSTASH_VECTOR_REST_TOKEN in your environment.
const config = {
embedder: {
provider: "openai",
config: {
apiKey: process.env.OPENAI_API_KEY,
model: "text-embedding-3-large",
},
},
vectorStore: {
provider: "upstash_vector",
config: {
collectionName: "memories",
url: process.env.UPSTASH_VECTOR_REST_URL,
token: process.env.UPSTASH_VECTOR_REST_TOKEN,
},
},
};
const memory = new Memory(config);
await memory.add("Likes to play cricket on weekends", {
userId: "alice",
metadata: { category: "hobbies" },
});
```
</CodeGroup>
### Config
Here are the parameters available for configuring Upstash Vector:
@@ -74,3 +109,7 @@ Here are the parameters available for configuring Upstash Vector:
When `url` and `token` are not provided, the `UPSTASH_VECTOR_REST_URL` and
`UPSTASH_VECTOR_REST_TOKEN` environment variables are used.
</Note>
<Note>
The TypeScript SDK uses camelCase config keys (`collectionName`, `url`, `token`), where `collectionName` is required. Pass `url` and `token` (or a preconfigured `client`) explicitly, since the TypeScript SDK does not read them from environment variables. `enable_embeddings` is not supported in TypeScript.
</Note>
+1
View File
@@ -122,6 +122,7 @@
"@turbopuffer/turbopuffer": "^2.0.0",
"@types/jest": "29.5.14",
"@types/pg": "8.11.0",
"@upstash/vector": "^1.2.3",
"better-sqlite3": "^12.6.2",
"cassandra-driver": "4.8.0",
"cloudflare": "^4.2.0",
+8
View File
@@ -74,6 +74,9 @@ importers:
'@types/pg':
specifier: 8.11.0
version: 8.11.0
'@upstash/vector':
specifier: ^1.2.3
version: 1.2.3
axios:
specifier: ^1.16.0
version: 1.17.0
@@ -1366,6 +1369,9 @@ packages:
resolution: {integrity: sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og==}
engines: {node: '>=20.0.0'}
'@upstash/vector@1.2.3':
resolution: {integrity: sha512-yXsWKeuHNYyH72BcSZd3bV5ZD5MybAoTvKxkMaeV2UzuGfNzbHBVh5eO+ysTWTFAf8I9XcOueF4tZfAGjCa4Iw==}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
@@ -5234,6 +5240,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@upstash/vector@1.2.3': {}
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
+1
View File
@@ -16,6 +16,7 @@
"@anthropic-ai/sdk": "^0.18.0",
"@google/genai": "^0.7.0",
"@qdrant/js-client-rest": "^1.13.0",
"@upstash/vector": "^1.2.3",
"@types/node": "^20.11.19",
"@types/pg": "^8.11.0",
"@types/redis": "^4.0.10",
+3
View File
@@ -32,8 +32,11 @@ export * from "./vector_stores/langchain";
export * from "./vector_stores/vectorize";
export * from "./vector_stores/azure_ai_search";
export * from "./vector_stores/pgvector";
export * from "./vector_stores/upstash_vector";
export * from "./vector_stores/azure_mysql";
export * from "./vector_stores/cassandra";
export * from "./vector_stores/s3_vectors";
export * from "./vector_stores/vertex_ai_vector_search";
export * from "./vector_stores/pinecone";
export * from "./vector_stores/turbopuffer";
export * from "./utils/factory";
+3
View File
@@ -42,6 +42,7 @@ import { LangchainEmbedder } from "../embeddings/langchain";
import { LangchainVectorStore } from "../vector_stores/langchain";
import { AzureAISearch } from "../vector_stores/azure_ai_search";
import { PGVector } from "../vector_stores/pgvector";
import { UpstashVector } from "../vector_stores/upstash_vector";
import { AzureMySQLDB } from "../vector_stores/azure_mysql";
import { VertexAIVectorSearch } from "../vector_stores/vertex_ai_vector_search";
import { CassandraDB } from "../vector_stores/cassandra";
@@ -136,6 +137,8 @@ export class VectorStoreFactory {
return new VertexAIVectorSearch(config as any);
case "pgvector":
return new PGVector(config as any);
case "upstash_vector":
return new UpstashVector(config as any);
case "azure_mysql":
return new AzureMySQLDB(config as any);
case "cassandra":
@@ -0,0 +1,240 @@
import { Index, QueryResult, Vector } from "@upstash/vector";
import { VectorStore } from "./base";
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
interface UpstashVectorConfig extends VectorStoreConfig {
collectionName: string;
url?: string;
token?: string;
client?: Index<Record<string, unknown>>;
}
type UpstashMetadata = Record<string, unknown>;
export class UpstashVector implements VectorStore {
private readonly client: Index<UpstashMetadata>;
private readonly collectionName: string;
constructor(config: UpstashVectorConfig) {
if (!config.collectionName) {
throw new Error("collectionName is required for Upstash Vector.");
}
if (config.client) {
this.client = config.client;
} else if (config.url && config.token) {
this.client = new Index({
url: config.url,
token: config.token,
});
} else {
throw new Error("Either a client or url and token must be provided.");
}
this.collectionName = config.collectionName;
}
async initialize(): Promise<void> {
return;
}
async insert(
vectors: number[][],
ids: string[],
payloads: Record<string, any>[],
): Promise<void> {
const upsertData = vectors.map((vector, idx) => {
return {
id: ids[idx],
vector,
metadata: payloads[idx] ?? {},
};
});
await this.client.upsert(upsertData, { namespace: this.collectionName });
}
async search(
query: number[],
topK: number = 5,
filters?: SearchFilters,
): Promise<VectorStoreResult[]> {
const response = await this.client.query<UpstashMetadata>(
{
vector: query,
topK,
filter: this.convertFilters(filters),
includeMetadata: true,
},
{ namespace: this.collectionName },
);
return response.map((result) => this.parseResult(result));
}
async keywordSearch(
query: string,
topK: number = 5,
filters?: SearchFilters,
): Promise<VectorStoreResult[] | null> {
try {
const response = await this.client.query<UpstashMetadata>(
{
data: query,
topK,
filter: this.convertFilters(filters),
includeMetadata: true,
},
{ namespace: this.collectionName },
);
return response.map((result) => this.parseResult(result));
} catch (error) {
console.error(`Error during keyword search for query '${query}':`, error);
return null;
}
}
async get(vectorId: string): Promise<VectorStoreResult | null> {
const response = await this.client.fetch<UpstashMetadata>([vectorId], {
includeMetadata: true,
namespace: this.collectionName,
});
const vector = response[0];
if (!vector) {
return null;
}
return {
id: String(vector.id),
payload: (vector.metadata ?? {}) as Record<string, any>,
};
}
async update(
vectorId: string,
vector: number[],
payload: Record<string, any>,
): Promise<void> {
// Upstash's `update` can't set the vector and metadata in one call (its
// payload is a discriminated union of vector | data | metadata), so a
// single `upsert` replaces both atomically, the same way insert() writes.
await this.client.upsert(
{
id: vectorId,
vector,
metadata: payload,
},
{ namespace: this.collectionName },
);
}
async delete(vectorId: string): Promise<void> {
await this.client.delete(vectorId, { namespace: this.collectionName });
}
async deleteCol(): Promise<void> {
await this.client.reset({ namespace: this.collectionName });
}
async list(
filters?: SearchFilters,
topK: number = 100,
): Promise<[VectorStoreResult[], number]> {
const results: VectorStoreResult[] = [];
let cursor = "0";
do {
const response = await this.client.range<UpstashMetadata>(
{
cursor,
limit: Math.min(100, topK - results.length),
includeMetadata: true,
},
{ namespace: this.collectionName },
);
for (const vector of response.vectors) {
if (this.matchesFilters(vector, filters)) {
results.push({
id: String(vector.id),
payload: (vector.metadata ?? {}) as Record<string, any>,
});
}
if (results.length >= topK) {
break;
}
}
cursor = response.nextCursor;
// Upstash returns an empty-string cursor once the scan is exhausted (it
// never comes back as "0"), so "" is the termination sentinel. Checking
// for "0" here would re-scan from the start and return duplicates.
} while (cursor !== "" && results.length < topK);
return [results, results.length];
}
async getUserId(): Promise<string> {
return "anonymous-upstash-vector";
}
async setUserId(): Promise<void> {
return;
}
async reset(): Promise<void> {
await this.deleteCol();
}
private parseResult(result: QueryResult<UpstashMetadata>): VectorStoreResult {
return {
id: String(result.id),
payload: (result.metadata ?? {}) as Record<string, any>,
score: result.score,
};
}
private stringifyFilterValue(value: unknown): string {
if (typeof value === "string") {
return JSON.stringify(value);
}
if (typeof value === "boolean") {
return value ? "true" : "false";
}
return String(value);
}
private convertFilters(filters?: SearchFilters): string | undefined {
if (!filters) {
return undefined;
}
const expressions = Object.entries(filters)
.filter(([, value]) => value !== undefined && value !== null)
.map(([key, value]) => `${key} = ${this.stringifyFilterValue(value)}`);
return expressions.length > 0 ? expressions.join(" AND ") : undefined;
}
private matchesFilters(
vector: Vector<UpstashMetadata>,
filters?: SearchFilters,
): boolean {
if (!filters) {
return true;
}
return Object.entries(filters).every(([key, value]) => {
if (value === undefined || value === null) {
return true;
}
return vector.metadata?.[key] === value;
});
}
}
@@ -158,6 +158,11 @@ jest.mock("../src/vector_stores/pgvector", () => ({
.fn()
.mockImplementation((config) => ({ type: "pgvector", config })),
}));
jest.mock("../src/vector_stores/upstash_vector", () => ({
UpstashVector: jest
.fn()
.mockImplementation((config) => ({ type: "upstash-vector", config })),
}));
jest.mock("../src/vector_stores/azure_mysql", () => ({
AzureMySQLDB: jest
.fn()
@@ -299,6 +304,7 @@ describe("VectorStoreFactory", () => {
["vectorize"],
["azure-ai-search"],
["pgvector"],
["upstash_vector"],
["azure_mysql"],
["cassandra"],
["s3-vectors"],
@@ -0,0 +1,166 @@
import { UpstashVector } from "../src/vector_stores/upstash_vector";
describe("UpstashVector", () => {
const namespace = "memories";
function createClient(overrides: Record<string, jest.Mock> = {}) {
return {
upsert: jest.fn().mockResolvedValue("Success"),
query: jest.fn().mockResolvedValue([]),
fetch: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue({ updated: 1 }),
delete: jest.fn().mockResolvedValue({ deleted: 1 }),
reset: jest.fn().mockResolvedValue("Success"),
range: jest.fn().mockResolvedValue({ vectors: [], nextCursor: "" }),
...overrides,
};
}
it("upserts vectors into the collection namespace", async () => {
const client = createClient();
const store = new UpstashVector({
collectionName: namespace,
client: client as any,
});
await store.insert(
[[0.1, 0.2]],
["memory-1"],
[{ data: "hello", user_id: "user-1" }],
);
expect(client.upsert).toHaveBeenCalledWith(
[
{
id: "memory-1",
vector: [0.1, 0.2],
metadata: { data: "hello", user_id: "user-1" },
},
],
{ namespace },
);
});
it("queries vectors with converted filters", async () => {
const client = createClient({
query: jest.fn().mockResolvedValue([
{
id: "memory-1",
score: 0.9,
metadata: { data: "hello", user_id: "user-1" },
},
]),
});
const store = new UpstashVector({
collectionName: namespace,
client: client as any,
});
const results = await store.search([0.1, 0.2], 3, {
user_id: "user-1",
active: true,
});
expect(client.query).toHaveBeenCalledWith(
{
vector: [0.1, 0.2],
topK: 3,
filter: 'user_id = "user-1" AND active = true',
includeMetadata: true,
},
{ namespace },
);
expect(results).toEqual([
{
id: "memory-1",
payload: { data: "hello", user_id: "user-1" },
score: 0.9,
},
]);
});
it("fetches, updates, deletes, resets, and lists vectors in the namespace", async () => {
const client = createClient({
fetch: jest.fn().mockResolvedValue([
{
id: "memory-1",
metadata: { data: "hello" },
},
]),
range: jest
.fn()
.mockResolvedValueOnce({
vectors: [
{ id: "memory-1", metadata: { user_id: "user-1" } },
{ id: "memory-2", metadata: { user_id: "user-2" } },
],
nextCursor: "2",
})
.mockResolvedValueOnce({
vectors: [{ id: "memory-3", metadata: { user_id: "user-1" } }],
nextCursor: "",
}),
});
const store = new UpstashVector({
collectionName: namespace,
client: client as any,
});
await expect(store.get("memory-1")).resolves.toEqual({
id: "memory-1",
payload: { data: "hello" },
});
await store.update("memory-1", [0.3], { data: "updated" });
await store.delete("memory-1");
await store.deleteCol();
await store.reset();
await expect(store.list({ user_id: "user-1" }, 2)).resolves.toEqual([
[
{ id: "memory-1", payload: { user_id: "user-1" } },
{ id: "memory-3", payload: { user_id: "user-1" } },
],
2,
]);
expect(client.fetch).toHaveBeenCalledWith(["memory-1"], {
includeMetadata: true,
namespace,
});
expect(client.upsert).toHaveBeenCalledWith(
{ id: "memory-1", vector: [0.3], metadata: { data: "updated" } },
{ namespace },
);
expect(client.delete).toHaveBeenCalledWith("memory-1", { namespace });
expect(client.reset).toHaveBeenCalledTimes(2);
expect(client.reset).toHaveBeenCalledWith({ namespace });
});
it("stops paging when the cursor is exhausted instead of re-scanning", async () => {
// Upstash returns nextCursor "" at the end of a scan. If list() treated ""
// as "keep going" (e.g. by checking for "0"), it would re-fetch from the
// start and pile up duplicates until it hit topK. With fewer vectors than
// topK, a single page must end the scan: one range call, no duplicates.
const client = createClient({
range: jest.fn().mockResolvedValue({
vectors: [
{ id: "memory-1", metadata: { user_id: "user-1" } },
{ id: "memory-2", metadata: { user_id: "user-1" } },
],
nextCursor: "",
}),
});
const store = new UpstashVector({
collectionName: namespace,
client: client as any,
});
const [rows, count] = await store.list({ user_id: "user-1" }, 100);
expect(client.range).toHaveBeenCalledTimes(1);
expect(rows).toEqual([
{ id: "memory-1", payload: { user_id: "user-1" } },
{ id: "memory-2", payload: { user_id: "user-1" } },
]);
expect(count).toBe(2);
});
});
+1
View File
@@ -20,6 +20,7 @@ const external = [
"@google-cloud/aiplatform",
"@mistralai/mistralai",
"@supabase/supabase-js",
"@upstash/vector",
"@azure/search-documents",
"@azure/identity",
"cloudflare",
@@ -29,6 +29,13 @@ class UpstashVectorConfig(BaseModel):
if not client and not (url and token):
raise ValueError("Either a client or URL and token must be provided.")
# Persist the env-resolved credentials so the provider constructor receives
# them; the validator used to check the env vars but drop them, so an
# env-var-only config passed validation and then raised on build.
if not client:
values["url"] = url
values["token"] = token
return values
model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -4,6 +4,7 @@ from unittest.mock import MagicMock, call, patch
import pytest
from mem0.configs.vector_stores.upstash_vector import UpstashVectorConfig
from mem0.vector_stores.upstash_vector import UpstashVector
@@ -393,3 +394,27 @@ def test_search_vectors_multi_query_namespace_at_top_level(upstash_instance):
assert [r.id for r in results] == ["id1", "id2", "id3"]
assert results[0].score == 0.9
assert results[1].payload == {"name": "vector2"}
def test_env_var_only_config_builds_provider(monkeypatch):
"""Regression: an env-var-only config (no url/token/client) must build.
VectorStoreFactory does ``UpstashVector(**config.model_dump())``. The config
validator read ``UPSTASH_VECTOR_REST_URL``/``UPSTASH_VECTOR_REST_TOKEN`` only
to pass its presence check, then returned the config unchanged, so
``model_dump()`` still carried ``url=token=None`` and construction raised
"Either a client or URL and token must be provided." — even though the docs
advertise env-var setup. The resolved credentials must reach the ctor.
"""
monkeypatch.setenv("UPSTASH_VECTOR_REST_URL", "https://example.upstash.io")
monkeypatch.setenv("UPSTASH_VECTOR_REST_TOKEN", "tok_123")
dumped = UpstashVectorConfig(collection_name="mem0").model_dump()
assert dumped["url"] == "https://example.upstash.io"
assert dumped["token"] == "tok_123"
# Mirrors VectorStoreFactory.create: instance(**config.model_dump()).
with patch("mem0.vector_stores.upstash_vector.Index") as mock_index:
UpstashVector(**dumped)
mock_index.assert_called_once_with("https://example.upstash.io", "tok_123")