From 58aa9397c1e51d320ad251dae8d476041faaa863 Mon Sep 17 00:00:00 2001 From: yassine1234944-design Date: Sun, 31 May 2026 13:27:30 +0200 Subject: [PATCH] feat: add qdrant vector database client --- .../arakoodev/src/vector-db/src/index.ts | 7 + .../src/vector-db/src/lib/qdrant/qdrant.ts | 249 ++++++++++++++++++ .../vector-db/src/tests/qdrant/qdrant.test.ts | 107 ++++++++ 3 files changed, 363 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..b8d2c94e4 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 { + QdrantClientOptions, + QdrantDistanceMetric, + QdrantPoint, + QdrantSearchArgs, +} 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..4330e4f3c --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/lib/qdrant/qdrant.ts @@ -0,0 +1,249 @@ +type QdrantPointId = string | number; +type QdrantPayload = Record; + +export type QdrantDistanceMetric = "Cosine" | "Dot" | "Euclid" | "Manhattan"; + +export interface QdrantClientOptions { + url?: string; + apiKey?: string; +} + +export interface QdrantPoint { + id: QdrantPointId; + vector: number[] | Record; + payload?: QdrantPayload; +} + +export interface QdrantSearchArgs { + collectionName: string; + vector: number[] | Record; + limit?: number; + filter?: QdrantPayload; + withPayload?: boolean | string[]; + withVector?: boolean | string[]; + scoreThreshold?: number; +} + +export class Qdrant { + url: string; + apiKey?: string; + + constructor({ url, apiKey }: QdrantClientOptions = {}) { + this.url = ( + url || + process.env.QDRANT_URL || + "http://localhost:6333" + ).replace(/\/$/, ""); + this.apiKey = apiKey || process.env.QDRANT_API_KEY; + } + + async createCollection({ + collectionName, + vectorSize, + distance = "Cosine", + }: { + collectionName: string; + vectorSize: number; + distance?: QdrantDistanceMetric; + }): Promise { + return this.request(`/collections/${encodeURIComponent(collectionName)}`, { + method: "PUT", + body: { + vectors: { + size: vectorSize, + distance, + }, + }, + }); + } + + async insertVectorData({ + collectionName, + points, + wait = true, + }: { + collectionName: string; + points: QdrantPoint | QdrantPoint[]; + wait?: boolean; + }): Promise { + return this.request( + `/collections/${encodeURIComponent(collectionName)}/points?wait=${wait}`, + { + method: "PUT", + body: { + points: Array.isArray(points) ? points : [points], + }, + }, + ); + } + + async search({ + collectionName, + vector, + limit = 10, + filter, + withPayload = true, + withVector = false, + scoreThreshold, + }: QdrantSearchArgs): Promise { + return this.request( + `/collections/${encodeURIComponent(collectionName)}/points/search`, + { + method: "POST", + body: { + vector, + limit, + filter, + with_payload: withPayload, + with_vector: withVector, + score_threshold: scoreThreshold, + }, + }, + ); + } + + async getData({ + collectionName, + limit = 10, + offset, + filter, + withPayload = true, + withVector = false, + }: { + collectionName: string; + limit?: number; + offset?: QdrantPointId; + filter?: QdrantPayload; + withPayload?: boolean | string[]; + withVector?: boolean | string[]; + }): Promise { + return this.request( + `/collections/${encodeURIComponent(collectionName)}/points/scroll`, + { + method: "POST", + body: { + limit, + offset, + filter, + with_payload: withPayload, + with_vector: withVector, + }, + }, + ); + } + + async getDataById({ + collectionName, + id, + withPayload = true, + withVector = false, + }: { + collectionName: string; + id: QdrantPointId; + withPayload?: boolean | string[]; + withVector?: boolean | string[]; + }): Promise { + const params = new URLSearchParams({ + with_payload: String(withPayload), + with_vector: String(withVector), + }); + + return this.request( + `/collections/${encodeURIComponent(collectionName)}/points/${encodeURIComponent(String(id))}?${params}`, + ); + } + + async updateById({ + collectionName, + id, + vector, + payload, + wait = true, + }: { + collectionName: string; + id: QdrantPointId; + vector?: number[] | Record; + payload?: QdrantPayload; + wait?: boolean; + }): Promise { + if (vector) { + await this.insertVectorData({ + collectionName, + points: { + id, + vector, + payload, + }, + wait, + }); + } + + if (payload) { + return this.request( + `/collections/${encodeURIComponent(collectionName)}/points/payload?wait=${wait}`, + { + method: "POST", + body: { + payload, + points: [id], + }, + }, + ); + } + + return { status: "ok" }; + } + + async deleteById({ + collectionName, + id, + wait = true, + }: { + collectionName: string; + id: QdrantPointId | QdrantPointId[]; + wait?: boolean; + }): Promise { + return this.request( + `/collections/${encodeURIComponent(collectionName)}/points/delete?wait=${wait}`, + { + method: "POST", + body: { + points: Array.isArray(id) ? id : [id], + }, + }, + ); + } + + private async request( + path: string, + options: { method?: string; body?: Record } = {}, + ) { + const response = await fetch(`${this.url}${path}`, { + method: options.method || "GET", + headers: { + "Content-Type": "application/json", + ...(this.apiKey ? { "api-key": this.apiKey } : {}), + }, + body: options.body + ? JSON.stringify(this.removeUndefinedValues(options.body)) + : undefined, + }); + + const text = await response.text(); + const data = text ? JSON.parse(text) : null; + + if (!response.ok) { + throw new Error( + `Qdrant request failed with status ${response.status}: ${text}`, + ); + } + + return data; + } + + private removeUndefinedValues(input: Record) { + return Object.fromEntries( + Object.entries(input).filter(([, value]) => value !== undefined), + ); + } +} 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..086e71571 --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/tests/qdrant/qdrant.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { Qdrant } from "../../lib/qdrant/qdrant.js"; + +const fetchMock = vi.fn(); + +beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ result: "ok" }), + }); + + global.fetch = fetchMock; +}); + +describe("Qdrant", () => { + it("creates a collection through the REST API", async () => { + const qdrant = new Qdrant({ + url: "http://localhost:6333", + apiKey: "test-key", + }); + + await qdrant.createCollection({ + collectionName: "documents", + vectorSize: 1536, + }); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:6333/collections/documents", + { + method: "PUT", + headers: { + "Content-Type": "application/json", + "api-key": "test-key", + }, + body: JSON.stringify({ + vectors: { + size: 1536, + distance: "Cosine", + }, + }), + }, + ); + }); + + it("upserts vector points without qdrant packages", async () => { + const qdrant = new Qdrant({ url: "http://localhost:6333" }); + + await qdrant.insertVectorData({ + collectionName: "documents", + points: { + id: "doc-1", + vector: [0.1, 0.2], + payload: { text: "hello" }, + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:6333/collections/documents/points?wait=true", + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + points: [ + { + id: "doc-1", + vector: [0.1, 0.2], + payload: { text: "hello" }, + }, + ], + }), + }, + ); + }); + + it("searches using the qdrant points search endpoint", async () => { + const qdrant = new Qdrant({ url: "http://localhost:6333" }); + + await qdrant.search({ + collectionName: "documents", + vector: [0.1, 0.2], + limit: 3, + filter: { must: [{ key: "namespace", match: { value: "docs" } }] }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:6333/collections/documents/points/search", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + vector: [0.1, 0.2], + limit: 3, + filter: { must: [{ key: "namespace", match: { value: "docs" } }] }, + with_payload: true, + with_vector: false, + }), + }, + ); + }); +});