From b6d09d217277fbbfb3354361688aa103a9e5a72e Mon Sep 17 00:00:00 2001 From: GhostAOI <232006627+GhostAOI@users.noreply.github.com> Date: Sat, 13 Jun 2026 07:37:40 +0700 Subject: [PATCH] Add Qdrant vector database client --- .../arakoodev/src/vector-db/src/index.ts | 6 + .../src/vector-db/src/lib/qdrant/qdrant.ts | 217 ++++++++++++++++++ .../vector-db/src/tests/qdrant/qdrant.test.ts | 94 ++++++++ 3 files changed, 317 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..549081a4c 100644 --- a/JS/edgechains/arakoodev/src/vector-db/src/index.ts +++ b/JS/edgechains/arakoodev/src/vector-db/src/index.ts @@ -1 +1,7 @@ export { Supabase } from "./lib/supabase/supabase.js"; +export { Qdrant } from "./lib/qdrant/qdrant.js"; +export type { + QdrantDistance, + QdrantPoint, + QdrantPointId, +} 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..8ea6d017b --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/lib/qdrant/qdrant.ts @@ -0,0 +1,217 @@ +import retry from "retry"; +import { config } from "dotenv"; +config(); + +export type QdrantPointId = string | number; +export type QdrantDistance = "Cosine" | "Euclid" | "Dot" | "Manhattan"; + +export interface QdrantPoint { + id: QdrantPointId; + vector: number[] | Record; + payload?: Record; +} + +export interface QdrantRequestOptions { + method?: "GET" | "POST" | "PUT" | "DELETE"; + body?: Record; +} + +export class Qdrant { + QDRANT_URL: string; + QDRANT_API_KEY?: string; + + constructor(QDRANT_URL?: string, QDRANT_API_KEY?: string) { + this.QDRANT_URL = (QDRANT_URL || process.env.QDRANT_URL || "").replace( + /\/+$/, + "", + ); + this.QDRANT_API_KEY = QDRANT_API_KEY || process.env.QDRANT_API_KEY; + } + + createClient() { + if (!this.QDRANT_URL) { + throw new Error("QDRANT_URL is required to create a Qdrant client"); + } + return this; + } + + async createCollection({ + client = this, + collectionName, + vectorSize, + distance = "Cosine", + }: { + client?: Qdrant; + collectionName: string; + vectorSize: number; + distance?: QdrantDistance; + }) { + return client.request(`/collections/${collectionName}`, { + method: "PUT", + body: { + vectors: { + size: vectorSize, + distance, + }, + }, + }); + } + + async insertVectorData({ + client = this, + collectionName, + points, + }: { + client?: Qdrant; + collectionName: string; + points: QdrantPoint[]; + }) { + return client.request(`/collections/${collectionName}/points?wait=true`, { + method: "PUT", + body: { points }, + }); + } + + async getDataFromQuery({ + client = this, + collectionName, + vector, + limit = 10, + filter, + withPayload = true, + withVector = false, + }: { + client?: Qdrant; + collectionName: string; + vector: number[] | Record; + limit?: number; + filter?: Record; + withPayload?: boolean; + withVector?: boolean; + }) { + return client.request(`/collections/${collectionName}/points/search`, { + method: "POST", + body: { + vector, + limit, + filter, + with_payload: withPayload, + with_vector: withVector, + }, + }); + } + + async getDataById({ + client = this, + collectionName, + ids, + withPayload = true, + withVector = false, + }: { + client?: Qdrant; + collectionName: string; + ids: QdrantPointId[]; + withPayload?: boolean; + withVector?: boolean; + }) { + return client.request(`/collections/${collectionName}/points`, { + method: "POST", + body: { + ids, + with_payload: withPayload, + with_vector: withVector, + }, + }); + } + + async updateById({ + client = this, + collectionName, + id, + updatedContent, + }: { + client?: Qdrant; + collectionName: string; + id: QdrantPointId; + updatedContent: Record; + }) { + return client.request( + `/collections/${collectionName}/points/payload?wait=true`, + { + method: "POST", + body: { + points: [id], + payload: updatedContent, + }, + }, + ); + } + + async deleteById({ + client = this, + collectionName, + id, + }: { + client?: Qdrant; + collectionName: string; + id: QdrantPointId; + }) { + return client.request( + `/collections/${collectionName}/points/delete?wait=true`, + { + method: "POST", + body: { + points: [id], + }, + }, + ); + } + + async request( + path: string, + options: QdrantRequestOptions = {}, + ): Promise { + return new Promise((resolve, reject) => { + const operation = retry.operation({ + retries: 5, + factor: 3, + minTimeout: 1 * 1000, + maxTimeout: 60 * 1000, + randomize: true, + }); + + operation.attempt(async () => { + try { + const response = await fetch(`${this.QDRANT_URL}${path}`, { + method: options.method || "GET", + headers: { + "Content-Type": "application/json", + ...(this.QDRANT_API_KEY + ? { "api-key": this.QDRANT_API_KEY } + : {}), + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + const responseBody = await response.json().catch(() => undefined); + + if (!response.ok) { + if (response.status >= 500 && operation.retry(new Error())) return; + reject( + new Error( + `Qdrant request failed with status ${response.status}: ${JSON.stringify( + responseBody, + )}`, + ), + ); + return; + } + + resolve(responseBody?.result ?? responseBody); + } catch (error: any) { + if (operation.retry(error)) return; + reject(error); + } + }); + }); + } +} 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..f6c6e2424 --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/tests/qdrant/qdrant.test.ts @@ -0,0 +1,94 @@ +import { Qdrant } from "../../lib/qdrant/qdrant"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFetch = vi.fn(); + +global.fetch = mockFetch as any; + +describe("Qdrant", () => { + beforeEach(() => { + mockFetch.mockReset(); + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ result: { status: "ok" } }), + }); + }); + + it("upserts vector points through the Qdrant REST API", async () => { + const qdrant = new Qdrant("https://qdrant.example", "secret-key"); + + const result = await qdrant.insertVectorData({ + collectionName: "documents", + points: [ + { + id: "doc-1", + vector: [0.1, 0.2, 0.3], + payload: { content: "hello" }, + }, + ], + }); + + expect(result).toEqual({ status: "ok" }); + expect(mockFetch).toHaveBeenCalledWith( + "https://qdrant.example/collections/documents/points?wait=true", + { + method: "PUT", + headers: { + "Content-Type": "application/json", + "api-key": "secret-key", + }, + body: JSON.stringify({ + points: [ + { + id: "doc-1", + vector: [0.1, 0.2, 0.3], + payload: { content: "hello" }, + }, + ], + }), + }, + ); + }); + + it("searches a collection with vector query options", async () => { + const qdrant = new Qdrant("https://qdrant.example/"); + + await qdrant.getDataFromQuery({ + collectionName: "documents", + vector: [0.1, 0.2, 0.3], + limit: 3, + filter: { must: [{ key: "namespace", match: { value: "docs" } }] }, + withVector: true, + }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://qdrant.example/collections/documents/points/search", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + vector: [0.1, 0.2, 0.3], + limit: 3, + filter: { must: [{ key: "namespace", match: { value: "docs" } }] }, + with_payload: true, + with_vector: true, + }), + }), + ); + }); + + it("throws a useful error when Qdrant returns a non-2xx response", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ status: { error: "Not found" } }), + }); + const qdrant = new Qdrant("https://qdrant.example"); + + await expect( + qdrant.getDataById({ + collectionName: "documents", + ids: ["missing"], + }), + ).rejects.toThrow("Qdrant request failed with status 404"); + }); +});