diff --git a/JS/edgechains/arakoodev/README.md b/JS/edgechains/arakoodev/README.md index 81237e662..3225ac0fb 100644 --- a/JS/edgechains/arakoodev/README.md +++ b/JS/edgechains/arakoodev/README.md @@ -3,3 +3,38 @@ Installation ``` npm install arakoodev ``` + +## Qdrant vector database + +`@arakoodev/edgechains.js/vector-db` includes a small Qdrant REST wrapper. It does not use the Qdrant client package. + +```ts +import { Qdrant, QdrantDistanceMetric } 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: 1536, + distance: QdrantDistanceMetric.COSINE, +}); + +await qdrant.insertVectorData({ + collectionName: "documents", + id: "doc-1", + embedding: [0.1, 0.2, 0.3], + payload: { + raw_text: "Document text", + namespace: "docs", + }, +}); + +const results = await qdrant.searchPoints({ + collectionName: "documents", + vector: [0.1, 0.2, 0.3], + limit: 5, + filter: { + must: [{ key: "namespace", match: { value: "docs" } }], + }, +}); +``` diff --git a/JS/edgechains/arakoodev/src/vector-db/src/index.ts b/JS/edgechains/arakoodev/src/vector-db/src/index.ts index 557104a14..552e0c684 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 * 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..c1092f3d2 --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/lib/qdrant/qdrant.ts @@ -0,0 +1,285 @@ +export type QdrantPointId = number | string; + +export enum QdrantDistanceMetric { + COSINE = "Cosine", + DOT = "Dot", + EUCLID = "Euclid", + MANHATTAN = "Manhattan", +} + +export interface QdrantPoint { + id: QdrantPointId; + vector: number[] | Record; + payload?: Record; +} + +export interface CreateCollectionArgs { + collectionName: string; + vectorSize?: number; + distance?: QdrantDistanceMetric; + vectors?: Record; + [key: string]: unknown; +} + +export interface UpsertPointsArgs { + collectionName: string; + points: QdrantPoint[]; + wait?: boolean; +} + +export interface InsertVectorDataArgs { + collectionName: string; + id: QdrantPointId; + vector?: number[] | Record; + embedding?: number[]; + payload?: Record; + wait?: boolean; +} + +export interface SearchPointsArgs { + collectionName: string; + vector: number[] | Record; + limit: number; + filter?: Record; + params?: Record; + offset?: number; + scoreThreshold?: number; + withPayload?: boolean | string[] | Record; + withVector?: boolean | string[]; +} + +export interface QueryPointsArgs { + collectionName: string; + query: number[] | Record; + limit?: number; + prefetch?: unknown; + filter?: Record; + params?: Record; + offset?: number; + scoreThreshold?: number; + withPayload?: boolean | string[] | Record; + withVector?: boolean | string[]; +} + +export interface GetPointByIdArgs { + collectionName: string; + id: QdrantPointId; + withPayload?: boolean | string[] | Record; + withVector?: boolean | string[]; +} + +export interface DeleteByIdArgs { + collectionName: string; + id: QdrantPointId; + wait?: boolean; +} + +type RequestOptions = Omit & { + body?: Record; +}; + +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 || "http://localhost:6333").replace( + /\/$/, + "" + ); + this.QDRANT_API_KEY = QDRANT_API_KEY || process.env.QDRANT_API_KEY; + } + + private getHeaders() { + const headers: Record = { + "Content-Type": "application/json", + }; + + if (this.QDRANT_API_KEY) { + headers["api-key"] = this.QDRANT_API_KEY; + } + + return headers; + } + + private async request(path: string, options: RequestOptions = {}): Promise { + const response = await fetch(`${this.QDRANT_URL}${path}`, { + ...options, + headers: { + ...this.getHeaders(), + ...(options.headers as Record | undefined), + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + const responseText = await response.text(); + const responseBody = responseText ? JSON.parse(responseText) : undefined; + + if (!response.ok || responseBody?.status === "error") { + const message = + responseBody?.status?.error || responseBody?.message || response.statusText; + throw new Error(`Qdrant request failed: ${message}`); + } + + return responseBody as T; + } + + async createCollection({ + collectionName, + vectorSize, + distance = QdrantDistanceMetric.COSINE, + vectors, + ...args + }: CreateCollectionArgs): Promise { + if (!vectors && !vectorSize) { + throw new Error("Qdrant createCollection requires either vectorSize or vectors"); + } + + return this.request(`/collections/${encodeURIComponent(collectionName)}`, { + method: "PUT", + body: { + ...args, + vectors: vectors ?? { + size: vectorSize, + distance, + }, + }, + }); + } + + async getCollection({ collectionName }: { collectionName: string }): Promise { + return this.request(`/collections/${encodeURIComponent(collectionName)}`, { + method: "GET", + }); + } + + async deleteCollection({ collectionName }: { collectionName: string }): Promise { + return this.request(`/collections/${encodeURIComponent(collectionName)}`, { + method: "DELETE", + }); + } + + async upsertPoints({ collectionName, points, wait = true }: UpsertPointsArgs): Promise { + return this.request( + `/collections/${encodeURIComponent(collectionName)}/points?wait=${String(wait)}`, + { + method: "PUT", + body: { points }, + } + ); + } + + async insertVectorData({ + collectionName, + id, + vector, + embedding, + payload, + wait = true, + }: InsertVectorDataArgs): Promise { + const qdrantVector = vector ?? embedding; + + if (!qdrantVector) { + throw new Error("Qdrant insertVectorData requires vector or embedding"); + } + + return this.upsertPoints({ + collectionName, + wait, + points: [ + { + id, + vector: qdrantVector, + payload, + }, + ], + }); + } + + async searchPoints({ + collectionName, + vector, + limit, + filter, + params, + offset, + scoreThreshold, + withPayload = true, + withVector = false, + }: SearchPointsArgs): Promise { + return this.request(`/collections/${encodeURIComponent(collectionName)}/points/search`, { + method: "POST", + body: { + vector, + limit, + filter, + params, + offset, + score_threshold: scoreThreshold, + with_payload: withPayload, + with_vector: withVector, + }, + }); + } + + async queryPoints({ + collectionName, + query, + limit, + prefetch, + filter, + params, + offset, + scoreThreshold, + withPayload = true, + withVector = false, + }: QueryPointsArgs): Promise { + return this.request(`/collections/${encodeURIComponent(collectionName)}/points/query`, { + method: "POST", + body: { + query, + limit, + prefetch, + filter, + params, + offset, + score_threshold: scoreThreshold, + with_payload: withPayload, + with_vector: withVector, + }, + }); + } + + async getPointById({ + collectionName, + id, + withPayload = true, + withVector = false, + }: GetPointByIdArgs): Promise { + const searchParams = new URLSearchParams({ + with_payload: JSON.stringify(withPayload), + with_vector: JSON.stringify(withVector), + }); + + return this.request( + `/collections/${encodeURIComponent(collectionName)}/points/${encodeURIComponent( + id + )}?${searchParams.toString()}`, + { + method: "GET", + } + ); + } + + async deleteById({ collectionName, id, wait = true }: DeleteByIdArgs): Promise { + return this.request( + `/collections/${encodeURIComponent(collectionName)}/points/delete?wait=${String(wait)}`, + { + method: "POST", + body: { + points: [id], + }, + } + ); + } +} 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..a297e6868 --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/tests/qdrant/qdrant.test.ts @@ -0,0 +1,110 @@ +import { Qdrant, QdrantDistanceMetric } from "../../../../../dist/vector-db/src/index.js"; + +describe("Qdrant", () => { + const fetchMock = jest.fn(); + + beforeEach(() => { + fetchMock.mockResolvedValue({ + ok: true, + text: async () => JSON.stringify({ status: "ok", result: true }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("creates collections with REST API vector settings", async () => { + const qdrant = new Qdrant("http://localhost:6333", "test-api-key"); + + await qdrant.createCollection({ + collectionName: "documents", + vectorSize: 1536, + distance: QdrantDistanceMetric.COSINE, + }); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:6333/collections/documents", + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ + "api-key": "test-api-key", + "Content-Type": "application/json", + }), + body: JSON.stringify({ + vectors: { + size: 1536, + distance: "Cosine", + }, + }), + }) + ); + }); + + it("upserts a vector with payload data", async () => { + const qdrant = new Qdrant("http://localhost:6333", "test-api-key"); + + await qdrant.insertVectorData({ + collectionName: "documents", + id: "doc-1", + embedding: [0.1, 0.2, 0.3], + payload: { raw_text: "hello" }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:6333/collections/documents/points?wait=true", + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ + points: [ + { + id: "doc-1", + vector: [0.1, 0.2, 0.3], + payload: { raw_text: "hello" }, + }, + ], + }), + }) + ); + }); + + it("searches points by vector and filter", async () => { + const qdrant = new Qdrant("http://localhost:6333", "test-api-key"); + + await qdrant.searchPoints({ + collectionName: "documents", + vector: [0.1, 0.2, 0.3], + limit: 5, + filter: { + must: [ + { + key: "namespace", + 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: 5, + filter: { + must: [ + { + key: "namespace", + match: { value: "docs" }, + }, + ], + }, + with_payload: true, + with_vector: false, + }), + }) + ); + }); +});