ydb-qdrant 2.2.2 → 3.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.
- package/README.md +1 -1
- package/dist/config/env.d.ts +1 -1
- package/dist/config/env.js +15 -3
- package/dist/repositories/pointsRepo.d.ts +3 -3
- package/dist/repositories/pointsRepo.js +32 -23
- package/dist/services/QdrantService.js +11 -3
- package/dist/types.d.ts +1 -2
- package/dist/types.js +1 -1
- package/dist/ydb/helpers.d.ts +1 -1
- package/dist/ydb/helpers.js +2 -40
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -400,7 +400,7 @@ curl -X POST http://localhost:8080/collections/mycol/points/delete \
|
|
|
400
400
|
## Notes
|
|
401
401
|
- One YDB table is created per collection; metadata is tracked in table `qdr__collections`.
|
|
402
402
|
- Each collection table schema: `point_id Utf8` (PK), `embedding String` (binary), `payload JsonDocument`.
|
|
403
|
-
- Vectors are serialized with `Knn::ToBinaryStringFloat
|
|
403
|
+
- Vectors are serialized with `Knn::ToBinaryStringFloat`.
|
|
404
404
|
- Search uses a single-phase top‑k over `embedding` with automatic YDB vector index (`emb_idx`) when available; falls back to table scan if missing.
|
|
405
405
|
- **Vector index auto-build**: After ≥100 points upserted + 5s quiet window, a `vector_kmeans_tree` index (levels=1, clusters=128) is built automatically. Incremental updates (<100 points) skip index rebuild.
|
|
406
406
|
- **Concurrency**: During index rebuilds, YDB may return transient `Aborted`/schema metadata errors. Upserts include bounded retries with backoff to handle this automatically.
|
package/dist/config/env.d.ts
CHANGED
|
@@ -3,4 +3,4 @@ export declare const YDB_ENDPOINT: string;
|
|
|
3
3
|
export declare const YDB_DATABASE: string;
|
|
4
4
|
export declare const PORT: number;
|
|
5
5
|
export declare const LOG_LEVEL: string;
|
|
6
|
-
export declare const
|
|
6
|
+
export declare const VECTOR_INDEX_BUILD_ENABLED: boolean;
|
package/dist/config/env.js
CHANGED
|
@@ -3,6 +3,18 @@ export const YDB_ENDPOINT = process.env.YDB_ENDPOINT ?? "";
|
|
|
3
3
|
export const YDB_DATABASE = process.env.YDB_DATABASE ?? "";
|
|
4
4
|
export const PORT = process.env.PORT ? Number(process.env.PORT) : 8080;
|
|
5
5
|
export const LOG_LEVEL = process.env.LOG_LEVEL ?? "info";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
function parseBooleanEnv(value, defaultValue) {
|
|
7
|
+
if (value === undefined) {
|
|
8
|
+
return defaultValue;
|
|
9
|
+
}
|
|
10
|
+
const normalized = value.trim().toLowerCase();
|
|
11
|
+
if (normalized === "" ||
|
|
12
|
+
normalized === "0" ||
|
|
13
|
+
normalized === "false" ||
|
|
14
|
+
normalized === "no" ||
|
|
15
|
+
normalized === "off") {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
export const VECTOR_INDEX_BUILD_ENABLED = parseBooleanEnv(process.env.VECTOR_INDEX_BUILD_ENABLED, false);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { DistanceKind } from "../types";
|
|
2
2
|
export declare function upsertPoints(tableName: string, points: Array<{
|
|
3
3
|
id: string | number;
|
|
4
4
|
vector: number[];
|
|
5
5
|
payload?: Record<string, unknown>;
|
|
6
|
-
}>,
|
|
7
|
-
export declare function searchPoints(tableName: string, queryVector: number[], top: number, withPayload: boolean | undefined, distance: DistanceKind,
|
|
6
|
+
}>, dimension: number): Promise<number>;
|
|
7
|
+
export declare function searchPoints(tableName: string, queryVector: number[], top: number, withPayload: boolean | undefined, distance: DistanceKind, dimension: number): Promise<Array<{
|
|
8
8
|
id: string;
|
|
9
9
|
score: number;
|
|
10
10
|
payload?: Record<string, unknown>;
|
|
@@ -2,7 +2,8 @@ import { TypedValues, withSession } from "../ydb/client.js";
|
|
|
2
2
|
import { buildJsonOrEmpty, buildVectorParam } from "../ydb/helpers.js";
|
|
3
3
|
import { logger } from "../logging/logger.js";
|
|
4
4
|
import { notifyUpsert } from "../indexing/IndexScheduler.js";
|
|
5
|
-
|
|
5
|
+
import { VECTOR_INDEX_BUILD_ENABLED } from "../config/env.js";
|
|
6
|
+
export async function upsertPoints(tableName, points, dimension) {
|
|
6
7
|
let upserted = 0;
|
|
7
8
|
await withSession(async (s) => {
|
|
8
9
|
for (const p of points) {
|
|
@@ -12,18 +13,18 @@ export async function upsertPoints(tableName, points, vectorType, dimension) {
|
|
|
12
13
|
}
|
|
13
14
|
const ddl = `
|
|
14
15
|
DECLARE $id AS Utf8;
|
|
15
|
-
DECLARE $vec AS List
|
|
16
|
+
DECLARE $vec AS List<Float>;
|
|
16
17
|
DECLARE $payload AS JsonDocument;
|
|
17
18
|
UPSERT INTO ${tableName} (point_id, embedding, payload)
|
|
18
19
|
VALUES (
|
|
19
20
|
$id,
|
|
20
|
-
Untag(Knn::
|
|
21
|
+
Untag(Knn::ToBinaryStringFloat($vec), "FloatVector"),
|
|
21
22
|
$payload
|
|
22
23
|
);
|
|
23
24
|
`;
|
|
24
25
|
const params = {
|
|
25
26
|
$id: TypedValues.utf8(id),
|
|
26
|
-
$vec: buildVectorParam(p.vector
|
|
27
|
+
$vec: buildVectorParam(p.vector),
|
|
27
28
|
$payload: buildJsonOrEmpty(p.payload),
|
|
28
29
|
};
|
|
29
30
|
// Retry on transient schema/metadata mismatches during index rebuild
|
|
@@ -55,47 +56,55 @@ export async function upsertPoints(tableName, points, vectorType, dimension) {
|
|
|
55
56
|
return upserted;
|
|
56
57
|
}
|
|
57
58
|
// Removed legacy index backfill helper
|
|
58
|
-
export async function searchPoints(tableName, queryVector, top, withPayload, distance,
|
|
59
|
+
export async function searchPoints(tableName, queryVector, top, withPayload, distance, dimension) {
|
|
59
60
|
if (queryVector.length !== dimension) {
|
|
60
61
|
throw new Error(`Vector dimension mismatch: got ${queryVector.length}, expected ${dimension}`);
|
|
61
62
|
}
|
|
62
63
|
const { fn, order } = mapDistanceToKnnFn(distance);
|
|
63
64
|
// Single-phase search over embedding using vector index if present
|
|
64
|
-
const qf = buildVectorParam(queryVector
|
|
65
|
+
const qf = buildVectorParam(queryVector);
|
|
65
66
|
const params = {
|
|
66
67
|
$qf: qf,
|
|
67
68
|
$k2: TypedValues.uint32(top),
|
|
68
69
|
};
|
|
69
70
|
const buildQuery = (useIndex) => `
|
|
70
|
-
DECLARE $qf AS List
|
|
71
|
+
DECLARE $qf AS List<Float>;
|
|
71
72
|
DECLARE $k2 AS Uint32;
|
|
72
|
-
$qbinf = Knn::
|
|
73
|
+
$qbinf = Knn::ToBinaryStringFloat($qf);
|
|
73
74
|
SELECT point_id, ${withPayload ? "payload, " : ""}${fn}(embedding, $qbinf) AS score
|
|
74
75
|
FROM ${tableName}${useIndex ? " VIEW emb_idx" : ""}
|
|
75
76
|
ORDER BY score ${order}
|
|
76
77
|
LIMIT $k2;
|
|
77
78
|
`;
|
|
78
79
|
let rs;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return await s.executeQuery(buildQuery(true), params);
|
|
83
|
-
});
|
|
84
|
-
logger.info({ tableName }, "vector index found; using index for search");
|
|
85
|
-
}
|
|
86
|
-
catch (e) {
|
|
87
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
88
|
-
// Fallback to table scan if index not found or not ready
|
|
89
|
-
if (/not found|does not exist|no such index|no global index|is not ready to use/i.test(msg)) {
|
|
90
|
-
logger.info({ tableName }, "vector index not available (missing or building); falling back to table scan");
|
|
80
|
+
if (VECTOR_INDEX_BUILD_ENABLED) {
|
|
81
|
+
try {
|
|
82
|
+
// Try with vector index first
|
|
91
83
|
rs = await withSession(async (s) => {
|
|
92
|
-
return await s.executeQuery(buildQuery(
|
|
84
|
+
return await s.executeQuery(buildQuery(true), params);
|
|
93
85
|
});
|
|
86
|
+
logger.info({ tableName }, "vector index found; using index for search");
|
|
94
87
|
}
|
|
95
|
-
|
|
96
|
-
|
|
88
|
+
catch (e) {
|
|
89
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
90
|
+
const indexUnavailable = /not found|does not exist|no such index|no global index|is not ready to use/i.test(msg);
|
|
91
|
+
if (indexUnavailable) {
|
|
92
|
+
logger.info({ tableName }, "vector index not available (missing or building); falling back to table scan");
|
|
93
|
+
rs = await withSession(async (s) => {
|
|
94
|
+
return await s.executeQuery(buildQuery(false), params);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
throw e;
|
|
99
|
+
}
|
|
97
100
|
}
|
|
98
101
|
}
|
|
102
|
+
else {
|
|
103
|
+
// Vector index usage disabled: always use table scan
|
|
104
|
+
rs = await withSession(async (s) => {
|
|
105
|
+
return await s.executeQuery(buildQuery(false), params);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
99
108
|
const rowset = rs.resultSets?.[0];
|
|
100
109
|
const rows = (rowset?.rows ?? []);
|
|
101
110
|
return rows.map((row) => {
|
|
@@ -5,6 +5,7 @@ import { createCollection as repoCreateCollection, deleteCollection as repoDelet
|
|
|
5
5
|
import { deletePoints as repoDeletePoints, searchPoints as repoSearchPoints, upsertPoints as repoUpsertPoints, } from "../repositories/pointsRepo.js";
|
|
6
6
|
import { requestIndexBuild } from "../indexing/IndexScheduler.js";
|
|
7
7
|
import { logger } from "../logging/logger.js";
|
|
8
|
+
import { VECTOR_INDEX_BUILD_ENABLED } from "../config/env.js";
|
|
8
9
|
export class QdrantServiceError extends Error {
|
|
9
10
|
statusCode;
|
|
10
11
|
payload;
|
|
@@ -87,6 +88,7 @@ export async function deleteCollection(ctx) {
|
|
|
87
88
|
await repoDeleteCollection(normalized.metaKey);
|
|
88
89
|
return { acknowledged: true };
|
|
89
90
|
}
|
|
91
|
+
let loggedIndexBuildDisabled = false;
|
|
90
92
|
function isNumberArray(value) {
|
|
91
93
|
return Array.isArray(value) && value.every((x) => typeof x === "number");
|
|
92
94
|
}
|
|
@@ -212,8 +214,14 @@ export async function upsertPoints(ctx, body) {
|
|
|
212
214
|
error: parsed.error.flatten(),
|
|
213
215
|
});
|
|
214
216
|
}
|
|
215
|
-
const upserted = await repoUpsertPoints(meta.table, parsed.data.points, meta.
|
|
216
|
-
|
|
217
|
+
const upserted = await repoUpsertPoints(meta.table, parsed.data.points, meta.dimension);
|
|
218
|
+
if (VECTOR_INDEX_BUILD_ENABLED) {
|
|
219
|
+
requestIndexBuild(meta.table, meta.dimension, meta.distance, meta.vectorType);
|
|
220
|
+
}
|
|
221
|
+
else if (!loggedIndexBuildDisabled) {
|
|
222
|
+
logger.info({ table: meta.table }, "vector index building disabled by env; skipping automatic emb_idx rebuilds");
|
|
223
|
+
loggedIndexBuildDisabled = true;
|
|
224
|
+
}
|
|
217
225
|
return { upserted };
|
|
218
226
|
}
|
|
219
227
|
async function executeSearch(ctx, normalizedSearch, source) {
|
|
@@ -257,7 +265,7 @@ async function executeSearch(ctx, normalizedSearch, source) {
|
|
|
257
265
|
distance: meta.distance,
|
|
258
266
|
vectorType: meta.vectorType,
|
|
259
267
|
}, `${source}: executing`);
|
|
260
|
-
const hits = await repoSearchPoints(meta.table, parsed.data.vector, parsed.data.top, parsed.data.with_payload, meta.distance, meta.
|
|
268
|
+
const hits = await repoSearchPoints(meta.table, parsed.data.vector, parsed.data.top, parsed.data.with_payload, meta.distance, meta.dimension);
|
|
261
269
|
const threshold = normalizedSearch.scoreThreshold;
|
|
262
270
|
const filtered = threshold === undefined
|
|
263
271
|
? hits
|
package/dist/types.d.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export type DistanceKind = "Cosine" | "Euclid" | "Dot" | "Manhattan";
|
|
3
|
-
export type VectorType = "float"
|
|
3
|
+
export type VectorType = "float";
|
|
4
4
|
export declare const CreateCollectionReq: z.ZodObject<{
|
|
5
5
|
vectors: z.ZodObject<{
|
|
6
6
|
size: z.ZodNumber;
|
|
7
7
|
distance: z.ZodType<DistanceKind>;
|
|
8
8
|
data_type: z.ZodOptional<z.ZodEnum<{
|
|
9
9
|
float: "float";
|
|
10
|
-
uint8: "uint8";
|
|
11
10
|
}>>;
|
|
12
11
|
}, z.core.$strip>;
|
|
13
12
|
}, z.core.$strip>;
|
package/dist/types.js
CHANGED
package/dist/ydb/helpers.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare function buildVectorParam(vector: number[]
|
|
1
|
+
export declare function buildVectorParam(vector: number[]): import("ydb-sdk-proto").Ydb.ITypedValue;
|
|
2
2
|
export declare function buildJsonOrEmpty(payload?: Record<string, unknown>): import("ydb-sdk-proto").Ydb.ITypedValue;
|
package/dist/ydb/helpers.js
CHANGED
|
@@ -1,44 +1,6 @@
|
|
|
1
1
|
import { Types, TypedValues } from "./client.js";
|
|
2
|
-
export function buildVectorParam(vector
|
|
3
|
-
|
|
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);
|
|
2
|
+
export function buildVectorParam(vector) {
|
|
3
|
+
return TypedValues.list(Types.FLOAT, vector);
|
|
42
4
|
}
|
|
43
5
|
export function buildJsonOrEmpty(payload) {
|
|
44
6
|
return TypedValues.jsonDocument(JSON.stringify(payload ?? {}));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ydb-qdrant",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"main": "dist/package/Api.js",
|
|
5
5
|
"types": "dist/package/Api.d.ts",
|
|
6
6
|
"exports": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"scripts": {
|
|
17
17
|
"test": "vitest run --exclude \"test/integration/**\"",
|
|
18
18
|
"test:coverage": "vitest run --coverage --exclude \"test/integration/**\"",
|
|
19
|
-
"test:integration": "vitest run test/integration/YdbRealIntegration.test.ts",
|
|
19
|
+
"test:integration": "VECTOR_INDEX_BUILD_ENABLED=false vitest run test/integration/YdbRealIntegration.index-disabled.test.ts && VECTOR_INDEX_BUILD_ENABLED=true vitest run test/integration/YdbRealIntegration.test.ts",
|
|
20
20
|
"build": "tsc -p tsconfig.json",
|
|
21
21
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
22
22
|
"dev": "tsx watch src/index.ts",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
],
|
|
47
47
|
"author": "",
|
|
48
48
|
"license": "ISC",
|
|
49
|
-
"description": "Qdrant-compatible Node.js/TypeScript API that stores/searches embeddings in YDB using
|
|
49
|
+
"description": "Qdrant-compatible Node.js/TypeScript API that stores/searches embeddings in YDB using single-phase top-k vector search with an automatic vector index and table-scan fallback.",
|
|
50
50
|
"type": "module",
|
|
51
51
|
"publishConfig": {
|
|
52
52
|
"access": "public"
|