diff --git a/JS/edgechains/arakoodev/src/vector-db/src/index.ts b/JS/edgechains/arakoodev/src/vector-db/src/index.ts index 557104a14..e68d60f2d 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 } 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..3200b0389 --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/lib/qdrant/qdrant.ts @@ -0,0 +1,335 @@ +type QdrantPointId = string | number; + +type QdrantDistance = "Cosine" | "Dot" | "Euclid" | "Manhattan"; + +type QdrantPayload = Record; + +interface QdrantConstructionOptions { + url?: string; + apiKey?: string; + fetcher?: typeof fetch; +} + +interface QdrantRequestOptions { + method?: string; + body?: unknown; +} + +interface CreateCollectionArgs { + collectionName: string; + vectorSize: number; + distance?: QdrantDistance; +} + +interface InsertVectorDataArgs { + collectionName?: string; + tableName?: string; + id?: QdrantPointId; + vector?: number[]; + payload?: QdrantPayload; + points?: Array<{ + id: QdrantPointId; + vector: number[]; + payload?: QdrantPayload; + }>; + wait?: boolean; +} + +interface SearchArgs { + collectionName?: string; + tableName?: string; + vector: number[]; + limit?: number; + filter?: QdrantPayload; + withPayload?: boolean; + withVector?: boolean; +} + +interface ScrollArgs { + collectionName?: string; + tableName?: string; + limit?: number; + offset?: QdrantPointId; + filter?: QdrantPayload; + withPayload?: boolean; + withVector?: boolean; +} + +interface PointLookupArgs { + collectionName?: string; + tableName?: string; + id: QdrantPointId; + withPayload?: boolean; + withVector?: boolean; +} + +interface UpdateByIdArgs { + collectionName?: string; + tableName?: string; + id: QdrantPointId; + updatedContent?: QdrantPayload; + payload?: QdrantPayload; + wait?: boolean; +} + +interface DeleteByIdArgs { + collectionName?: string; + tableName?: string; + id?: QdrantPointId; + ids?: QdrantPointId[]; + wait?: boolean; +} + +export class Qdrant { + QDRANT_URL: string; + QDRANT_API_KEY: string; + private fetcher: typeof fetch; + + constructor( + QDRANT_URL?: string, + QDRANT_API_KEY?: string, + options: QdrantConstructionOptions = {}, + ) { + this.QDRANT_URL = QDRANT_URL || options.url || process.env.QDRANT_URL || ""; + this.QDRANT_API_KEY = + QDRANT_API_KEY || options.apiKey || process.env.QDRANT_API_KEY || ""; + this.fetcher = options.fetcher || fetch; + + if (!this.QDRANT_URL) { + throw new Error( + "Qdrant URL is missing. Pass QDRANT_URL or set the QDRANT_URL env var.", + ); + } + } + + createClient(): Qdrant { + return this; + } + + async createCollection({ + collectionName, + vectorSize, + distance = "Cosine", + }: CreateCollectionArgs): Promise { + return this.request(`/collections/${encodeURIComponent(collectionName)}`, { + method: "PUT", + body: { + vectors: { + size: vectorSize, + distance, + }, + }, + }); + } + + async insertVectorData({ + collectionName, + tableName, + id, + vector, + payload, + points, + wait = true, + }: InsertVectorDataArgs): Promise { + const collection = this.resolveCollection(collectionName, tableName); + const qdrantPoints = + points || + (id !== undefined && vector + ? [ + { + id, + vector, + payload, + }, + ] + : undefined); + + if (!qdrantPoints?.length) { + throw new Error( + "insertVectorData requires either points or an id/vector pair.", + ); + } + + return this.request(`/collections/${collection}/points?wait=${wait}`, { + method: "PUT", + body: { + points: qdrantPoints, + }, + }); + } + + async getDataFromQuery({ + collectionName, + tableName, + vector, + limit = 10, + filter, + withPayload = true, + withVector = false, + }: SearchArgs): Promise { + const collection = this.resolveCollection(collectionName, tableName); + + return this.request(`/collections/${collection}/points/search`, { + method: "POST", + body: { + vector, + limit, + filter, + with_payload: withPayload, + with_vector: withVector, + }, + }); + } + + async getData({ + collectionName, + tableName, + limit = 100, + offset, + filter, + withPayload = true, + withVector = false, + }: ScrollArgs): Promise { + const collection = this.resolveCollection(collectionName, tableName); + + return this.request(`/collections/${collection}/points/scroll`, { + method: "POST", + body: { + limit, + offset, + filter, + with_payload: withPayload, + with_vector: withVector, + }, + }); + } + + async getDataById({ + collectionName, + tableName, + id, + withPayload = true, + withVector = false, + }: PointLookupArgs): Promise { + const collection = this.resolveCollection(collectionName, tableName); + + return this.request(`/collections/${collection}/points`, { + method: "POST", + body: { + ids: [id], + with_payload: withPayload, + with_vector: withVector, + }, + }); + } + + async updateById({ + collectionName, + tableName, + id, + updatedContent, + payload, + wait = true, + }: UpdateByIdArgs): Promise { + const collection = this.resolveCollection(collectionName, tableName); + + return this.request( + `/collections/${collection}/points/payload?wait=${wait}`, + { + method: "POST", + body: { + points: [id], + payload: payload || updatedContent || {}, + }, + }, + ); + } + + async deleteById({ + collectionName, + tableName, + id, + ids, + wait = true, + }: DeleteByIdArgs): Promise { + const collection = this.resolveCollection(collectionName, tableName); + const pointIds = ids || (id !== undefined ? [id] : undefined); + + if (!pointIds?.length) { + throw new Error("deleteById requires id or ids."); + } + + return this.request( + `/collections/${collection}/points/delete?wait=${wait}`, + { + method: "POST", + body: { + points: pointIds, + }, + }, + ); + } + + private async request( + path: string, + options: QdrantRequestOptions = {}, + ): Promise { + const response = await this.fetcher(`${this.baseUrl()}${path}`, { + method: options.method || "GET", + headers: this.headers(), + body: + options.body === undefined + ? undefined + : JSON.stringify(this.removeUndefined(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}: ${JSON.stringify(data)}`, + ); + } + + return data; + } + + private headers(): HeadersInit { + return { + "Content-Type": "application/json", + ...(this.QDRANT_API_KEY ? { "api-key": this.QDRANT_API_KEY } : {}), + }; + } + + private baseUrl(): string { + return this.QDRANT_URL.replace(/\/+$/, ""); + } + + private resolveCollection( + collectionName?: string, + tableName?: string, + ): string { + const collection = collectionName || tableName; + if (!collection) { + throw new Error("A Qdrant collectionName or tableName is required."); + } + return encodeURIComponent(collection); + } + + private removeUndefined(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => this.removeUndefined(item)); + } + + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .filter(([, item]) => item !== undefined) + .map(([key, item]) => [key, this.removeUndefined(item)]), + ); + } + + return value; + } +} 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..0dcc6573f --- /dev/null +++ b/JS/edgechains/arakoodev/src/vector-db/src/tests/qdrant/qdrant.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Qdrant } from "../../lib/qdrant/qdrant.js"; + +const makeResponse = (body: unknown, ok = true, status = 200) => + ({ + ok, + status, + text: async () => JSON.stringify(body), + }) as Response; + +describe("Qdrant vector database client", () => { + let fetcher: ReturnType; + + beforeEach(() => { + fetcher = vi.fn(async () => makeResponse({ result: "ok" })); + }); + + it("creates collections through the REST API without a Qdrant package", async () => { + const qdrant = new Qdrant("https://qdrant.local/", "secret", { + fetcher: fetcher as unknown as typeof fetch, + }); + + await qdrant.createCollection({ + collectionName: "documents", + vectorSize: 1536, + distance: "Dot", + }); + + expect(fetcher).toHaveBeenCalledWith( + "https://qdrant.local/collections/documents", + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ + "Content-Type": "application/json", + "api-key": "secret", + }), + body: JSON.stringify({ + vectors: { + size: 1536, + distance: "Dot", + }, + }), + }), + ); + }); + + it("upserts one vector using the existing tableName-style argument", async () => { + const qdrant = new Qdrant("https://qdrant.local", undefined, { + fetcher: fetcher as unknown as typeof fetch, + }); + + await qdrant.insertVectorData({ + tableName: "chunks", + id: "chunk-1", + vector: [0.1, 0.2], + payload: { + text: "hello", + }, + }); + + expect(fetcher).toHaveBeenCalledWith( + "https://qdrant.local/collections/chunks/points?wait=true", + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ + points: [ + { + id: "chunk-1", + vector: [0.1, 0.2], + payload: { + text: "hello", + }, + }, + ], + }), + }), + ); + }); + + it("searches with Qdrant filter and payload options", async () => { + const qdrant = new Qdrant("https://qdrant.local", "", { + fetcher: fetcher as unknown as typeof fetch, + }); + + await qdrant.getDataFromQuery({ + collectionName: "chunks", + vector: [0.3, 0.4], + limit: 3, + filter: { + must: [{ key: "source", match: { value: "docs" } }], + }, + withVector: true, + }); + + expect(fetcher).toHaveBeenCalledWith( + "https://qdrant.local/collections/chunks/points/search", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + vector: [0.3, 0.4], + limit: 3, + filter: { + must: [{ key: "source", match: { value: "docs" } }], + }, + with_payload: true, + with_vector: true, + }), + }), + ); + }); + + it("retrieves and deletes points by id", async () => { + const qdrant = new Qdrant("https://qdrant.local", "", { + fetcher: fetcher as unknown as typeof fetch, + }); + + await qdrant.getDataById({ + tableName: "chunks", + id: 42, + withPayload: false, + }); + await qdrant.deleteById({ + tableName: "chunks", + id: 42, + }); + + expect(fetcher).toHaveBeenNthCalledWith( + 1, + "https://qdrant.local/collections/chunks/points", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + ids: [42], + with_payload: false, + with_vector: false, + }), + }), + ); + expect(fetcher).toHaveBeenNthCalledWith( + 2, + "https://qdrant.local/collections/chunks/points/delete?wait=true", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + points: [42], + }), + }), + ); + }); + + it("surfaces Qdrant API errors with response context", async () => { + fetcher = vi.fn(async () => + makeResponse({ status: { error: "bad vector" } }, false, 400), + ); + const qdrant = new Qdrant("https://qdrant.local", "", { + fetcher: fetcher as unknown as typeof fetch, + }); + + await expect( + qdrant.insertVectorData({ + tableName: "chunks", + id: "bad", + vector: [1], + }), + ).rejects.toThrow("Qdrant request failed with 400"); + }); +}); 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..43f831095 --- /dev/null +++ b/JS/edgechains/examples/qdrant-vector-search/README.md @@ -0,0 +1,34 @@ +# Qdrant vector search + +This example shows the Qdrant vector database client exposed by `@arakoodev/edgechains.js/vector-db`. + +```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, + distance: "Cosine", +}); + +await qdrant.insertVectorData({ + collectionName: "documents", + id: "doc-1", + vector: [0.1, 0.2, 0.3], + payload: { + text: "EdgeChains can use Qdrant through the REST API.", + }, +}); + +const results = await qdrant.getDataFromQuery({ + collectionName: "documents", + vector: [0.1, 0.2, 0.3], + limit: 5, +}); + +console.log(results); +``` + +The implementation uses Qdrant's HTTP API directly and does not require a Qdrant npm package.