From 7960ad82250609d0f80ff5b770f1160691e28931 Mon Sep 17 00:00:00 2001 From: jchatelet <266390483+jchatelet@users.noreply.github.com> Date: Sun, 10 May 2026 18:48:06 -0300 Subject: [PATCH] Add Qdrant vector database client --- .../arakoodev/src/vector-db/src/index.ts | 1 + .../src/vector-db/src/lib/qdrant/qdrant.ts | 224 ++++++++++++++++++ .../vector-db/src/tests/qdrant/qdrant.test.ts | 127 ++++++++++ README.md | 35 +++ 4 files changed, 387 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..0b7b4cf45 --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/lib/qdrant/qdrant.ts @@ -0,0 +1,224 @@ +import retry from "retry"; +import { config } from "dotenv"; +config(); + +export type QdrantPointId = string | number; + +export interface QdrantClientConfig { + url: string; + apiKey?: string; +} + +interface QdrantRequestOptions { + method?: string; + body?: unknown; +} + +export interface CreateCollectionArgs { + client: QdrantClientConfig; + collectionName: string; + vectorSize: number; + distance?: "Cosine" | "Dot" | "Euclid" | "Manhattan"; +} + +export interface InsertVectorDataArgs { + client: QdrantClientConfig; + collectionName: string; + points: Array<{ + id: QdrantPointId; + vector: number[]; + payload?: Record; + }>; + wait?: boolean; +} + +export interface SearchArgs { + client: QdrantClientConfig; + collectionName: string; + vector: number[]; + limit?: number; + filter?: Record; + withPayload?: boolean | string[]; + withVector?: boolean | string[]; +} + +export interface GetDataByIdArgs { + client: QdrantClientConfig; + collectionName: string; + id: QdrantPointId; + withPayload?: boolean | string[]; + withVector?: boolean | string[]; +} + +export interface DeleteByIdArgs { + client: QdrantClientConfig; + collectionName: string; + id: QdrantPointId; + wait?: boolean; +} + +export class Qdrant { + QDRANT_URL: string; + QDRANT_API_KEY?: string; + retryOptions: retry.OperationOptions; + + constructor( + QDRANT_URL?: string, + QDRANT_API_KEY?: string, + retryOptions: retry.OperationOptions = {}, + ) { + this.QDRANT_URL = QDRANT_URL || process.env.QDRANT_URL!; + this.QDRANT_API_KEY = QDRANT_API_KEY || process.env.QDRANT_API_KEY; + this.retryOptions = retryOptions; + } + + createClient(): QdrantClientConfig { + return { + url: this.QDRANT_URL.replace(/\/$/, ""), + apiKey: this.QDRANT_API_KEY, + }; + } + + async createCollection({ + client, + collectionName, + vectorSize, + distance = "Cosine", + }: CreateCollectionArgs): Promise { + return this.request(client, `/collections/${collectionName}`, { + method: "PUT", + body: { + vectors: { + size: vectorSize, + distance, + }, + }, + }); + } + + async insertVectorData({ + client, + collectionName, + points, + wait = true, + }: InsertVectorDataArgs): Promise { + return this.request( + client, + `/collections/${collectionName}/points?wait=${wait}`, + { + method: "PUT", + body: { + points, + }, + }, + ); + } + + async search({ + client, + collectionName, + vector, + limit = 10, + filter, + withPayload = true, + withVector = false, + }: SearchArgs): Promise { + return this.request( + client, + `/collections/${collectionName}/points/search`, + { + method: "POST", + body: { + vector, + limit, + filter, + with_payload: withPayload, + with_vector: withVector, + }, + }, + ); + } + + async getDataById({ + client, + collectionName, + id, + withPayload = true, + withVector = false, + }: GetDataByIdArgs): Promise { + return this.request(client, `/collections/${collectionName}/points`, { + method: "POST", + body: { + ids: [id], + with_payload: withPayload, + with_vector: withVector, + }, + }); + } + + async deleteById({ + client, + collectionName, + id, + wait = true, + }: DeleteByIdArgs): Promise { + return this.request( + client, + `/collections/${collectionName}/points/delete?wait=${wait}`, + { + method: "POST", + body: { + points: [id], + }, + }, + ); + } + + private async request( + client: QdrantClientConfig, + path: string, + { method = "GET", body }: QdrantRequestOptions = {}, + ): Promise { + return new Promise((resolve, reject) => { + const operation = retry.operation({ + retries: 5, + factor: 3, + minTimeout: 1 * 1000, + maxTimeout: 60 * 1000, + randomize: true, + ...this.retryOptions, + }); + + operation.attempt(async () => { + try { + const response = await fetch(`${client.url}${path}`, { + method, + headers: { + "Content-Type": "application/json", + ...(client.apiKey ? { "api-key": client.apiKey } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + const text = await response.text(); + const data = text ? JSON.parse(text) : null; + + if (!response.ok) { + const message = + data?.status?.error || + data?.message || + response.statusText || + "Unknown Qdrant error"; + if (operation.retry(new Error(message))) return; + reject(new Error(`Qdrant request failed: ${message}`)); + return; + } + + resolve(data); + } 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..fd8331288 --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/tests/qdrant/qdrant.test.ts @@ -0,0 +1,127 @@ +import { Qdrant } from "../../../../../dist/vector-db/src/lib/qdrant/qdrant.js"; + +const MOCK_QDRANT_URL = "https://mock-qdrant.local"; +const MOCK_QDRANT_API_KEY = "mock-api-key"; + +describe("Qdrant vector database client", () => { + beforeEach(() => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: jest.fn().mockResolvedValue(JSON.stringify({ result: "ok" })), + }) as jest.Mock; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("creates a client from constructor arguments", () => { + const qdrant = new Qdrant(MOCK_QDRANT_URL, MOCK_QDRANT_API_KEY); + + expect(qdrant.createClient()).toEqual({ + url: MOCK_QDRANT_URL, + apiKey: MOCK_QDRANT_API_KEY, + }); + }); + + it("creates a collection with vector configuration", async () => { + const qdrant = new Qdrant(MOCK_QDRANT_URL, MOCK_QDRANT_API_KEY); + const client = qdrant.createClient(); + + await qdrant.createCollection({ + client, + collectionName: "documents", + vectorSize: 1536, + }); + + expect(fetch).toHaveBeenCalledWith( + `${MOCK_QDRANT_URL}/collections/documents`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + "api-key": MOCK_QDRANT_API_KEY, + }, + body: JSON.stringify({ + vectors: { + size: 1536, + distance: "Cosine", + }, + }), + }, + ); + }); + + it("upserts vector points", async () => { + const qdrant = new Qdrant(MOCK_QDRANT_URL, MOCK_QDRANT_API_KEY); + const client = qdrant.createClient(); + const points = [ + { + id: 1, + vector: [0.1, 0.2, 0.3], + payload: { content: "hello" }, + }, + ]; + + await qdrant.insertVectorData({ + client, + collectionName: "documents", + points, + }); + + expect(fetch).toHaveBeenCalledWith( + `${MOCK_QDRANT_URL}/collections/documents/points?wait=true`, + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ points }), + }), + ); + }); + + it("searches a collection", async () => { + const qdrant = new Qdrant(MOCK_QDRANT_URL, MOCK_QDRANT_API_KEY); + const client = qdrant.createClient(); + + await qdrant.search({ + client, + collectionName: "documents", + vector: [0.1, 0.2, 0.3], + limit: 3, + }); + + expect(fetch).toHaveBeenCalledWith( + `${MOCK_QDRANT_URL}/collections/documents/points/search`, + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + vector: [0.1, 0.2, 0.3], + limit: 3, + with_payload: true, + with_vector: false, + }), + }), + ); + }); + + it("throws when Qdrant returns an error", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + statusText: "Bad Request", + text: jest + .fn() + .mockResolvedValue(JSON.stringify({ status: { error: "bad vector" } })), + }) as jest.Mock; + + const qdrant = new Qdrant(MOCK_QDRANT_URL, MOCK_QDRANT_API_KEY, { + retries: 0, + }); + + await expect( + qdrant.search({ + client: qdrant.createClient(), + collectionName: "documents", + vector: [0.1], + }), + ).rejects.toThrow("Qdrant request failed: bad vector"); + }); +}); diff --git a/README.md b/README.md index 12da4c6e1..501ec6fa8 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,41 @@ as $$ - You should see a success message in the Result tab. +### Qdrant vector database + +The JavaScript SDK also supports Qdrant through `@arakoodev/edgechains.js/vector-db`. + +```ts +import { Qdrant } from "@arakoodev/edgechains.js/vector-db"; + +const qdrant = new Qdrant(process.env.QDRANT_URL, process.env.QDRANT_API_KEY); +const client = qdrant.createClient(); + +await qdrant.createCollection({ + client, + collectionName: "documents", + vectorSize: 1536, +}); + +await qdrant.insertVectorData({ + client, + collectionName: "documents", + points: [ + { + id: 1, + vector: embedding, + payload: { content: "Document text" }, + }, + ], +}); + +const matches = await qdrant.search({ + client, + collectionName: "documents", + vector: queryEmbedding, + limit: 5, +}); +``` ## Usage