From f202efb66a4749e41899aa9129df4418d943e86f Mon Sep 17 00:00:00 2001 From: apollo-jpg Date: Sun, 10 May 2026 19:21:17 -0500 Subject: [PATCH] feat(js): add qdrant vector database client --- .../arakoodev/src/vector-db/src/index.ts | 2 + .../src/vector-db/src/lib/qdrant/qdrant.ts | 152 ++++++++++++++++++ .../vector-db/src/tests/qdrant/qdrant.test.ts | 122 ++++++++++++++ .../qdrant-vector-search/jsonnet/main.jsonnet | 30 ++++ .../qdrant-vector-search/package.json | 12 ++ .../examples/qdrant-vector-search/readme.md | 27 ++++ .../qdrant-vector-search/src/index.ts | 50 ++++++ 7 files changed, 395 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 create mode 100644 JS/edgechains/examples/qdrant-vector-search/jsonnet/main.jsonnet create mode 100644 JS/edgechains/examples/qdrant-vector-search/package.json create mode 100644 JS/edgechains/examples/qdrant-vector-search/readme.md create mode 100644 JS/edgechains/examples/qdrant-vector-search/src/index.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..ff037fbd2 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 { QdrantCollectionArgs, QdrantPoint, QdrantSearchArgs } 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..e5abc238a --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/lib/qdrant/qdrant.ts @@ -0,0 +1,152 @@ +type QdrantPayload = Record; + +export interface QdrantPoint { + id: string | number; + vector: number[]; + payload?: QdrantPayload; +} + +export interface QdrantSearchArgs { + collectionName: string; + vector: number[]; + limit?: number; + filter?: QdrantPayload; + withPayload?: boolean | QdrantPayload; + withVector?: boolean; +} + +export interface QdrantCollectionArgs { + collectionName: string; + vectorSize: number; + distance?: "Cosine" | "Dot" | "Euclid" | "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; + + if (!this.QDRANT_URL) { + throw new Error("QDRANT_URL is required"); + } + } + + private async request(path: string, init: RequestInit = {}): Promise { + const headers: Record = { + "Content-Type": "application/json", + ...(init.headers as Record | undefined), + }; + + if (this.QDRANT_API_KEY) { + headers["api-key"] = this.QDRANT_API_KEY; + } + + const response = await fetch(`${this.QDRANT_URL}${path}`, { + ...init, + headers, + }); + + const text = await response.text(); + const data = text ? JSON.parse(text) : {}; + + if (!response.ok) { + throw new Error( + `Qdrant request failed with ${response.status}: ${JSON.stringify(data)}` + ); + } + + return data as T; + } + + async createCollection({ + collectionName, + vectorSize, + distance = "Cosine", + }: QdrantCollectionArgs): Promise { + return this.request(`/collections/${collectionName}`, { + method: "PUT", + body: JSON.stringify({ + vectors: { + size: vectorSize, + distance, + }, + }), + }); + } + + async insertVectorData({ + collectionName, + points, + wait = true, + }: { + collectionName: string; + points: QdrantPoint[]; + wait?: boolean; + }): Promise { + return this.request(`/collections/${collectionName}/points?wait=${wait}`, { + method: "PUT", + body: JSON.stringify({ points }), + }); + } + + async getDataById({ + collectionName, + ids, + withPayload = true, + withVector = false, + }: { + collectionName: string; + ids: Array; + withPayload?: boolean | QdrantPayload; + withVector?: boolean; + }): Promise { + return this.request(`/collections/${collectionName}/points`, { + method: "POST", + body: JSON.stringify({ + ids, + with_payload: withPayload, + with_vector: withVector, + }), + }); + } + + async getDataFromQuery({ + collectionName, + vector, + limit = 10, + filter, + withPayload = true, + withVector = false, + }: QdrantSearchArgs): Promise { + return this.request(`/collections/${collectionName}/points/search`, { + method: "POST", + body: JSON.stringify({ + vector, + limit, + filter, + with_payload: withPayload, + with_vector: withVector, + }), + }); + } + + async deleteById({ + collectionName, + ids, + wait = true, + }: { + collectionName: string; + ids: Array; + wait?: boolean; + }): Promise { + return this.request(`/collections/${collectionName}/points/delete?wait=${wait}`, { + method: "POST", + body: JSON.stringify({ + points: ids, + }), + }); + } +} 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..19e855997 --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/tests/qdrant/qdrant.test.ts @@ -0,0 +1,122 @@ +import { Qdrant } from "../../lib/qdrant/qdrant.js"; + +const jsonResponse = (body: unknown, ok = true, status = 200) => + ({ + ok, + status, + text: async () => JSON.stringify(body), + }) as Response; + +describe("Qdrant", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("creates a collection using the Qdrant REST API", async () => { + const fetchMock = jest + .spyOn(global, "fetch") + .mockResolvedValue(jsonResponse({ result: true })); + + const qdrant = new Qdrant("http://localhost:6333", "test-key"); + await qdrant.createCollection({ + collectionName: "documents", + vectorSize: 1536, + }); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:6333/collections/documents", + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ + "Content-Type": "application/json", + "api-key": "test-key", + }), + body: JSON.stringify({ + vectors: { + size: 1536, + distance: "Cosine", + }, + }), + }) + ); + }); + + it("upserts vector points", async () => { + const fetchMock = jest + .spyOn(global, "fetch") + .mockResolvedValue(jsonResponse({ result: { operation_id: 1 } })); + + const qdrant = new Qdrant("http://localhost:6333"); + await qdrant.insertVectorData({ + collectionName: "documents", + points: [ + { + id: 1, + vector: [0.1, 0.2, 0.3], + payload: { content: "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: { content: "hello" }, + }, + ], + }), + }) + ); + }); + + it("searches vectors with filters", async () => { + const fetchMock = jest.spyOn(global, "fetch").mockResolvedValue( + jsonResponse({ + result: [{ id: 1, score: 0.9, payload: { content: "hello" } }], + }) + ); + + const qdrant = new Qdrant("http://localhost:6333"); + await qdrant.getDataFromQuery({ + collectionName: "documents", + vector: [0.1, 0.2, 0.3], + limit: 3, + filter: { must: [{ key: "source", match: { value: "docs" } }] }, + }); + + 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, + filter: { must: [{ key: "source", match: { value: "docs" } }] }, + with_payload: true, + with_vector: false, + }), + }) + ); + }); + + it("throws readable errors from Qdrant", async () => { + jest.spyOn(global, "fetch").mockResolvedValue( + jsonResponse({ status: { error: "missing collection" } }, false, 404) + ); + + const qdrant = new Qdrant("http://localhost:6333"); + + await expect( + qdrant.getDataById({ + collectionName: "missing", + ids: [1], + }) + ).rejects.toThrow("Qdrant request failed with 404"); + }); +}); diff --git a/JS/edgechains/examples/qdrant-vector-search/jsonnet/main.jsonnet b/JS/edgechains/examples/qdrant-vector-search/jsonnet/main.jsonnet new file mode 100644 index 000000000..cbdf573f8 --- /dev/null +++ b/JS/edgechains/examples/qdrant-vector-search/jsonnet/main.jsonnet @@ -0,0 +1,30 @@ +{ + qdrant: { + url: std.extVar("QDRANT_URL"), + apiKey: std.extVar("QDRANT_API_KEY"), + collectionName: "edgechains_qdrant_example", + vectorSize: 3, + }, + documents: [ + { + id: 1, + vector: [0.1, 0.2, 0.3], + payload: { + content: "EdgeChains can query Qdrant using the REST API.", + source: "docs", + }, + }, + { + id: 2, + vector: [0.2, 0.1, 0.4], + payload: { + content: "Qdrant stores vectors and metadata payloads.", + source: "docs", + }, + }, + ], + query: { + vector: [0.1, 0.2, 0.3], + limit: 2, + }, +} diff --git a/JS/edgechains/examples/qdrant-vector-search/package.json b/JS/edgechains/examples/qdrant-vector-search/package.json new file mode 100644 index 000000000..0f2697494 --- /dev/null +++ b/JS/edgechains/examples/qdrant-vector-search/package.json @@ -0,0 +1,12 @@ +{ + "name": "qdrant-vector-search", + "private": true, + "type": "module", + "scripts": { + "start": "tsx src/index.ts" + }, + "dependencies": { + "@arakoodev/edgechains.js": "file:../../arakoodev", + "tsx": "^4.7.2" + } +} diff --git a/JS/edgechains/examples/qdrant-vector-search/readme.md b/JS/edgechains/examples/qdrant-vector-search/readme.md new file mode 100644 index 000000000..77757add9 --- /dev/null +++ b/JS/edgechains/examples/qdrant-vector-search/readme.md @@ -0,0 +1,27 @@ +# Qdrant Vector Search + +This example demonstrates using EdgeChains' Qdrant vector database client. + +The client talks to Qdrant through the REST API directly. It does not require a +Qdrant npm package. + +## Run + +Start Qdrant locally: + +```sh +docker run -p 6333:6333 qdrant/qdrant +``` + +Then run: + +```sh +export QDRANT_URL=http://localhost:6333 +export QDRANT_API_KEY= +export QDRANT_COLLECTION=edgechains_qdrant_example +bun install +bun run start +``` + +The example creates a collection, upserts two points, and searches for the +closest matches with a payload filter. diff --git a/JS/edgechains/examples/qdrant-vector-search/src/index.ts b/JS/edgechains/examples/qdrant-vector-search/src/index.ts new file mode 100644 index 000000000..7239b554e --- /dev/null +++ b/JS/edgechains/examples/qdrant-vector-search/src/index.ts @@ -0,0 +1,50 @@ +import { Qdrant } from "@arakoodev/edgechains.js/vector-db"; + +const qdrant = new Qdrant(process.env.QDRANT_URL, process.env.QDRANT_API_KEY); + +const collectionName = process.env.QDRANT_COLLECTION || "edgechains_qdrant_example"; + +async function main() { + await qdrant.createCollection({ + collectionName, + vectorSize: 3, + }); + + await qdrant.insertVectorData({ + collectionName, + points: [ + { + id: 1, + vector: [0.1, 0.2, 0.3], + payload: { + content: "EdgeChains can query Qdrant using the REST API.", + source: "docs", + }, + }, + { + id: 2, + vector: [0.2, 0.1, 0.4], + payload: { + content: "Qdrant stores vectors and metadata payloads.", + source: "docs", + }, + }, + ], + }); + + const results = await qdrant.getDataFromQuery({ + collectionName, + vector: [0.1, 0.2, 0.3], + limit: 2, + filter: { + must: [{ key: "source", match: { value: "docs" } }], + }, + }); + + console.log(JSON.stringify(results, null, 2)); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +});