diff --git a/JS/edgechains/arakoodev/src/vector-db/src/index.ts b/JS/edgechains/arakoodev/src/vector-db/src/index.ts index 557104a14..258d09ebf 100644 --- a/JS/edgechains/arakoodev/src/vector-db/src/index.ts +++ b/JS/edgechains/arakoodev/src/vector-db/src/index.ts @@ -1 +1,3 @@ export { Supabase } from "./lib/supabase/supabase.js"; +export { Qdrant } from "./lib/qdrant/qdrant.js"; +export type { QdrantClientOptions, QdrantPoint, QdrantSearchResult } 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..d3436c394 --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/lib/qdrant/qdrant.ts @@ -0,0 +1,281 @@ +type QdrantFetch = typeof fetch; + +export interface QdrantClientOptions { + url?: string; + apiKey?: string; + fetcher?: QdrantFetch; +} + +export interface QdrantPoint { + id: string | number; + vector: number[] | Record; + payload?: Record; +} + +export interface QdrantSearchResult { + id: string | number; + score?: number; + payload?: Record; + vector?: number[] | Record; +} + +interface QdrantRequestOptions { + method?: string; + body?: unknown; + query?: Record; +} + +export class Qdrant { + QDRANT_URL: string; + QDRANT_API_KEY?: string; + private readonly fetcher: QdrantFetch; + + constructor(QDRANT_URL?: string, QDRANT_API_KEY?: string, options: QdrantClientOptions = {}) { + this.QDRANT_URL = (options.url || QDRANT_URL || process.env.QDRANT_URL || "").replace( + /\/$/, + "" + ); + this.QDRANT_API_KEY = options.apiKey || QDRANT_API_KEY || process.env.QDRANT_API_KEY; + this.fetcher = options.fetcher || fetch; + + if (!this.QDRANT_URL) { + throw new Error("Qdrant URL is required. Pass QDRANT_URL or set process.env.QDRANT_URL."); + } + } + + createClient() { + return this; + } + + async createCollection({ + collectionName, + vectorSize, + distance = "Cosine", + }: { + collectionName: string; + vectorSize: number; + distance?: "Cosine" | "Euclid" | "Dot" | "Manhattan"; + }) { + return this.request(`/collections/${collectionName}`, { + method: "PUT", + body: { + vectors: { + size: vectorSize, + distance, + }, + }, + }); + } + + async insertVectorData({ + collectionName, + tableName, + points, + id, + embedding, + vector, + payload, + content, + ...rest + }: { + collectionName?: string; + tableName?: string; + points?: QdrantPoint[]; + id?: string | number; + embedding?: number[]; + vector?: number[] | Record; + payload?: Record; + content?: string; + [key: string]: unknown; + }) { + const targetCollection = collectionName || tableName; + if (!targetCollection) throw new Error("collectionName is required"); + + const qdrantPoints = + points || + [ + { + id: id || Date.now(), + vector: vector || embedding || [], + payload: payload || { content, ...rest }, + }, + ]; + + return this.request(`/collections/${targetCollection}/points`, { + method: "PUT", + body: { + points: qdrantPoints, + }, + }); + } + + async getDataFromQuery({ + collectionName, + tableName, + vector, + embedding, + limit = 10, + filter, + withPayload = true, + withVector = false, + }: { + collectionName?: string; + tableName?: string; + vector?: number[] | Record; + embedding?: number[]; + limit?: number; + filter?: Record; + withPayload?: boolean; + withVector?: boolean; + }): Promise { + const targetCollection = collectionName || tableName; + if (!targetCollection) throw new Error("collectionName is required"); + + const response = await this.request(`/collections/${targetCollection}/points/search`, { + method: "POST", + body: { + vector: vector || embedding, + limit, + filter, + with_payload: withPayload, + with_vector: withVector, + }, + }); + + return response.result || []; + } + + async getData({ + collectionName, + tableName, + limit = 10, + offset, + filter, + withPayload = true, + withVector = false, + }: { + collectionName?: string; + tableName?: string; + limit?: number; + offset?: string | number; + filter?: Record; + withPayload?: boolean; + withVector?: boolean; + }) { + const targetCollection = collectionName || tableName; + if (!targetCollection) throw new Error("collectionName is required"); + + const response = await this.request(`/collections/${targetCollection}/points/scroll`, { + method: "POST", + body: { + limit, + offset, + filter, + with_payload: withPayload, + with_vector: withVector, + }, + }); + + return response.result; + } + + async getDataById({ + collectionName, + tableName, + id, + withPayload = true, + withVector = false, + }: { + collectionName?: string; + tableName?: string; + id: string | number; + withPayload?: boolean; + withVector?: boolean; + }) { + const targetCollection = collectionName || tableName; + if (!targetCollection) throw new Error("collectionName is required"); + + const response = await this.request(`/collections/${targetCollection}/points`, { + method: "POST", + body: { + ids: [id], + with_payload: withPayload, + with_vector: withVector, + }, + }); + + return response.result?.[0] || null; + } + + async updateById({ + collectionName, + tableName, + id, + updatedContent, + payload, + }: { + collectionName?: string; + tableName?: string; + id: string | number; + updatedContent?: Record; + payload?: Record; + }) { + const targetCollection = collectionName || tableName; + if (!targetCollection) throw new Error("collectionName is required"); + + return this.request(`/collections/${targetCollection}/points/payload`, { + method: "POST", + body: { + points: [id], + payload: payload || updatedContent || {}, + }, + }); + } + + async deleteById({ + collectionName, + tableName, + id, + }: { + collectionName?: string; + tableName?: string; + id: string | number; + }) { + const targetCollection = collectionName || tableName; + if (!targetCollection) throw new Error("collectionName is required"); + + return this.request(`/collections/${targetCollection}/points/delete`, { + method: "POST", + body: { + points: [id], + }, + }); + } + + private async request(path: string, options: QdrantRequestOptions = {}) { + const url = new URL(`${this.QDRANT_URL}${path}`); + Object.entries(options.query || {}).forEach(([key, value]) => { + if (value !== undefined) url.searchParams.set(key, String(value)); + }); + + const response = await this.fetcher(url.toString(), { + method: options.method || "GET", + headers: { + "Content-Type": "application/json", + ...(this.QDRANT_API_KEY ? { "api-key": this.QDRANT_API_KEY } : {}), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + + const text = await response.text(); + const data = text ? JSON.parse(text) : {}; + + if (!response.ok) { + throw new Error( + `Qdrant request failed with ${response.status}: ${data.status?.error || text}` + ); + } + + return data; + } +} 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..95cba7c35 --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/tests/qdrant/qdrant.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest"; +import { Qdrant } from "../../lib/qdrant/qdrant.js"; + +function jsonResponse(body: unknown, ok = true, status = 200) { + return { + ok, + status, + text: vi.fn().mockResolvedValue(JSON.stringify(body)), + } as unknown as Response; +} + +describe("Qdrant", () => { + it("creates a collection with vector configuration", async () => { + const fetcher = vi.fn().mockResolvedValue(jsonResponse({ result: true })); + const qdrant = new Qdrant("https://qdrant.test", "secret", { fetcher }); + + await qdrant.createCollection({ + collectionName: "documents", + vectorSize: 1536, + }); + + expect(fetcher).toHaveBeenCalledWith( + "https://qdrant.test/collections/documents", + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ "api-key": "secret" }), + body: JSON.stringify({ + vectors: { + size: 1536, + distance: "Cosine", + }, + }), + }) + ); + }); + + it("upserts vector points using the REST API", async () => { + const fetcher = vi.fn().mockResolvedValue(jsonResponse({ result: { status: "ok" } })); + const qdrant = new Qdrant("https://qdrant.test", undefined, { fetcher }); + + await qdrant.insertVectorData({ + collectionName: "documents", + id: 1, + embedding: [0.1, 0.2], + content: "hello", + }); + + expect(fetcher).toHaveBeenCalledWith( + "https://qdrant.test/collections/documents/points", + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ + points: [ + { + id: 1, + vector: [0.1, 0.2], + payload: { content: "hello" }, + }, + ], + }), + }) + ); + }); + + it("returns normalized search results", async () => { + const fetcher = vi.fn().mockResolvedValue( + jsonResponse({ + result: [{ id: 1, score: 0.99, payload: { content: "match" } }], + }) + ); + const qdrant = new Qdrant("https://qdrant.test", undefined, { fetcher }); + + const result = await qdrant.getDataFromQuery({ + collectionName: "documents", + vector: [0.1, 0.2], + limit: 1, + }); + + expect(result).toEqual([{ id: 1, score: 0.99, payload: { content: "match" } }]); + }); + + it("deletes a point by id", async () => { + const fetcher = vi.fn().mockResolvedValue(jsonResponse({ result: { status: "ok" } })); + const qdrant = new Qdrant("https://qdrant.test", undefined, { fetcher }); + + await qdrant.deleteById({ collectionName: "documents", id: "abc" }); + + expect(fetcher).toHaveBeenCalledWith( + "https://qdrant.test/collections/documents/points/delete", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ points: ["abc"] }), + }) + ); + }); +}); diff --git a/JS/edgechains/examples/qdrant-vector-db/README.md b/JS/edgechains/examples/qdrant-vector-db/README.md new file mode 100644 index 000000000..a7b1d68ed --- /dev/null +++ b/JS/edgechains/examples/qdrant-vector-db/README.md @@ -0,0 +1,29 @@ +# Qdrant vector database example + +This example shows how to use the EdgeChains JavaScript SDK with Qdrant through +the direct REST API wrapper. + +```ts +import { Qdrant } from "@arakoodev/edgechains.js/vector-db"; + +const qdrant = new Qdrant(process.env.QDRANT_URL, process.env.QDRANT_API_KEY); + +await qdrant.createCollection({ + collectionName: "documents", + vectorSize: 3, +}); + +await qdrant.insertVectorData({ + collectionName: "documents", + id: 1, + embedding: [0.1, 0.2, 0.3], + content: "EdgeChains can store vectors in Qdrant", +}); + +const matches = await qdrant.getDataFromQuery({ + collectionName: "documents", + vector: [0.1, 0.2, 0.3], +}); + +console.log(matches); +```