From 0acca0f8fac7a64ffe6abbe59fbf7deb85be4398 Mon Sep 17 00:00:00 2001 From: moluscorichman-oss Date: Fri, 24 Apr 2026 12:01:22 -0700 Subject: [PATCH] feat: add Qdrant vector database client --- .../arakoodev/src/vector-db/src/index.ts | 1 + .../src/vector-db/src/lib/qdrant/qdrant.ts | 184 ++++++++++++++++++ .../vector-db/src/tests/qdrant/qdrant.test.ts | 44 +++++ 3 files changed, 229 insertions(+) create mode 100644 JS/edgechains/arakoodev/src/vector-db/src/lib/qdrant/qdrant.ts create mode 100644 JS/edgechains/arakoodev/src/vector-db/src/tests/qdrant/qdrant.test.ts diff --git a/JS/edgechains/arakoodev/src/vector-db/src/index.ts b/JS/edgechains/arakoodev/src/vector-db/src/index.ts index 557104a14..e68d60f2d 100644 --- a/JS/edgechains/arakoodev/src/vector-db/src/index.ts +++ b/JS/edgechains/arakoodev/src/vector-db/src/index.ts @@ -1 +1,2 @@ export { Supabase } from "./lib/supabase/supabase.js"; +export { Qdrant } from "./lib/qdrant/qdrant.js"; diff --git a/JS/edgechains/arakoodev/src/vector-db/src/lib/qdrant/qdrant.ts b/JS/edgechains/arakoodev/src/vector-db/src/lib/qdrant/qdrant.ts new file mode 100644 index 000000000..481507630 --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/lib/qdrant/qdrant.ts @@ -0,0 +1,184 @@ +import retry from "retry"; +import { config } from "dotenv"; +config(); + +type QdrantDistance = "Cosine" | "Euclid" | "Dot"; +type PointId = number | string; + +interface QdrantConfig { + url?: string; + apiKey?: string; +} + +interface CreateCollectionArgs { + collectionName: string; + vectorSize: number; + distance?: QdrantDistance; +} + +interface UpsertPointsArgs { + collectionName: string; + points: Array<{ + id: PointId; + vector: number[]; + payload?: Record; + }>; + wait?: boolean; +} + +interface SearchPointsArgs { + collectionName: string; + vector: number[]; + limit?: number; + filter?: Record; + withPayload?: boolean | string[] | Record; + withVector?: boolean | string[]; +} + +interface DeletePointsArgs { + collectionName: string; + points: PointId[]; + wait?: boolean; +} + +interface GetPointArgs { + collectionName: string; + id: PointId; + withPayload?: boolean | string[] | Record; + withVector?: boolean | string[]; +} + +export class Qdrant { + private readonly url: string; + private readonly apiKey?: string; + + constructor(url?: string, apiKey?: string) { + this.url = (url || process.env.QDRANT_URL || process.env.QDRANT_API_URL || "").replace(/\/$/, ""); + this.apiKey = apiKey || process.env.QDRANT_API_KEY; + + if (!this.url) { + throw new Error("Qdrant URL is required. Pass it to the constructor or set QDRANT_URL."); + } + } + + private async request(path: string, method: string, body?: unknown): Promise { + const headers: Record = { + "Content-Type": "application/json", + }; + + if (this.apiKey) { + headers["api-key"] = this.apiKey; + } + + const response = await fetch(`${this.url}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + const text = await response.text(); + const payload = text ? JSON.parse(text) : undefined; + + if (!response.ok) { + const message = payload?.status?.error || payload?.message || response.statusText; + throw new Error(`Qdrant request failed (${response.status}): ${message}`); + } + + return payload as T; + } + + private withRetry(operationName: string, run: () => Promise): Promise { + return new Promise((resolve, reject) => { + const operation = retry.operation({ + retries: 5, + factor: 3, + minTimeout: 1000, + maxTimeout: 60000, + randomize: true, + }); + + operation.attempt(async () => { + try { + resolve(await run()); + } catch (error) { + if (operation.retry(error as Error)) return; + reject(new Error(`${operationName} failed: ${(error as Error).message}`)); + } + }); + }); + } + + async createCollection({ + collectionName, + vectorSize, + distance = "Cosine", + }: CreateCollectionArgs): Promise { + return this.withRetry("createCollection", () => + this.request(`/collections/${collectionName}`, "PUT", { + vectors: { + size: vectorSize, + distance, + }, + }) + ); + } + + async listCollections(): Promise { + return this.withRetry("listCollections", () => this.request("/collections", "GET")); + } + + async getCollection(collectionName: string): Promise { + return this.withRetry("getCollection", () => this.request(`/collections/${collectionName}`, "GET")); + } + + async deleteCollection(collectionName: string): Promise { + return this.withRetry("deleteCollection", () => this.request(`/collections/${collectionName}`, "DELETE")); + } + + async upsertPoints({ collectionName, points, wait = true }: UpsertPointsArgs): Promise { + return this.withRetry("upsertPoints", () => + this.request(`/collections/${collectionName}/points?wait=${wait}`, "PUT", { points }) + ); + } + + async searchPoints({ + collectionName, + vector, + limit = 10, + filter, + withPayload = true, + withVector = false, + }: SearchPointsArgs): Promise { + return this.withRetry("searchPoints", () => + this.request(`/collections/${collectionName}/points/search`, "POST", { + vector, + limit, + filter, + with_payload: withPayload, + with_vector: withVector, + }) + ); + } + + async getPoint({ + collectionName, + id, + withPayload = true, + withVector = false, + }: GetPointArgs): Promise { + return this.withRetry("getPoint", () => + this.request(`/collections/${collectionName}/points/${id}`, "GET", { + with_payload: withPayload, + with_vector: withVector, + }) + ); + } + + async deletePoints({ collectionName, points, wait = true }: DeletePointsArgs): Promise { + return this.withRetry("deletePoints", () => + this.request(`/collections/${collectionName}/points/delete?wait=${wait}`, "POST", { + points, + }) + ); + } +} diff --git a/JS/edgechains/arakoodev/src/vector-db/src/tests/qdrant/qdrant.test.ts b/JS/edgechains/arakoodev/src/vector-db/src/tests/qdrant/qdrant.test.ts new file mode 100644 index 000000000..dfe1cd843 --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/tests/qdrant/qdrant.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Qdrant } from "../../lib/qdrant/qdrant.js"; + +describe("Qdrant", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + global.fetch = fetchMock; + }); + + it("creates a collection using the Qdrant REST API", async () => { + fetchMock.mockResolvedValueOnce({ ok: true, text: async () => JSON.stringify({ result: true }) }); + + const qdrant = new Qdrant("https://qdrant.test", "secret"); + await qdrant.createCollection({ collectionName: "docs", vectorSize: 1536 }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://qdrant.test/collections/docs", + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ "api-key": "secret" }), + body: JSON.stringify({ vectors: { size: 1536, distance: "Cosine" } }), + }) + ); + }); + + it("upserts and searches points without using a Qdrant SDK", async () => { + fetchMock + .mockResolvedValueOnce({ ok: true, text: async () => JSON.stringify({ result: { operation_id: 1 } }) }) + .mockResolvedValueOnce({ ok: true, text: async () => JSON.stringify({ result: [{ id: 1, score: 0.99 }] }) }); + + const qdrant = new Qdrant("https://qdrant.test"); + await qdrant.upsertPoints({ + collectionName: "docs", + points: [{ id: 1, vector: [0.1, 0.2], payload: { text: "hello" } }], + }); + const search = await qdrant.searchPoints({ collectionName: "docs", vector: [0.1, 0.2], limit: 1 }); + + expect(fetchMock.mock.calls[0][0]).toBe("https://qdrant.test/collections/docs/points?wait=true"); + expect(fetchMock.mock.calls[1][0]).toBe("https://qdrant.test/collections/docs/points/search"); + expect(search.result[0].score).toBe(0.99); + }); +});