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..01abbc4db --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/lib/qdrant/qdrant.ts @@ -0,0 +1,285 @@ +import retry from "retry"; +import { config } from "dotenv"; +config(); + +type QdrantPointId = string | number; + +type QdrantVector = number[] | Record; + +type QdrantDistance = "Cosine" | "Euclid" | "Dot" | "Manhattan"; + +interface QdrantPoint { + id: QdrantPointId; + vector: QdrantVector; + payload?: Record; +} + +interface QdrantRequestOptions { + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + body?: Record; + searchParams?: Record; +} + +interface CreateCollectionArgs { + collectionName: string; + vectorSize: number; + distance?: QdrantDistance; +} + +interface UpsertVectorDataArgs { + collectionName: string; + points: QdrantPoint[]; + wait?: boolean; +} + +interface SearchVectorDataArgs { + collectionName: string; + vector: QdrantVector; + limit?: number; + filter?: Record; + withPayload?: boolean | string[]; + withVector?: boolean | string[]; + scoreThreshold?: number; +} + +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; + + if (!this.QDRANT_URL) { + throw new Error("QDRANT_URL is required"); + } + } + + createClient() { + return this; + } + + private pathSegment(value: string | number) { + return encodeURIComponent(String(value)); + } + + private async request(path: string, options: QdrantRequestOptions = {}): Promise { + const url = new URL(`${this.QDRANT_URL}${path}`); + + for (const [key, value] of Object.entries(options.searchParams ?? {})) { + if (value !== undefined) { + url.searchParams.set(key, String(value)); + } + } + + const headers: Record = { + "content-type": "application/json", + }; + + if (this.QDRANT_API_KEY) { + headers["api-key"] = this.QDRANT_API_KEY; + } + + 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(url, { + method: options.method ?? "GET", + headers, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + const text = await response.text(); + const data = text ? JSON.parse(text) : {}; + + if (!response.ok) { + const error = new Error( + `Qdrant request failed with ${response.status}: ${JSON.stringify(data)}` + ); + if (operation.retry(error)) return; + reject(error); + return; + } + + resolve(data); + } catch (error: any) { + if (operation.retry(error)) return; + reject(error); + } + }); + }); + } + + async createCollection({ + collectionName, + vectorSize, + distance = "Cosine", + }: CreateCollectionArgs): Promise { + return this.request(`/collections/${this.pathSegment(collectionName)}`, { + method: "PUT", + body: { + vectors: { + size: vectorSize, + distance, + }, + }, + }); + } + + async insertVectorData({ + collectionName, + points, + wait = true, + }: UpsertVectorDataArgs): Promise { + return this.request(`/collections/${this.pathSegment(collectionName)}/points`, { + method: "PUT", + searchParams: { wait }, + body: { points }, + }); + } + + async searchVectorData({ + collectionName, + vector, + limit = 10, + filter, + withPayload = true, + withVector = false, + scoreThreshold, + }: SearchVectorDataArgs): Promise { + return this.request(`/collections/${this.pathSegment(collectionName)}/points/search`, { + method: "POST", + body: { + vector, + limit, + filter, + with_payload: withPayload, + with_vector: withVector, + score_threshold: scoreThreshold, + }, + }); + } + + async getDataById({ + collectionName, + id, + withPayload = true, + withVector = false, + }: { + collectionName: string; + id: QdrantPointId; + withPayload?: boolean | string[]; + withVector?: boolean | string[]; + }): Promise { + return this.request( + `/collections/${this.pathSegment(collectionName)}/points/${this.pathSegment(id)}`, + { + searchParams: { + with_payload: typeof withPayload === "boolean" ? withPayload : undefined, + with_vector: typeof withVector === "boolean" ? withVector : undefined, + }, + } + ); + } + + async getData({ + collectionName, + limit = 10, + offset, + withPayload = true, + withVector = false, + }: { + collectionName: string; + limit?: number; + offset?: QdrantPointId; + withPayload?: boolean; + withVector?: boolean; + }): Promise { + return this.request(`/collections/${this.pathSegment(collectionName)}/points/scroll`, { + method: "POST", + body: { + limit, + offset, + with_payload: withPayload, + with_vector: withVector, + }, + }); + } + + async updateById({ + collectionName, + id, + updatedContent, + wait = true, + }: { + collectionName: string; + id: QdrantPointId; + updatedContent: Record; + wait?: boolean; + }): Promise { + return this.updatePayload({ + collectionName, + points: [id], + payload: updatedContent, + wait, + }); + } + + async clearPayloadById({ + collectionName, + points, + wait = true, + }: { + collectionName: string; + points: QdrantPointId[]; + wait?: boolean; + }): Promise { + return this.request(`/collections/${this.pathSegment(collectionName)}/points/payload/delete`, { + method: "POST", + searchParams: { wait }, + body: { points }, + }); + } + + async updatePayload({ + collectionName, + points, + payload, + wait = true, + }: { + collectionName: string; + points: QdrantPointId[]; + payload: Record; + wait?: boolean; + }): Promise { + return this.request(`/collections/${this.pathSegment(collectionName)}/points/payload`, { + method: "POST", + searchParams: { wait }, + body: { points, payload }, + }); + } + + async deleteById({ + collectionName, + points, + wait = true, + }: { + collectionName: string; + points: QdrantPointId[]; + wait?: boolean; + }): Promise { + return this.request(`/collections/${this.pathSegment(collectionName)}/points/delete`, { + method: "POST", + searchParams: { wait }, + body: { 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..1b1fa2e84 --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/tests/qdrant/qdrant.test.ts @@ -0,0 +1,110 @@ +import { Qdrant } from "../../lib/qdrant/qdrant"; + +const mockFetch = jest.fn(); + +beforeEach(() => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ result: "ok" }), + }); + global.fetch = mockFetch as any; +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe("Qdrant", () => { + test("creates a collection through the REST API", async () => { + const qdrant = new Qdrant("https://qdrant.example.com", "test-key"); + + await qdrant.createCollection({ + collectionName: "documents", + vectorSize: 1536, + distance: "Cosine", + }); + + expect(mockFetch).toHaveBeenCalledWith( + new URL("https://qdrant.example.com/collections/documents"), + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ + "content-type": "application/json", + "api-key": "test-key", + }), + body: JSON.stringify({ + vectors: { + size: 1536, + distance: "Cosine", + }, + }), + }) + ); + }); + + test("upserts points through the REST API", async () => { + const qdrant = new Qdrant("https://qdrant.example.com", "test-key"); + + await qdrant.insertVectorData({ + collectionName: "documents", + points: [ + { + id: 1, + vector: [0.1, 0.2, 0.3], + payload: { raw_text: "hello" }, + }, + ], + }); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url.toString()).toBe("https://qdrant.example.com/collections/documents/points?wait=true"); + expect(options).toEqual( + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ + points: [ + { + id: 1, + vector: [0.1, 0.2, 0.3], + payload: { raw_text: "hello" }, + }, + ], + }), + }) + ); + }); + + test("searches a collection with vector and payload options", async () => { + const qdrant = new Qdrant("https://qdrant.example.com", "test-key"); + + await qdrant.searchVectorData({ + collectionName: "documents", + vector: [0.1, 0.2, 0.3], + limit: 5, + filter: { + must: [{ key: "namespace", match: { value: "docs" } }], + }, + withPayload: true, + withVector: false, + scoreThreshold: 0.8, + }); + + expect(mockFetch).toHaveBeenCalledWith( + new URL("https://qdrant.example.com/collections/documents/points/search"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + vector: [0.1, 0.2, 0.3], + limit: 5, + filter: { + must: [{ key: "namespace", match: { value: "docs" } }], + }, + with_payload: true, + with_vector: false, + score_threshold: 0.8, + }), + }) + ); + }); +});