From b56dac737eac4c0849c6014ed48a2c05b2691425 Mon Sep 17 00:00:00 2001 From: Shaidyk Date: Fri, 12 Jun 2026 17:05:24 +0300 Subject: [PATCH] fix: resolve #273 - BOUNTY: add support for qdrant vector database in javascript --- .../arakoodev/src/vector-db/src/index.ts | 1 + .../src/vector-db/src/lib/qdrant/qdrant.ts | 268 ++++++++++++++++++ .../vector-db/src/tests/qdrant/qdrant.test.ts | 164 +++++++++++ 3 files changed, 433 insertions(+) create mode 100644 JS/edgechains/arakoodev/src/vector-db/src/lib/qdrant/qdrant.ts create mode 100644 JS/edgechains/arakoodev/src/vector-db/src/tests/qdrant/qdrant.test.ts diff --git a/JS/edgechains/arakoodev/src/vector-db/src/index.ts b/JS/edgechains/arakoodev/src/vector-db/src/index.ts index 557104a14..5ca60f642 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, QdrantDistanceMetric } 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..f27f915a7 --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/lib/qdrant/qdrant.ts @@ -0,0 +1,268 @@ +import axios, { AxiosInstance } from "axios"; + +export enum QdrantDistanceMetric { + COSINE = "Cosine", + EUCLID = "Euclid", + DOT = "Dot", + MANHATTAN = "Manhattan", +} + +interface QdrantPoint { + id: number | string; + vector: number[]; + payload?: Record; +} + +interface CreateCollectionArgs { + client: AxiosInstance; + collectionName: string; + vectorSize: number; + distance?: QdrantDistanceMetric; +} + +interface InsertVectorDataArgs { + client: AxiosInstance; + collectionName: string; + points: QdrantPoint[]; +} + +interface GetDataFromQueryArgs { + client: AxiosInstance; + collectionName: string; + vector: number[]; + topK?: number; + filter?: Record; + withPayload?: boolean; +} + +interface GetDataArgs { + client: AxiosInstance; + collectionName: string; + limit?: number; + withPayload?: boolean; +} + +interface GetDataByIdArgs { + client: AxiosInstance; + collectionName: string; + id: number | string; +} + +interface UpdateByIdArgs { + client: AxiosInstance; + collectionName: string; + id: number | string; + payload: Record; +} + +interface DeleteByIdArgs { + client: AxiosInstance; + collectionName: string; + id: number | string; +} + +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!; + this.QDRANT_API_KEY = QDRANT_API_KEY || process.env.QDRANT_API_KEY || ""; + if (!this.QDRANT_URL) { + throw new Error( + "Qdrant URL is missing. Please provide a valid Qdrant URL or set QDRANT_URL in your .env file." + ); + } + } + + // Extract the most useful message Qdrant returns on an error response. + private extractError(error: any): string { + return error.response?.data?.status?.error || error.message; + } + + /** + * Create a configured axios client pointed at the Qdrant REST API. + * The api-key header is only attached when an API key is provided + * (a local Qdrant instance does not require one). + * @returns An axios instance scoped to the Qdrant base URL. + */ + createClient(): AxiosInstance { + const headers: Record = { "content-type": "application/json" }; + if (this.QDRANT_API_KEY) { + headers["api-key"] = this.QDRANT_API_KEY; + } + return axios.create({ + baseURL: this.QDRANT_URL.replace(/\/$/, ""), + headers, + }); + } + + /** + * Create a collection to store vectors in. + * @param client The Qdrant axios client instance. + * @param collectionName The name of the collection to create. + * @param vectorSize The dimensionality of the vectors stored in the collection. + * @param distance The distance metric used for similarity search. + * @returns The Qdrant response body if successful. + * @throws Error if collection creation fails. + */ + async createCollection({ + client, + collectionName, + vectorSize, + distance = QdrantDistanceMetric.COSINE, + }: CreateCollectionArgs): Promise { + try { + const res = await client.put(`/collections/${collectionName}`, { + vectors: { size: vectorSize, distance }, + }); + return res.data; + } catch (error: any) { + throw new Error( + `Failed to create collection "${collectionName}": ${this.extractError(error)}` + ); + } + } + + /** + * Insert (upsert) vector points into a collection. + * @param client The Qdrant axios client instance. + * @param collectionName The name of the collection to insert into. + * @param points The points (id, vector and optional payload) to upsert. + * @returns The Qdrant response body if successful. + * @throws Error if insertion fails. + */ + async insertVectorData({ client, collectionName, points }: InsertVectorDataArgs): Promise { + try { + const res = await client.put(`/collections/${collectionName}/points`, { points }); + return res.data; + } catch (error: any) { + throw new Error( + `Failed to insert points into "${collectionName}": ${this.extractError(error)}` + ); + } + } + + /** + * Search the collection for the points nearest to the given vector. + * @param client The Qdrant axios client instance. + * @param collectionName The name of the collection to search. + * @param vector The query embedding to search with. + * @param topK The maximum number of results to return. + * @param filter An optional Qdrant payload filter. + * @param withPayload Whether to include the stored payload in the results. + * @returns The matched points if successful. + * @throws Error if the search fails. + */ + async getDataFromQuery({ + client, + collectionName, + vector, + topK = 10, + filter, + withPayload = true, + }: GetDataFromQueryArgs): Promise { + try { + const res = await client.post(`/collections/${collectionName}/points/search`, { + vector, + limit: topK, + filter, + with_payload: withPayload, + }); + return res.data.result; + } catch (error: any) { + throw new Error(`Failed to search "${collectionName}": ${this.extractError(error)}`); + } + } + + /** + * Scroll through the points stored in a collection. + * @param client The Qdrant axios client instance. + * @param collectionName The name of the collection to read from. + * @param limit The maximum number of points to return. + * @param withPayload Whether to include the stored payload in the results. + * @returns The points if successful. + * @throws Error if fetching fails. + */ + async getData({ + client, + collectionName, + limit = 10, + withPayload = true, + }: GetDataArgs): Promise { + try { + const res = await client.post(`/collections/${collectionName}/points/scroll`, { + limit, + with_payload: withPayload, + }); + return res.data.result.points; + } catch (error: any) { + throw new Error( + `Failed to fetch data from "${collectionName}": ${this.extractError(error)}` + ); + } + } + + /** + * Fetch a single point by id. + * @param client The Qdrant axios client instance. + * @param collectionName The name of the collection to read from. + * @param id The id of the point. + * @returns The point if successful. + * @throws Error if fetching fails. + */ + async getDataById({ client, collectionName, id }: GetDataByIdArgs): Promise { + try { + const res = await client.get(`/collections/${collectionName}/points/${id}`); + return res.data.result; + } catch (error: any) { + throw new Error( + `Failed to fetch id "${id}" from "${collectionName}": ${this.extractError(error)}` + ); + } + } + + /** + * Update (set) the payload of a point by id. + * @param client The Qdrant axios client instance. + * @param collectionName The name of the collection to update. + * @param id The id of the point. + * @param payload The payload fields to set on the point. + * @returns The Qdrant response body if successful. + * @throws Error if updating fails. + */ + async updateById({ client, collectionName, id, payload }: UpdateByIdArgs): Promise { + try { + const res = await client.post(`/collections/${collectionName}/points/payload`, { + payload, + points: [id], + }); + return res.data; + } catch (error: any) { + throw new Error( + `Failed to update id "${id}" in "${collectionName}": ${this.extractError(error)}` + ); + } + } + + /** + * Delete a point by id. + * @param client The Qdrant axios client instance. + * @param collectionName The name of the collection to delete from. + * @param id The id of the point. + * @returns The Qdrant response body if successful. + * @throws Error if deleting fails. + */ + async deleteById({ client, collectionName, id }: DeleteByIdArgs): Promise { + try { + const res = await client.post(`/collections/${collectionName}/points/delete`, { + points: [id], + }); + return res.data; + } catch (error: any) { + throw new Error( + `Failed to delete id "${id}" from "${collectionName}": ${this.extractError(error)}` + ); + } + } +} 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..5fcf987b7 --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/tests/qdrant/qdrant.test.ts @@ -0,0 +1,164 @@ +import { + Qdrant, + QdrantDistanceMetric, +} from "../../../../../dist/vector-db/src/lib/qdrant/qdrant.js"; + +const MOCK_QDRANT_URL = "https://mock-qdrant.io"; +const MOCK_QDRANT_API_KEY = "mock-api-key"; + +// A mock axios-like client so tests run without a real Qdrant instance. +function createMockClient() { + return { + put: jest.fn(), + post: jest.fn(), + get: jest.fn(), + }; +} + +describe("Qdrant", () => { + describe("constructor", () => { + it("should throw when no URL is provided", () => { + expect(() => new Qdrant("")).toThrow(/Qdrant URL is missing/); + }); + }); + + describe("createCollection", () => { + it("should create a collection with the given vector size and distance", async () => { + const qdrant = new Qdrant(MOCK_QDRANT_URL, MOCK_QDRANT_API_KEY); + const client = createMockClient(); + client.put.mockResolvedValueOnce({ data: { result: true, status: "ok" } }); + + const res = await qdrant.createCollection({ + client: client as any, + collectionName: "documents", + vectorSize: 1536, + distance: QdrantDistanceMetric.COSINE, + }); + + expect(client.put).toHaveBeenCalledWith("/collections/documents", { + vectors: { size: 1536, distance: "Cosine" }, + }); + expect(res).toEqual({ result: true, status: "ok" }); + }); + }); + + describe("insertVectorData", () => { + it("should upsert points into the collection", async () => { + const qdrant = new Qdrant(MOCK_QDRANT_URL, MOCK_QDRANT_API_KEY); + const client = createMockClient(); + client.put.mockResolvedValueOnce({ data: { result: { status: "completed" } } }); + + const points = [ + { id: 1, vector: Array.from({ length: 4 }, (_, i) => i), payload: { content: "hi" } }, + ]; + const res = await qdrant.insertVectorData({ + client: client as any, + collectionName: "documents", + points, + }); + + expect(client.put).toHaveBeenCalledWith("/collections/documents/points", { points }); + expect(res).toEqual({ result: { status: "completed" } }); + }); + + it("should throw a descriptive error when insertion fails", async () => { + const qdrant = new Qdrant(MOCK_QDRANT_URL, MOCK_QDRANT_API_KEY); + const client = createMockClient(); + client.put.mockRejectedValueOnce({ + response: { data: { status: { error: "Wrong vector size" } } }, + }); + + await expect( + qdrant.insertVectorData({ + client: client as any, + collectionName: "documents", + points: [{ id: 1, vector: [0, 1] }], + }) + ).rejects.toThrow('Failed to insert points into "documents"'); + }); + }); + + describe("getDataFromQuery", () => { + it("should search the collection by vector and return the matched points", async () => { + const qdrant = new Qdrant(MOCK_QDRANT_URL, MOCK_QDRANT_API_KEY); + const client = createMockClient(); + const matches = [{ id: 1, score: 0.9, payload: { content: "hi" } }]; + client.post.mockResolvedValueOnce({ data: { result: matches } }); + + const vector = [0.1, 0.2, 0.3]; + const res = await qdrant.getDataFromQuery({ + client: client as any, + collectionName: "documents", + vector, + topK: 5, + }); + + expect(client.post).toHaveBeenCalledWith("/collections/documents/points/search", { + vector, + limit: 5, + filter: undefined, + with_payload: true, + }); + expect(res).toEqual(matches); + }); + }); + + describe("getDataById", () => { + it("should fetch a single point by id", async () => { + const qdrant = new Qdrant(MOCK_QDRANT_URL, MOCK_QDRANT_API_KEY); + const client = createMockClient(); + const point = { id: 1, payload: { content: "hi" } }; + client.get.mockResolvedValueOnce({ data: { result: point } }); + + const res = await qdrant.getDataById({ + client: client as any, + collectionName: "documents", + id: 1, + }); + + expect(client.get).toHaveBeenCalledWith("/collections/documents/points/1"); + expect(res).toEqual(point); + }); + }); + + describe("updateById", () => { + it("should set the payload of a point by id", async () => { + const qdrant = new Qdrant(MOCK_QDRANT_URL, MOCK_QDRANT_API_KEY); + const client = createMockClient(); + client.post.mockResolvedValueOnce({ data: { result: { status: "completed" } } }); + + const payload = { content: "updated" }; + const res = await qdrant.updateById({ + client: client as any, + collectionName: "documents", + id: 1, + payload, + }); + + expect(client.post).toHaveBeenCalledWith("/collections/documents/points/payload", { + payload, + points: [1], + }); + expect(res).toEqual({ result: { status: "completed" } }); + }); + }); + + describe("deleteById", () => { + it("should delete a point by id", async () => { + const qdrant = new Qdrant(MOCK_QDRANT_URL, MOCK_QDRANT_API_KEY); + const client = createMockClient(); + client.post.mockResolvedValueOnce({ data: { result: { status: "completed" } } }); + + const res = await qdrant.deleteById({ + client: client as any, + collectionName: "documents", + id: 1, + }); + + expect(client.post).toHaveBeenCalledWith("/collections/documents/points/delete", { + points: [1], + }); + expect(res).toEqual({ result: { status: "completed" } }); + }); + }); +});