From 709e0106af6886718235a64ff9c590165eb28a3b Mon Sep 17 00:00:00 2001 From: Alce Ops Date: Fri, 24 Apr 2026 17:51:12 +0000 Subject: [PATCH] Add Qdrant vector database client --- JS/edgechains/arakoodev/src/db/src/index.ts | 1 + .../db/src/lib/qdrant-client/QdrantClient.ts | 145 ++++++++++++++++++ .../tests/qdrant-client/qdrantClient.test.ts | 73 +++++++++ 3 files changed, 219 insertions(+) create mode 100644 JS/edgechains/arakoodev/src/db/src/lib/qdrant-client/QdrantClient.ts create mode 100644 JS/edgechains/arakoodev/src/db/src/tests/qdrant-client/qdrantClient.test.ts diff --git a/JS/edgechains/arakoodev/src/db/src/index.ts b/JS/edgechains/arakoodev/src/db/src/index.ts index 1fcbf8b51..b6030bc71 100644 --- a/JS/edgechains/arakoodev/src/db/src/index.ts +++ b/JS/edgechains/arakoodev/src/db/src/index.ts @@ -1 +1,2 @@ export { PostgresClient } from "./lib/postgres-client/PostgresClient.js"; +export { QdrantClient, QdrantDistanceMetric } from "./lib/qdrant-client/QdrantClient.js"; diff --git a/JS/edgechains/arakoodev/src/db/src/lib/qdrant-client/QdrantClient.ts b/JS/edgechains/arakoodev/src/db/src/lib/qdrant-client/QdrantClient.ts new file mode 100644 index 000000000..07d6a7749 --- /dev/null +++ b/JS/edgechains/arakoodev/src/db/src/lib/qdrant-client/QdrantClient.ts @@ -0,0 +1,145 @@ +export enum QdrantDistanceMetric { + COSINE = "Cosine", + DOT = "Dot", + EUCLID = "Euclid", + MANHATTAN = "Manhattan", +} + +export type QdrantClientOptions = { + url: string; + apiKey?: string; + collectionName: string; + vectorName?: string; + namespace?: string; + topK?: number; + withPayload?: boolean; + withVector?: boolean; +}; + +export type QdrantPoint = { + id: string | number; + vector: number[] | Record; + payload?: Record; +}; + +type QdrantSearchResult = { + id: string | number; + score: number; + payload?: Record; + vector?: number[] | Record; +}; + +export class QdrantClient { + private readonly url: string; + private readonly apiKey?: string; + private readonly collectionName: string; + private readonly vectorName?: string; + private readonly namespace?: string; + private readonly topK: number; + private readonly withPayload: boolean; + private readonly withVector: boolean; + + constructor(options: QdrantClientOptions) { + if (!options.url) { + throw new Error("Qdrant url is required"); + } + if (!options.collectionName) { + throw new Error("Qdrant collectionName is required"); + } + + this.url = options.url.replace(/\/$/, ""); + this.apiKey = options.apiKey; + this.collectionName = options.collectionName; + this.vectorName = options.vectorName; + this.namespace = options.namespace; + this.topK = options.topK ?? 10; + this.withPayload = options.withPayload ?? true; + this.withVector = options.withVector ?? false; + } + + async upsertPoints(points: QdrantPoint[], wait = true): Promise { + return this.request( + `/collections/${encodeURIComponent(this.collectionName)}/points?wait=${wait}`, + { + method: "PUT", + body: JSON.stringify({ points }), + } + ); + } + + async search(vector: number[], topK = this.topK, filter?: Record): Promise { + const body: Record = { + vector: this.vectorName ? { name: this.vectorName, vector } : vector, + limit: topK, + with_payload: this.withPayload, + with_vector: this.withVector, + }; + + const combinedFilter = this.buildFilter(filter); + if (combinedFilter) { + body.filter = combinedFilter; + } + + const response = await this.request( + `/collections/${encodeURIComponent(this.collectionName)}/points/search`, + { + method: "POST", + body: JSON.stringify(body), + } + ); + + return ((response as { result?: QdrantSearchResult[] }).result ?? []).map((result) => ({ + ...result, + raw_text: result.payload?.raw_text ?? result.payload?.text, + metadata: result.payload?.metadata ?? result.payload, + namespace: result.payload?.namespace, + })) as QdrantSearchResult[]; + } + + async dbQuery(wordEmbeddings: number[][]): Promise { + const searches = await Promise.all( + wordEmbeddings.map((embedding) => this.search(embedding, this.topK)) + ); + + const byId = new Map(); + searches.flat().forEach((result) => { + const previous = byId.get(result.id); + if (!previous || result.score > previous.score) { + byId.set(result.id, result); + } + }); + + return Array.from(byId.values()) + .sort((a, b) => b.score - a.score) + .slice(0, this.topK); + } + + private buildFilter(filter?: Record): Record | undefined { + const must: unknown[] = []; + if (this.namespace) { + must.push({ key: "namespace", match: { value: this.namespace } }); + } + if (filter?.must && Array.isArray(filter.must)) { + must.push(...filter.must); + } + return must.length ? { ...filter, must } : filter; + } + + private async request(path: string, init: RequestInit): Promise { + const response = await fetch(`${this.url}${path}`, { + ...init, + headers: { + "content-type": "application/json", + ...(this.apiKey ? { "api-key": this.apiKey } : {}), + ...(init.headers ?? {}), + }, + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Qdrant request failed (${response.status}): ${body}`); + } + + return response.json(); + } +} diff --git a/JS/edgechains/arakoodev/src/db/src/tests/qdrant-client/qdrantClient.test.ts b/JS/edgechains/arakoodev/src/db/src/tests/qdrant-client/qdrantClient.test.ts new file mode 100644 index 000000000..ab1489874 --- /dev/null +++ b/JS/edgechains/arakoodev/src/db/src/tests/qdrant-client/qdrantClient.test.ts @@ -0,0 +1,73 @@ +import { QdrantClient } from "../../../../../dist/db/src/lib/qdrant-client/QdrantClient.js"; + +describe("QdrantClient", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.clearAllMocks(); + }); + + test("search posts directly to Qdrant REST API without SDK packages", async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + result: [ + { + id: 1, + score: 0.91, + payload: { raw_text: "hello", namespace: "docs" }, + }, + ], + }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + + const client = new QdrantClient({ + url: "http://localhost:6333/", + apiKey: "secret", + collectionName: "documents", + namespace: "docs", + topK: 3, + }); + + const results = await client.search([0.1, 0.2, 0.3]); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:6333/collections/documents/points/search", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ "api-key": "secret" }), + }) + ); + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual( + expect.objectContaining({ + vector: [0.1, 0.2, 0.3], + limit: 3, + filter: { must: [{ key: "namespace", match: { value: "docs" } }] }, + }) + ); + expect(results[0]).toMatchObject({ id: 1, raw_text: "hello", namespace: "docs" }); + }); + + test("dbQuery deduplicates multi-embedding results by best score", async () => { + const fetchMock = jest.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ result: [{ id: "a", score: 0.4 }, { id: "b", score: 0.7 }] }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ result: [{ id: "a", score: 0.9 }] }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + + const client = new QdrantClient({ url: "http://localhost:6333", collectionName: "docs", topK: 2 }); + const results = await client.dbQuery([[1], [2]]); + + expect(results).toEqual([ + expect.objectContaining({ id: "a", score: 0.9 }), + expect.objectContaining({ id: "b", score: 0.7 }), + ]); + }); +});