From cefcddd81804513fbd2f8160fceb677920c9202c Mon Sep 17 00:00:00 2001 From: Miles Feldstein Date: Mon, 11 May 2026 14:57:19 -0700 Subject: [PATCH] Add Qdrant vector database client --- .../arakoodev/src/vector-db/src/index.ts | 7 + .../src/vector-db/src/lib/qdrant/qdrant.ts | 289 ++++++++++++++++++ .../vector-db/src/tests/qdrant/qdrant.test.ts | 121 ++++++++ 3 files changed, 417 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..20032e2ea 100644 --- a/JS/edgechains/arakoodev/src/vector-db/src/index.ts +++ b/JS/edgechains/arakoodev/src/vector-db/src/index.ts @@ -1 +1,8 @@ export { Supabase } from "./lib/supabase/supabase.js"; +export { Qdrant } from "./lib/qdrant/qdrant.js"; +export type { + QdrantPayload, + QdrantPoint, + QdrantPointId, + QdrantVector, +} 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..3a97fb879 --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/lib/qdrant/qdrant.ts @@ -0,0 +1,289 @@ +import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from "axios"; +import retry from "retry"; +import { config } from "dotenv"; +config(); + +export type QdrantPointId = string | number; +export type QdrantVector = number[] | Record; +export type QdrantPayload = Record; + +export interface QdrantPoint { + id: QdrantPointId; + vector: QdrantVector; + payload?: QdrantPayload; +} + +interface QdrantRequestOptions { + wait?: boolean; + ordering?: "weak" | "medium" | "strong"; + timeout?: number; +} + +interface QdrantClientArgs { + url?: string; + apiKey?: string; + timeout?: number; +} + +interface InsertVectorDataArgs extends QdrantRequestOptions { + client: AxiosInstance; + collectionName: string; + id?: QdrantPointId; + vector?: QdrantVector; + payload?: QdrantPayload; + points?: QdrantPoint[]; +} + +interface GetDataFromQueryArgs { + client: AxiosInstance; + collectionName: string; + query: QdrantVector; + limit?: number; + filter?: Record; + with_payload?: boolean | string[] | Record; + with_vector?: boolean | string[]; + score_threshold?: number; + params?: Record; + timeout?: number; +} + +interface GetDataArgs { + client: AxiosInstance; + collectionName: string; + ids: QdrantPointId[]; + with_payload?: boolean | string[] | Record; + with_vector?: boolean | string[]; + timeout?: number; +} + +interface GetDataByIdArgs { + client: AxiosInstance; + collectionName: string; + id: QdrantPointId; + with_payload?: boolean | string[] | Record; + with_vector?: boolean | string[]; + timeout?: number; +} + +interface UpdateByIdArgs extends QdrantRequestOptions { + client: AxiosInstance; + collectionName: string; + id: QdrantPointId; + vector: QdrantVector; +} + +interface DeleteByIdArgs extends QdrantRequestOptions { + client: AxiosInstance; + collectionName: string; + id: QdrantPointId; +} + +export class Qdrant { + QDRANT_URL: string; + QDRANT_API_KEY?: string; + timeout: number; + + constructor(QDRANT_URL?: string, QDRANT_API_KEY?: string, timeout = 30000) { + this.QDRANT_URL = + QDRANT_URL || process.env.QDRANT_URL || "http://localhost:6333"; + this.QDRANT_API_KEY = QDRANT_API_KEY || process.env.QDRANT_API_KEY; + this.timeout = timeout; + } + + createClient({ url, apiKey, timeout }: QdrantClientArgs = {}) { + const headers: Record = { + "Content-Type": "application/json", + }; + const resolvedApiKey = apiKey || this.QDRANT_API_KEY; + + if (resolvedApiKey) { + headers["api-key"] = resolvedApiKey; + } + + return axios.create({ + baseURL: (url || this.QDRANT_URL).replace(/\/+$/, ""), + headers, + timeout: timeout || this.timeout, + }); + } + + /** + * Insert or overwrite vector points in a Qdrant collection. + */ + async insertVectorData({ + client, + collectionName, + id, + vector, + payload, + points, + ...options + }: InsertVectorDataArgs): Promise { + const pointList = + points || (id !== undefined && vector ? [{ id, vector, payload }] : []); + + if (!pointList.length) { + throw new Error( + "Provide either points or both id and vector to insert Qdrant data", + ); + } + + const response = await this.requestWithRetry(client, { + method: "PUT", + url: `/collections/${encodeURIComponent(collectionName)}/points`, + params: this.operationParams(options), + data: { points: pointList }, + }); + + return response.data; + } + + /** + * Search/query a Qdrant collection with a vector. + */ + async getDataFromQuery({ + client, + collectionName, + query, + timeout, + ...args + }: GetDataFromQueryArgs): Promise { + const response = await this.requestWithRetry(client, { + method: "POST", + url: `/collections/${encodeURIComponent(collectionName)}/points/query`, + params: timeout ? { timeout } : undefined, + data: { query, ...args }, + }); + + return response.data.result; + } + + /** + * Retrieve multiple Qdrant points by id. + */ + async getData({ + client, + collectionName, + timeout, + ...args + }: GetDataArgs): Promise { + const response = await this.requestWithRetry(client, { + method: "POST", + url: `/collections/${encodeURIComponent(collectionName)}/points`, + params: timeout ? { timeout } : undefined, + data: args, + }); + + return response.data.result; + } + + /** + * Retrieve one Qdrant point by id. + */ + async getDataById({ + client, + collectionName, + id, + timeout, + with_payload, + with_vector, + }: GetDataByIdArgs): Promise { + const response = await this.requestWithRetry(client, { + method: "GET", + url: `/collections/${encodeURIComponent(collectionName)}/points/${encodeURIComponent(id)}`, + params: { + ...(timeout ? { timeout } : {}), + ...(with_payload !== undefined ? { with_payload } : {}), + ...(with_vector !== undefined ? { with_vector } : {}), + }, + }); + + return response.data.result; + } + + /** + * Update vectors for a single Qdrant point. + */ + async updateById({ + client, + collectionName, + id, + vector, + ...options + }: UpdateByIdArgs): Promise { + const response = await this.requestWithRetry(client, { + method: "PUT", + url: `/collections/${encodeURIComponent(collectionName)}/points/vectors`, + params: this.operationParams(options), + data: { points: [{ id, vector }] }, + }); + + return response.data; + } + + /** + * Delete a single Qdrant point by id. + */ + async deleteById({ + client, + collectionName, + id, + ...options + }: DeleteByIdArgs): Promise { + const response = await this.requestWithRetry(client, { + method: "POST", + url: `/collections/${encodeURIComponent(collectionName)}/points/delete`, + params: this.operationParams(options), + data: { points: [id] }, + }); + + return response.data; + } + + private operationParams({ wait, ordering, timeout }: QdrantRequestOptions) { + return { + ...(wait !== undefined ? { wait } : {}), + ...(ordering ? { ordering } : {}), + ...(timeout ? { timeout } : {}), + }; + } + + private async requestWithRetry( + client: AxiosInstance, + config: AxiosRequestConfig, + ): 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 { + resolve(await client.request(config)); + } catch (error: any) { + if (operation.retry(error)) return; + reject(this.toQdrantError(error)); + } + }); + }); + } + + private toQdrantError(error: AxiosError | Error): Error { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const statusText = error.response?.statusText; + const responseData = error.response?.data; + return new Error( + `Qdrant request failed${status ? ` with status ${status}` : ""}${ + statusText ? ` ${statusText}` : "" + }: ${JSON.stringify(responseData || error.message)}`, + ); + } + + return 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..a0205961f --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/tests/qdrant/qdrant.test.ts @@ -0,0 +1,121 @@ +import axios from "axios"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Qdrant } from "../../lib/qdrant/qdrant.js"; + +vi.mock("axios", () => ({ + __esModule: true, + default: { + create: vi.fn(), + isAxiosError: vi.fn(), + }, +})); + +const mockedAxios = vi.mocked(axios); + +describe("Qdrant", () => { + const request = vi.fn(); + + beforeEach(() => { + request.mockReset(); + mockedAxios.create.mockReturnValue({ request } as any); + mockedAxios.isAxiosError.mockReturnValue(false); + }); + + it("creates a direct HTTP client with Qdrant headers", () => { + const qdrant = new Qdrant("https://example-qdrant.test/", "mock-api-key"); + + qdrant.createClient(); + + expect(mockedAxios.create).toHaveBeenCalledWith({ + baseURL: "https://example-qdrant.test", + headers: { + "Content-Type": "application/json", + "api-key": "mock-api-key", + }, + timeout: 30000, + }); + }); + + it("upserts a single vector point", async () => { + request.mockResolvedValue({ + data: { status: "ok", result: { operation_id: 1 } }, + }); + const qdrant = new Qdrant("https://example-qdrant.test", "mock-api-key"); + const client = qdrant.createClient(); + + const result = await qdrant.insertVectorData({ + client, + collectionName: "test_collection", + id: "point-1", + vector: [0.1, 0.2, 0.3], + payload: { content: "hello" }, + wait: true, + }); + + expect(request).toHaveBeenCalledWith({ + method: "PUT", + url: "/collections/test_collection/points", + params: { wait: true }, + data: { + points: [ + { + id: "point-1", + vector: [0.1, 0.2, 0.3], + payload: { content: "hello" }, + }, + ], + }, + }); + expect(result).toEqual({ status: "ok", result: { operation_id: 1 } }); + }); + + it("queries points with a vector", async () => { + request.mockResolvedValue({ + data: { status: "ok", result: [{ id: 1, score: 0.98 }] }, + }); + const qdrant = new Qdrant("https://example-qdrant.test", "mock-api-key"); + const client = qdrant.createClient(); + + const result = await qdrant.getDataFromQuery({ + client, + collectionName: "test_collection", + query: [0.1, 0.2, 0.3], + limit: 5, + with_payload: true, + }); + + expect(request).toHaveBeenCalledWith({ + method: "POST", + url: "/collections/test_collection/points/query", + params: undefined, + data: { + query: [0.1, 0.2, 0.3], + limit: 5, + with_payload: true, + }, + }); + expect(result).toEqual([{ id: 1, score: 0.98 }]); + }); + + it("deletes a point by id", async () => { + request.mockResolvedValue({ + data: { status: "ok", result: { operation_id: 2 } }, + }); + const qdrant = new Qdrant("https://example-qdrant.test", "mock-api-key"); + const client = qdrant.createClient(); + + await qdrant.deleteById({ + client, + collectionName: "test_collection", + id: 42, + wait: true, + }); + + expect(request).toHaveBeenCalledWith({ + method: "POST", + url: "/collections/test_collection/points/delete", + params: { wait: true }, + data: { points: [42] }, + }); + }); +});