diff --git a/JS/edgechains/arakoodev/src/vector-db/src/index.ts b/JS/edgechains/arakoodev/src/vector-db/src/index.ts index 557104a14..de1d9b8b8 100644 --- a/JS/edgechains/arakoodev/src/vector-db/src/index.ts +++ b/JS/edgechains/arakoodev/src/vector-db/src/index.ts @@ -1 +1,11 @@ export { Supabase } from "./lib/supabase/supabase.js"; +export { + Qdrant, + QdrantDistanceMetric, + type QdrantCreateCollectionArgs, + type QdrantDeletePointsArgs, + type QdrantPoint, + type QdrantSearchPointsArgs, + type QdrantSearchResult, + type QdrantUpsertPointsArgs, +} 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..cc23e5308 --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/lib/qdrant/qdrant.ts @@ -0,0 +1,155 @@ +export interface QdrantPoint { + id: string | number; + vector: number[]; + payload?: Record; +} + +export interface QdrantSearchResult { + id: string | number; + score: number; + payload?: Record; + vector?: number[]; +} + +export interface QdrantCreateCollectionArgs { + collectionName: string; + vectorSize: number; + distance?: QdrantDistanceMetric; +} + +export interface QdrantUpsertPointsArgs { + collectionName: string; + points: QdrantPoint[]; + wait?: boolean; +} + +export interface QdrantSearchPointsArgs { + collectionName: string; + vector: number[]; + limit?: number; + filter?: Record; + withPayload?: boolean | string[]; + withVector?: boolean | string[]; + scoreThreshold?: number; +} + +export interface QdrantDeletePointsArgs { + collectionName: string; + points: Array; + wait?: boolean; +} + +export enum QdrantDistanceMetric { + COSINE = "Cosine", + DOT = "Dot", + EUCLID = "Euclid", + MANHATTAN = "Manhattan", +} + +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; + } + + async createCollection({ + collectionName, + vectorSize, + distance = QdrantDistanceMetric.COSINE, + }: QdrantCreateCollectionArgs): Promise { + return this.request(`/collections/${collectionName}`, { + method: "PUT", + body: { + vectors: { + size: vectorSize, + distance, + }, + }, + }); + } + + async upsertPoints({ + collectionName, + points, + wait = true, + }: QdrantUpsertPointsArgs): Promise { + return this.request(`/collections/${collectionName}/points?wait=${wait}`, { + method: "PUT", + body: { points }, + }); + } + + async searchPoints({ + collectionName, + vector, + limit = 10, + filter, + withPayload = true, + withVector = false, + scoreThreshold, + }: QdrantSearchPointsArgs): Promise { + const response = await this.request(`/collections/${collectionName}/points/search`, { + method: "POST", + body: { + vector, + limit, + filter, + with_payload: withPayload, + with_vector: withVector, + score_threshold: scoreThreshold, + }, + }); + + return response.result; + } + + async deletePoints({ + collectionName, + points, + wait = true, + }: QdrantDeletePointsArgs): Promise { + return this.request(`/collections/${collectionName}/points/delete?wait=${wait}`, { + method: "POST", + body: { + points, + }, + }); + } + + private async request(path: string, options: { method: string; body?: unknown }): Promise { + if (!this.QDRANT_URL) { + throw new Error("QDRANT_URL is required"); + } + + const response = await fetch(`${this.QDRANT_URL}${path}`, { + method: options.method, + headers: this.headers(), + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + const responseBody = await response.json().catch(() => null); + + if (!response.ok) { + throw new Error( + `Qdrant request failed with status ${response.status}: ${JSON.stringify(responseBody)}` + ); + } + + return responseBody; + } + + private headers(): Record { + const headers: Record = { + "Content-Type": "application/json", + }; + + if (this.QDRANT_API_KEY) { + headers["api-key"] = this.QDRANT_API_KEY; + } + + return headers; + } +} 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..3551d7b90 --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/tests/qdrant/qdrant.test.ts @@ -0,0 +1,138 @@ +import { Qdrant, QdrantDistanceMetric } from "../../lib/qdrant/qdrant.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("Qdrant", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("creates a collection using the Qdrant HTTP API", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ result: true }), + }); + global.fetch = fetchMock; + + const qdrant = new Qdrant("http://localhost:6333", "test-api-key"); + await qdrant.createCollection({ + collectionName: "documents", + vectorSize: 1536, + distance: QdrantDistanceMetric.COSINE, + }); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:6333/collections/documents", + expect.objectContaining({ + method: "PUT", + headers: { + "Content-Type": "application/json", + "api-key": "test-api-key", + }, + body: JSON.stringify({ + vectors: { + size: 1536, + distance: "Cosine", + }, + }), + }) + ); + }); + + it("upserts points without using a Qdrant package", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ result: { operation_id: 1 } }), + }); + global.fetch = fetchMock; + + const qdrant = new Qdrant("http://localhost:6333"); + await qdrant.upsertPoints({ + collectionName: "documents", + points: [ + { + id: 1, + vector: [0.1, 0.2, 0.3], + payload: { raw_text: "hello" }, + }, + ], + }); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:6333/collections/documents/points?wait=true", + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ + points: [ + { + id: 1, + vector: [0.1, 0.2, 0.3], + payload: { raw_text: "hello" }, + }, + ], + }), + }) + ); + }); + + it("returns search results from Qdrant", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + result: [ + { + id: 1, + score: 0.98, + payload: { raw_text: "matched document" }, + }, + ], + }), + }); + global.fetch = fetchMock; + + const qdrant = new Qdrant("http://localhost:6333"); + const result = await qdrant.searchPoints({ + collectionName: "documents", + vector: [0.1, 0.2, 0.3], + limit: 3, + }); + + expect(result).toEqual([ + { + id: 1, + score: 0.98, + payload: { raw_text: "matched document" }, + }, + ]); + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:6333/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 an error when Qdrant returns a failed response", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ status: { error: "Collection not found" } }), + }); + + const qdrant = new Qdrant("http://localhost:6333"); + + await expect( + qdrant.searchPoints({ + collectionName: "missing", + vector: [0.1], + }) + ).rejects.toThrow("Qdrant request failed with status 404"); + }); +});