diff --git a/JS/edgechains/arakoodev/src/vector-db/src/index.ts b/JS/edgechains/arakoodev/src/vector-db/src/index.ts index 557104a1..41c807fb 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 { QdrantClient } from "./lib/qdrant/QdrantClient.js"; diff --git a/JS/edgechains/arakoodev/src/vector-db/src/lib/qdrant/QdrantClient.ts b/JS/edgechains/arakoodev/src/vector-db/src/lib/qdrant/QdrantClient.ts new file mode 100644 index 00000000..f8298fb1 --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/lib/qdrant/QdrantClient.ts @@ -0,0 +1,98 @@ +import retry from "retry"; + +interface QdrantPoint { + id: string | number; + vector: number[]; + payload?: Record; +} + +interface SearchResult { + id: string | number; + score: number; + payload?: Record; +} + +interface QdrantCollectionConfig { + vectorSize: number; + distance: "Cosine" | "Euclid" | "Dot"; +} + +export class QdrantClient { + private baseUrl: string; + private apiKey: string; + + constructor(baseUrl?: string, apiKey?: string) { + this.baseUrl = baseUrl || process.env.QDRANT_URL || "http://localhost:6333"; + this.apiKey = apiKey || process.env.QDRANT_API_KEY || ""; + } + + private headers(): Record { + const h: Record = { "Content-Type": "application/json" }; + if (this.apiKey) h["api-key"] = this.apiKey; + return h; + } + + private async request(method: string, path: string, body?: any): Promise { + const url = `${this.baseUrl}${path}`; + const opts: RequestInit = { + method, + headers: this.headers(), + }; + if (body) opts.body = JSON.stringify(body); + + const res = await fetch(url, opts); + const data = await res.json(); + if (!res.ok) throw new Error(`Qdrant ${method} ${path}: ${data.status?.error || res.status}`); + return data as T; + } + + async createCollection(name: string, config: QdrantCollectionConfig): Promise { + await this.request("PUT", `/collections/${name}`, { + vectors: { size: config.vectorSize, distance: config.distance }, + }); + } + + async deleteCollection(name: string): Promise { + await this.request("DELETE", `/collections/${name}`); + } + + async upsert(collection: string, points: QdrantPoint[]): Promise { + return new Promise((resolve, reject) => { + const operation = retry.operation({ retries: 3, factor: 2, minTimeout: 1000, randomize: true }); + operation.attempt(async () => { + try { + await this.request("PUT", `/collections/${collection}/points?wait=true`, { points }); + resolve(); + } catch (err: any) { + if (operation.retry(err)) return; + reject(err); + } + }); + }); + } + + async search( + collection: string, + vector: number[], + topK: number = 10, + filter?: Record, + ): Promise { + const body: any = { vector, limit: topK, with_payload: true }; + if (filter) body.filter = filter; + const res = await this.request("POST", `/collections/${collection}/points/search`, body); + return (res.result || []).map((r: any) => ({ + id: r.id, + score: r.score, + payload: r.payload, + })); + } + + async deletePoints(collection: string, ids: (string | number)[]): Promise { + await this.request("POST", `/collections/${collection}/points/delete`, { points: ids }); + } + + async collectionInfo(collection: string): Promise { + const res = await this.request("GET", `/collections/${collection}`); + return res.result; + } +} diff --git a/JS/edgechains/arakoodev/src/vector-db/src/tests/qdrant/qdrantClient.test.ts b/JS/edgechains/arakoodev/src/vector-db/src/tests/qdrant/qdrantClient.test.ts new file mode 100644 index 00000000..ee0cc6f6 --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/tests/qdrant/qdrantClient.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock global fetch +const mockFetch = vi.fn(); +global.fetch = mockFetch as any; + +const mockJson = (data: any, ok = true) => + Promise.resolve({ json: () => Promise.resolve(data), ok, status: ok ? 200 : 400 } as any); + +describe("QdrantClient", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should upsert points", async () => { + mockFetch.mockResolvedValue(mockJson({ result: true })); + const { QdrantClient } = await import("../../lib/qdrant/QdrantClient.js"); + const client = new QdrantClient("http://qdrant:6333"); + await client.upsert("test_collection", [{ id: 1, vector: [0.1, 0.2, 0.3], payload: { text: "hello" } }]); + expect(mockFetch).toHaveBeenCalledWith( + "http://qdrant:6333/collections/test_collection/points?wait=true", + expect.objectContaining({ method: "PUT" }), + ); + }); + + it("should search points", async () => { + mockFetch.mockResolvedValue(mockJson({ + result: [ + { id: 1, score: 0.95, payload: { text: "result" } }, + ], + })); + const { QdrantClient } = await import("../../lib/qdrant/QdrantClient.js"); + const client = new QdrantClient("http://qdrant:6333"); + const results = await client.search("test_collection", [0.1, 0.2, 0.3], 5); + expect(results).toHaveLength(1); + expect(results[0].score).toBeCloseTo(0.95); + }); + + it("should create collection", async () => { + mockFetch.mockResolvedValue(mockJson({ result: true })); + const { QdrantClient } = await import("../../lib/qdrant/QdrantClient.js"); + const client = new QdrantClient("http://qdrant:6333"); + await client.createCollection("vectors", { vectorSize: 128, distance: "Cosine" }); + expect(mockFetch).toHaveBeenCalledWith( + "http://qdrant:6333/collections/vectors", + expect.objectContaining({ + method: "PUT", + body: expect.stringContaining('"size":128'), + }), + ); + }); + + it("should delete points", async () => { + mockFetch.mockResolvedValue(mockJson({ result: true })); + const { QdrantClient } = await import("../../lib/qdrant/QdrantClient.js"); + const client = new QdrantClient("http://qdrant:6333"); + await client.deletePoints("test", [1, 2, 3]); + expect(mockFetch).toHaveBeenCalledWith( + "http://qdrant:6333/collections/test/points/delete", + expect.objectContaining({ method: "POST" }), + ); + }); + + it("should get collection info", async () => { + mockFetch.mockResolvedValue(mockJson({ + result: { status: "green", vectors_count: 100 }, + })); + const { QdrantClient } = await import("../../lib/qdrant/QdrantClient.js"); + const client = new QdrantClient("http://qdrant:6333"); + const info = await client.collectionInfo("test"); + expect(info.status).toBe("green"); + expect(info.vectors_count).toBe(100); + }); +});