diff --git a/JS/edgechains/arakoodev/src/vector-db/src/index.ts b/JS/edgechains/arakoodev/src/vector-db/src/index.ts index 557104a14..584bb1b32 100644 --- a/JS/edgechains/arakoodev/src/vector-db/src/index.ts +++ b/JS/edgechains/arakoodev/src/vector-db/src/index.ts @@ -1 +1,9 @@ export { Supabase } from "./lib/supabase/supabase.js"; +export { Qdrant } from "./lib/qdrant/qdrant.js"; +export type { + QdrantCollectionOptions, + QdrantPoint, + QdrantPointId, + QdrantQueryOptions, + QdrantScoredPoint, +} 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..19ddff70b --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/lib/qdrant/qdrant.ts @@ -0,0 +1,126 @@ +export type QdrantPointId = string | number; + +export interface QdrantPoint { + id: QdrantPointId; + vector: number[] | Record; + payload?: Record; +} + +export interface QdrantScoredPoint extends QdrantPoint { + score: number; +} + +export interface QdrantCollectionOptions { + size: number; + distance?: "Cosine" | "Euclid" | "Dot" | "Manhattan"; +} + +export interface QdrantQueryOptions { + limit?: number; + filter?: Record; + withPayload?: boolean; + withVector?: boolean; +} + +export interface QdrantOptions { + url: string; + apiKey?: string; + fetcher?: typeof fetch; +} + +export class Qdrant { + private readonly baseUrl: string; + private readonly apiKey?: string; + private readonly fetcher: typeof fetch; + + constructor(options: QdrantOptions) { + const url = new URL(options.url); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Qdrant URL must use HTTP or HTTPS"); + } + this.baseUrl = url.toString().replace(/\/$/, ""); + this.apiKey = options.apiKey; + this.fetcher = options.fetcher || fetch; + } + + createCollection(name: string, options: QdrantCollectionOptions) { + if (!Number.isSafeInteger(options.size) || options.size < 1) { + throw new Error("Qdrant vector size must be a positive integer"); + } + return this.request("PUT", `/collections/${collection(name)}`, { + vectors: { size: options.size, distance: options.distance || "Cosine" }, + }); + } + + upsert(name: string, points: QdrantPoint[], wait = true) { + if (!points.length) throw new Error("At least one Qdrant point is required"); + return this.request("PUT", `/collections/${collection(name)}/points?wait=${wait}`, { + points, + }); + } + + query( + name: string, + vector: number[] | Record, + options: QdrantQueryOptions = {} + ): Promise { + return this.request<{ points: QdrantScoredPoint[] }>( + "POST", + `/collections/${collection(name)}/points/query`, + { + query: vector, + limit: options.limit ?? 10, + filter: options.filter, + with_payload: options.withPayload ?? true, + with_vector: options.withVector ?? false, + } + ).then((result) => result.points); + } + + retrieve( + name: string, + ids: QdrantPointId[], + withPayload = true, + withVector = false + ): Promise { + if (!ids.length) throw new Error("At least one Qdrant point ID is required"); + return this.request("POST", `/collections/${collection(name)}/points`, { + ids, + with_payload: withPayload, + with_vector: withVector, + }); + } + + delete(name: string, ids: QdrantPointId[], wait = true) { + if (!ids.length) throw new Error("At least one Qdrant point ID is required"); + return this.request("POST", `/collections/${collection(name)}/points/delete?wait=${wait}`, { + points: ids, + }); + } + + private async request(method: string, path: string, body: unknown): Promise { + const response = await this.fetcher(`${this.baseUrl}${path}`, { + method, + headers: { + "content-type": "application/json", + ...(this.apiKey ? { "api-key": this.apiKey } : {}), + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) { + const detail = await response.text(); + throw new Error( + `Qdrant returned HTTP ${response.status}${detail ? `: ${detail}` : ""}` + ); + } + const payload = (await response.json()) as { result: T }; + return payload.result; + } +} + +function collection(name: string): string { + const value = name.trim(); + if (!value) throw new Error("Qdrant collection name cannot be empty"); + return encodeURIComponent(value); +} 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..97b7da91e --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/tests/qdrant/qdrant.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from "vitest"; +import { Qdrant } from "../../lib/qdrant/qdrant.js"; + +function response(result: unknown, status = 200) { + return new Response(JSON.stringify({ result }), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("Qdrant", () => { + it("uses the direct REST API for collection creation and point upserts", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(response(true)) + .mockResolvedValueOnce(response({ status: "completed" })); + const qdrant = new Qdrant({ + url: "https://qdrant.example/", + apiKey: "secret", + fetcher, + }); + + await qdrant.createCollection("docs", { size: 3 }); + await qdrant.upsert("docs", [{ id: 1, vector: [0.1, 0.2, 0.3] }]); + + expect(fetcher).toHaveBeenNthCalledWith( + 1, + "https://qdrant.example/collections/docs", + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ "api-key": "secret" }), + body: JSON.stringify({ vectors: { size: 3, distance: "Cosine" } }), + }) + ); + expect(fetcher).toHaveBeenNthCalledWith( + 2, + "https://qdrant.example/collections/docs/points?wait=true", + expect.objectContaining({ method: "PUT" }) + ); + }); + + it("queries through the current points/query endpoint", async () => { + const points = [{ id: 1, vector: [1, 0], score: 0.9 }]; + const fetcher = vi.fn().mockResolvedValue(response({ points })); + const qdrant = new Qdrant({ url: "http://localhost:6333", fetcher }); + + await expect(qdrant.query("docs", [1, 0], { limit: 2 })).resolves.toEqual(points); + expect(fetcher).toHaveBeenCalledWith( + "http://localhost:6333/collections/docs/points/query", + expect.objectContaining({ + body: JSON.stringify({ + query: [1, 0], + limit: 2, + with_payload: true, + with_vector: false, + }), + }) + ); + }); + + it("surfaces HTTP failures", async () => { + const fetcher = vi + .fn() + .mockResolvedValue(new Response("missing", { status: 404 })); + const qdrant = new Qdrant({ url: "http://localhost:6333", fetcher }); + + await expect(qdrant.retrieve("missing", [1])).rejects.toThrow( + "Qdrant returned HTTP 404: missing" + ); + }); +});