ydb-qdrant 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,268 @@
1
+ import { sanitizeCollectionName, sanitizeTenantId, metaKeyFor, tableNameFor, } from "../utils/tenant.js";
2
+ import { CreateCollectionReq, DeletePointsReq, SearchReq, UpsertPointsReq, } from "../types.js";
3
+ import { ensureMetaTable } from "../ydb/schema.js";
4
+ import { createCollection as repoCreateCollection, deleteCollection as repoDeleteCollection, getCollectionMeta, } from "../repositories/collectionsRepo.js";
5
+ import { deletePoints as repoDeletePoints, searchPoints as repoSearchPoints, upsertPoints as repoUpsertPoints, } from "../repositories/pointsRepo.js";
6
+ import { requestIndexBuild } from "../indexing/IndexScheduler.js";
7
+ import { logger } from "../logging/logger.js";
8
+ export class QdrantServiceError extends Error {
9
+ statusCode;
10
+ payload;
11
+ constructor(statusCode, payload, message) {
12
+ super(message ?? String(payload.error));
13
+ this.statusCode = statusCode;
14
+ this.payload = payload;
15
+ }
16
+ }
17
+ function normalizeCollectionContext(input) {
18
+ const tenant = sanitizeTenantId(input.tenant);
19
+ const collection = sanitizeCollectionName(input.collection);
20
+ const metaKey = metaKeyFor(tenant, collection);
21
+ return { tenant, collection, metaKey };
22
+ }
23
+ export async function putCollectionIndex(ctx) {
24
+ await ensureMetaTable();
25
+ const normalized = normalizeCollectionContext(ctx);
26
+ const meta = await getCollectionMeta(normalized.metaKey);
27
+ if (!meta) {
28
+ throw new QdrantServiceError(404, {
29
+ status: "error",
30
+ error: "collection not found",
31
+ });
32
+ }
33
+ return { acknowledged: true };
34
+ }
35
+ export async function createCollection(ctx, body) {
36
+ await ensureMetaTable();
37
+ const normalized = normalizeCollectionContext(ctx);
38
+ const parsed = CreateCollectionReq.safeParse(body);
39
+ if (!parsed.success) {
40
+ throw new QdrantServiceError(400, {
41
+ status: "error",
42
+ error: parsed.error.flatten(),
43
+ });
44
+ }
45
+ const dim = parsed.data.vectors.size;
46
+ const distance = parsed.data.vectors.distance;
47
+ const vectorType = parsed.data.vectors.data_type ?? "float";
48
+ const existing = await getCollectionMeta(normalized.metaKey);
49
+ if (existing) {
50
+ if (existing.dimension === dim &&
51
+ existing.distance === distance &&
52
+ existing.vectorType === vectorType) {
53
+ return { name: normalized.collection, tenant: normalized.tenant };
54
+ }
55
+ const errorMessage = `Collection already exists with different config: dimension=${existing.dimension}, distance=${existing.distance}, type=${existing.vectorType}`;
56
+ throw new QdrantServiceError(400, {
57
+ status: "error",
58
+ error: errorMessage,
59
+ });
60
+ }
61
+ const tableName = tableNameFor(normalized.tenant, normalized.collection);
62
+ await repoCreateCollection(normalized.metaKey, dim, distance, vectorType, tableName);
63
+ return { name: normalized.collection, tenant: normalized.tenant };
64
+ }
65
+ export async function getCollection(ctx) {
66
+ await ensureMetaTable();
67
+ const normalized = normalizeCollectionContext(ctx);
68
+ const meta = await getCollectionMeta(normalized.metaKey);
69
+ if (!meta) {
70
+ throw new QdrantServiceError(404, {
71
+ status: "error",
72
+ error: "collection not found",
73
+ });
74
+ }
75
+ return {
76
+ name: normalized.collection,
77
+ vectors: {
78
+ size: meta.dimension,
79
+ distance: meta.distance,
80
+ data_type: meta.vectorType,
81
+ },
82
+ };
83
+ }
84
+ export async function deleteCollection(ctx) {
85
+ await ensureMetaTable();
86
+ const normalized = normalizeCollectionContext(ctx);
87
+ await repoDeleteCollection(normalized.metaKey);
88
+ return { acknowledged: true };
89
+ }
90
+ function isNumberArray(value) {
91
+ return Array.isArray(value) && value.every((x) => typeof x === "number");
92
+ }
93
+ function extractVectorLoose(body, depth = 0) {
94
+ if (!body || typeof body !== "object" || depth > 3) {
95
+ return undefined;
96
+ }
97
+ const obj = body;
98
+ if (isNumberArray(obj.vector))
99
+ return obj.vector;
100
+ if (isNumberArray(obj.embedding))
101
+ return obj.embedding;
102
+ const query = obj.query;
103
+ if (query) {
104
+ const queryVector = query.vector;
105
+ if (isNumberArray(queryVector))
106
+ return queryVector;
107
+ const nearest = query.nearest;
108
+ if (nearest && isNumberArray(nearest.vector)) {
109
+ return nearest.vector;
110
+ }
111
+ }
112
+ const nearest = obj.nearest;
113
+ if (nearest && isNumberArray(nearest.vector)) {
114
+ return nearest.vector;
115
+ }
116
+ for (const key of Object.keys(obj)) {
117
+ const value = obj[key];
118
+ if (isNumberArray(value)) {
119
+ return value;
120
+ }
121
+ }
122
+ for (const key of Object.keys(obj)) {
123
+ const value = obj[key];
124
+ if (value && typeof value === "object") {
125
+ const found = extractVectorLoose(value, depth + 1);
126
+ if (found) {
127
+ return found;
128
+ }
129
+ }
130
+ }
131
+ return undefined;
132
+ }
133
+ function normalizeSearchBodyForSearch(body) {
134
+ const b = body;
135
+ const vector = Array.isArray(b?.vector) ? b.vector : undefined;
136
+ const topFromTop = typeof b?.top === "number" ? b.top : undefined;
137
+ const topFromLimit = typeof b?.limit === "number" ? b.limit : undefined;
138
+ const top = topFromTop ?? topFromLimit;
139
+ let withPayload;
140
+ const rawWithPayload = b?.with_payload;
141
+ if (typeof rawWithPayload === "boolean") {
142
+ withPayload = rawWithPayload;
143
+ }
144
+ else if (Array.isArray(rawWithPayload) || typeof rawWithPayload === "object") {
145
+ withPayload = true;
146
+ }
147
+ const thresholdValue = Number(b?.score_threshold);
148
+ const scoreThreshold = Number.isFinite(thresholdValue) ? thresholdValue : undefined;
149
+ return { vector, top, withPayload, scoreThreshold };
150
+ }
151
+ function normalizeSearchBodyForQuery(body) {
152
+ const b = body;
153
+ const vector = extractVectorLoose(b);
154
+ const topFromTop = typeof b?.top === "number" ? b.top : undefined;
155
+ const topFromLimit = typeof b?.limit === "number" ? b.limit : undefined;
156
+ const top = topFromTop ?? topFromLimit;
157
+ let withPayload;
158
+ const rawWithPayload = b?.with_payload;
159
+ if (typeof rawWithPayload === "boolean") {
160
+ withPayload = rawWithPayload;
161
+ }
162
+ else if (Array.isArray(rawWithPayload) || typeof rawWithPayload === "object") {
163
+ withPayload = true;
164
+ }
165
+ const thresholdValue = Number(b?.score_threshold);
166
+ const scoreThreshold = Number.isFinite(thresholdValue) ? thresholdValue : undefined;
167
+ return { vector, top, withPayload, scoreThreshold };
168
+ }
169
+ export async function upsertPoints(ctx, body) {
170
+ await ensureMetaTable();
171
+ const normalized = normalizeCollectionContext(ctx);
172
+ const meta = await getCollectionMeta(normalized.metaKey);
173
+ if (!meta) {
174
+ throw new QdrantServiceError(404, {
175
+ status: "error",
176
+ error: "collection not found",
177
+ });
178
+ }
179
+ const parsed = UpsertPointsReq.safeParse(body);
180
+ if (!parsed.success) {
181
+ throw new QdrantServiceError(400, {
182
+ status: "error",
183
+ error: parsed.error.flatten(),
184
+ });
185
+ }
186
+ const upserted = await repoUpsertPoints(meta.table, parsed.data.points, meta.vectorType, meta.dimension);
187
+ requestIndexBuild(meta.table, meta.dimension, meta.distance, meta.vectorType);
188
+ return { upserted };
189
+ }
190
+ async function executeSearch(ctx, normalizedSearch, source) {
191
+ await ensureMetaTable();
192
+ const normalized = normalizeCollectionContext(ctx);
193
+ logger.info({ tenant: normalized.tenant, collection: normalized.collection }, `${source}: resolve collection meta`);
194
+ const meta = await getCollectionMeta(normalized.metaKey);
195
+ if (!meta) {
196
+ logger.warn({ tenant: normalized.tenant, collection: normalized.collection, metaKey: normalized.metaKey }, `${source}: collection not found`);
197
+ throw new QdrantServiceError(404, {
198
+ status: "error",
199
+ error: "collection not found",
200
+ });
201
+ }
202
+ const parsed = SearchReq.safeParse({
203
+ vector: normalizedSearch.vector,
204
+ top: normalizedSearch.top,
205
+ with_payload: normalizedSearch.withPayload,
206
+ });
207
+ if (!parsed.success) {
208
+ logger.warn({
209
+ tenant: normalized.tenant,
210
+ collection: normalized.collection,
211
+ issues: parsed.error.issues,
212
+ }, `${source}: invalid payload`);
213
+ throw new QdrantServiceError(400, {
214
+ status: "error",
215
+ error: parsed.error.flatten(),
216
+ });
217
+ }
218
+ logger.info({
219
+ tenant: normalized.tenant,
220
+ collection: normalized.collection,
221
+ top: parsed.data.top,
222
+ queryVectorLen: parsed.data.vector.length,
223
+ collectionDim: meta.dimension,
224
+ distance: meta.distance,
225
+ vectorType: meta.vectorType,
226
+ }, `${source}: executing`);
227
+ const hits = await repoSearchPoints(meta.table, parsed.data.vector, parsed.data.top, parsed.data.with_payload, meta.distance, meta.vectorType, meta.dimension);
228
+ const threshold = normalizedSearch.scoreThreshold;
229
+ const filtered = threshold === undefined
230
+ ? hits
231
+ : hits.filter((hit) => {
232
+ const isSimilarity = meta.distance === "Cosine" || meta.distance === "Dot";
233
+ if (isSimilarity) {
234
+ return hit.score >= threshold;
235
+ }
236
+ return hit.score <= threshold;
237
+ });
238
+ logger.info({ tenant: normalized.tenant, collection: normalized.collection, hits: hits.length }, `${source}: completed`);
239
+ return { points: filtered };
240
+ }
241
+ export async function searchPoints(ctx, body) {
242
+ const normalizedSearch = normalizeSearchBodyForSearch(body);
243
+ return await executeSearch(ctx, normalizedSearch, "search");
244
+ }
245
+ export async function queryPoints(ctx, body) {
246
+ const normalizedSearch = normalizeSearchBodyForQuery(body);
247
+ return await executeSearch(ctx, normalizedSearch, "query");
248
+ }
249
+ export async function deletePoints(ctx, body) {
250
+ await ensureMetaTable();
251
+ const normalized = normalizeCollectionContext(ctx);
252
+ const meta = await getCollectionMeta(normalized.metaKey);
253
+ if (!meta) {
254
+ throw new QdrantServiceError(404, {
255
+ status: "error",
256
+ error: "collection not found",
257
+ });
258
+ }
259
+ const parsed = DeletePointsReq.safeParse(body);
260
+ if (!parsed.success) {
261
+ throw new QdrantServiceError(400, {
262
+ status: "error",
263
+ error: parsed.error.flatten(),
264
+ });
265
+ }
266
+ const deleted = await repoDeletePoints(meta.table, parsed.data.points);
267
+ return { deleted };
268
+ }
@@ -0,0 +1,28 @@
1
+ import { z } from "zod";
2
+ export type DistanceKind = "Cosine" | "Euclid" | "Dot" | "Manhattan";
3
+ export type VectorType = "float" | "uint8";
4
+ export declare const CreateCollectionReq: z.ZodObject<{
5
+ vectors: z.ZodObject<{
6
+ size: z.ZodNumber;
7
+ distance: z.ZodType<DistanceKind>;
8
+ data_type: z.ZodOptional<z.ZodEnum<{
9
+ float: "float";
10
+ uint8: "uint8";
11
+ }>>;
12
+ }, z.core.$strip>;
13
+ }, z.core.$strip>;
14
+ export declare const UpsertPointsReq: z.ZodObject<{
15
+ points: z.ZodArray<z.ZodObject<{
16
+ id: z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>;
17
+ vector: z.ZodArray<z.ZodNumber>;
18
+ payload: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodAny>>;
19
+ }, z.core.$strip>>;
20
+ }, z.core.$strip>;
21
+ export declare const SearchReq: z.ZodObject<{
22
+ vector: z.ZodArray<z.ZodNumber>;
23
+ top: z.ZodNumber;
24
+ with_payload: z.ZodOptional<z.ZodBoolean>;
25
+ }, z.core.$strip>;
26
+ export declare const DeletePointsReq: z.ZodObject<{
27
+ points: z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>;
28
+ }, z.core.$strip>;
package/dist/types.js ADDED
@@ -0,0 +1,30 @@
1
+ import { z } from "zod";
2
+ export const CreateCollectionReq = z.object({
3
+ vectors: z.object({
4
+ size: z.number().int().positive(),
5
+ distance: z.enum([
6
+ "Cosine",
7
+ "Euclid",
8
+ "Dot",
9
+ "Manhattan",
10
+ ]),
11
+ data_type: z.enum(["float", "uint8"]).optional(),
12
+ }),
13
+ });
14
+ export const UpsertPointsReq = z.object({
15
+ points: z
16
+ .array(z.object({
17
+ id: z.union([z.string(), z.number()]),
18
+ vector: z.array(z.number()),
19
+ payload: z.record(z.string(), z.any()).optional(),
20
+ }))
21
+ .min(1),
22
+ });
23
+ export const SearchReq = z.object({
24
+ vector: z.array(z.number()).min(1),
25
+ top: z.number().int().positive().max(1000),
26
+ with_payload: z.boolean().optional(),
27
+ });
28
+ export const DeletePointsReq = z.object({
29
+ points: z.array(z.union([z.string(), z.number()])).min(1),
30
+ });
@@ -0,0 +1,4 @@
1
+ export declare function sanitizeCollectionName(name: string): string;
2
+ export declare function sanitizeTenantId(tenantId: string | undefined): string;
3
+ export declare function tableNameFor(tenantId: string, collection: string): string;
4
+ export declare function metaKeyFor(tenantId: string, collection: string): string;
@@ -0,0 +1,17 @@
1
+ export function sanitizeCollectionName(name) {
2
+ const cleaned = name.replace(/[^a-zA-Z0-9_]/g, "_").replace(/_+/g, "_");
3
+ const lowered = cleaned.toLowerCase().replace(/^_+/, "");
4
+ return lowered.length > 0 ? lowered : "collection";
5
+ }
6
+ export function sanitizeTenantId(tenantId) {
7
+ const raw = (tenantId ?? "default").toString();
8
+ const cleaned = raw.replace(/[^a-zA-Z0-9_]/g, "_").replace(/_+/g, "_");
9
+ const lowered = cleaned.toLowerCase().replace(/^_+/, "");
10
+ return lowered.length > 0 ? lowered : "default";
11
+ }
12
+ export function tableNameFor(tenantId, collection) {
13
+ return `qdr_${sanitizeTenantId(tenantId)}__${sanitizeCollectionName(collection)}`;
14
+ }
15
+ export function metaKeyFor(tenantId, collection) {
16
+ return `${sanitizeTenantId(tenantId)}/${sanitizeCollectionName(collection)}`;
17
+ }
@@ -0,0 +1,6 @@
1
+ import type { Session } from "ydb-sdk";
2
+ declare const Types: any, TypedValues: any, TableDescription: any, Column: any;
3
+ export { Types, TypedValues, TableDescription, Column };
4
+ export declare const driver: any;
5
+ export declare function readyOrThrow(): Promise<void>;
6
+ export declare function withSession<T>(fn: (s: Session) => Promise<T>): Promise<T>;
@@ -0,0 +1,20 @@
1
+ import { createRequire } from "module";
2
+ import { YDB_DATABASE, YDB_ENDPOINT } from "../config/env.js";
3
+ const require = createRequire(import.meta.url);
4
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
5
+ const { Driver, getCredentialsFromEnv, Types, TypedValues, TableDescription, Column, } = require("ydb-sdk");
6
+ export { Types, TypedValues, TableDescription, Column };
7
+ export const driver = new Driver({
8
+ endpoint: YDB_ENDPOINT,
9
+ database: YDB_DATABASE,
10
+ authService: getCredentialsFromEnv(),
11
+ });
12
+ export async function readyOrThrow() {
13
+ const ok = await driver.ready(10000);
14
+ if (!ok) {
15
+ throw new Error("YDB driver is not ready in 10s. Check connectivity and credentials.");
16
+ }
17
+ }
18
+ export async function withSession(fn) {
19
+ return await driver.tableClient.withSession(fn, 15000);
20
+ }
@@ -0,0 +1,2 @@
1
+ export declare function buildVectorParam(vector: number[], vectorType: "float" | "uint8"): any;
2
+ export declare function buildJsonOrEmpty(payload?: Record<string, unknown>): any;
@@ -0,0 +1,45 @@
1
+ import { Types, TypedValues } from "./client.js";
2
+ export function buildVectorParam(vector, vectorType) {
3
+ let list;
4
+ if (vectorType === "uint8") {
5
+ // Check if vector is already quantized (integers in [0,255])
6
+ const isAlreadyQuantized = vector.every(v => Number.isInteger(v) && v >= 0 && v <= 255);
7
+ if (isAlreadyQuantized) {
8
+ list = vector;
9
+ }
10
+ else {
11
+ // Float embeddings need quantization. Per YDB docs (knn.md lines 282-294):
12
+ // Formula: ((x - min) / (max - min)) * 255
13
+ const min = Math.min(...vector);
14
+ const max = Math.max(...vector);
15
+ // Determine quantization strategy based on detected range
16
+ if (min >= 0 && max <= 1.01) {
17
+ // Normalized [0,1] embeddings (common for some models)
18
+ list = vector.map(v => Math.round(Math.max(0, Math.min(1, v)) * 255));
19
+ }
20
+ else if (min >= -1.01 && max <= 1.01) {
21
+ // Normalized [-1,1] embeddings (most common)
22
+ // Map to [0,255]: ((x + 1) / 2) * 255 = (x + 1) * 127.5
23
+ list = vector.map(v => Math.round((Math.max(-1, Math.min(1, v)) + 1) * 127.5));
24
+ }
25
+ else {
26
+ // General case: linear scaling from [min,max] to [0,255]
27
+ const range = max - min;
28
+ if (range > 0) {
29
+ list = vector.map(v => Math.round(((v - min) / range) * 255));
30
+ }
31
+ else {
32
+ // All values identical; map to midpoint
33
+ list = vector.map(() => 127);
34
+ }
35
+ }
36
+ }
37
+ }
38
+ else {
39
+ list = vector;
40
+ }
41
+ return TypedValues.list(vectorType === "uint8" ? Types.UINT8 : Types.FLOAT, list);
42
+ }
43
+ export function buildJsonOrEmpty(payload) {
44
+ return TypedValues.jsonDocument(JSON.stringify(payload ?? {}));
45
+ }
@@ -0,0 +1 @@
1
+ export declare function ensureMetaTable(): Promise<void>;
@@ -0,0 +1,24 @@
1
+ import { withSession, TableDescription, Column, Types } from "./client.js";
2
+ import { logger } from "../logging/logger.js";
3
+ export async function ensureMetaTable() {
4
+ try {
5
+ await withSession(async (s) => {
6
+ // If table exists, describeTable will succeed
7
+ try {
8
+ await s.describeTable("qdr__collections");
9
+ return;
10
+ }
11
+ catch {
12
+ // create via schema API
13
+ const desc = new TableDescription()
14
+ .withColumns(new Column("collection", Types.UTF8), new Column("table_name", Types.UTF8), new Column("vector_dimension", Types.UINT32), new Column("distance", Types.UTF8), new Column("vector_type", Types.UTF8), new Column("created_at", Types.TIMESTAMP))
15
+ .withPrimaryKey("collection");
16
+ await s.createTable("qdr__collections", desc);
17
+ logger.info("created metadata table qdr__collections");
18
+ }
19
+ });
20
+ }
21
+ catch (err) {
22
+ logger.debug({ err }, "ensureMetaTable: ignored");
23
+ }
24
+ }
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "ydb-qdrant",
3
+ "version": "2.0.0",
4
+ "main": "dist/Api.js",
5
+ "types": "dist/Api.d.ts",
6
+ "exports": {
7
+ ".": "./dist/Api.js",
8
+ "./server": "./dist/server.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "test": "vitest run",
17
+ "build": "tsc -p tsconfig.json",
18
+ "dev": "tsx watch src/index.ts",
19
+ "start": "node --experimental-specifier-resolution=node --enable-source-maps dist/index.js",
20
+ "smoke": "npm run build && node --experimental-specifier-resolution=node --enable-source-maps dist/SmokeTest.js",
21
+ "lint": "biome check .",
22
+ "prepublishOnly": "npm test && npm run build"
23
+ },
24
+ "keywords": [
25
+ "ydb",
26
+ "vector-search",
27
+ "approximate-nearest-neighbor",
28
+ "qdrant-compatible",
29
+ "embeddings",
30
+ "nodejs",
31
+ "typescript",
32
+ "express",
33
+ "yql",
34
+ "grpc",
35
+ "yandex-cloud",
36
+ "ann",
37
+ "semantic-search",
38
+ "rag",
39
+ "pino",
40
+ "zod"
41
+ ],
42
+ "author": "",
43
+ "license": "ISC",
44
+ "description": "Qdrant-compatible Node.js/TypeScript API that stores/searches embeddings in YDB using approximate coarse-to-fine vector search (quantized uint8 preselect + float refine).",
45
+ "type": "module",
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "dependencies": {
50
+ "@bufbuild/protobuf": "^2.10.0",
51
+ "@grpc/grpc-js": "^1.14.0",
52
+ "@ydbjs/api": "^6.0.4",
53
+ "@ydbjs/core": "^6.0.4",
54
+ "@ydbjs/query": "^6.0.4",
55
+ "@ydbjs/value": "^6.0.4",
56
+ "dotenv": "^17.2.3",
57
+ "express": "^5.1.0",
58
+ "nice-grpc": "^2.1.13",
59
+ "pino": "^10.1.0",
60
+ "ydb-sdk": "^5.11.1",
61
+ "zod": "^4.1.12"
62
+ },
63
+ "devDependencies": {
64
+ "@biomejs/biome": "^2.2.7",
65
+ "@types/express": "^5.0.3",
66
+ "@types/node": "^24.9.1",
67
+ "docsify-cli": "^4.4.4",
68
+ "node-plantuml-latest": "^2.4.0",
69
+ "tsx": "^4.20.6",
70
+ "typescript": "^5.9.3",
71
+ "vitest": "^4.0.12"
72
+ }
73
+ }