@mhalder/qdrant-mcp-server 1.1.1 → 1.2.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/CHANGELOG.md +5 -0
- package/README.md +2 -0
- package/biome.json +34 -0
- package/build/embeddings/sparse.d.ts +40 -0
- package/build/embeddings/sparse.d.ts.map +1 -0
- package/build/embeddings/sparse.js +105 -0
- package/build/embeddings/sparse.js.map +1 -0
- package/build/embeddings/sparse.test.d.ts +2 -0
- package/build/embeddings/sparse.test.d.ts.map +1 -0
- package/build/embeddings/sparse.test.js +69 -0
- package/build/embeddings/sparse.test.js.map +1 -0
- package/build/index.js +130 -30
- package/build/index.js.map +1 -1
- package/build/qdrant/client.d.ts +21 -2
- package/build/qdrant/client.d.ts.map +1 -1
- package/build/qdrant/client.js +131 -17
- package/build/qdrant/client.js.map +1 -1
- package/build/qdrant/client.test.js +429 -21
- package/build/qdrant/client.test.js.map +1 -1
- package/examples/README.md +16 -1
- package/examples/basic/README.md +1 -0
- package/examples/hybrid-search/README.md +199 -0
- package/package.json +1 -1
- package/src/embeddings/sparse.test.ts +87 -0
- package/src/embeddings/sparse.ts +127 -0
- package/src/index.ts +161 -57
- package/src/qdrant/client.test.ts +544 -56
- package/src/qdrant/client.ts +162 -22
- package/vitest.config.ts +3 -3
package/src/qdrant/client.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { QdrantClient } from "@qdrant/js-client-rest";
|
|
3
3
|
|
|
4
4
|
export interface CollectionInfo {
|
|
5
5
|
name: string;
|
|
6
6
|
vectorSize: number;
|
|
7
7
|
pointsCount: number;
|
|
8
|
-
distance:
|
|
8
|
+
distance: "Cosine" | "Euclid" | "Dot";
|
|
9
|
+
hybridEnabled?: boolean;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
export interface SearchResult {
|
|
@@ -14,10 +15,15 @@ export interface SearchResult {
|
|
|
14
15
|
payload?: Record<string, any>;
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
export interface SparseVector {
|
|
19
|
+
indices: number[];
|
|
20
|
+
values: number[];
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
export class QdrantManager {
|
|
18
24
|
private client: QdrantClient;
|
|
19
25
|
|
|
20
|
-
constructor(url: string =
|
|
26
|
+
constructor(url: string = "http://localhost:6333") {
|
|
21
27
|
this.client = new QdrantClient({ url });
|
|
22
28
|
}
|
|
23
29
|
|
|
@@ -26,7 +32,7 @@ export class QdrantManager {
|
|
|
26
32
|
* Qdrant requires string IDs to be in UUID format.
|
|
27
33
|
*/
|
|
28
34
|
private normalizeId(id: string | number): string | number {
|
|
29
|
-
if (typeof id ===
|
|
35
|
+
if (typeof id === "number") {
|
|
30
36
|
return id;
|
|
31
37
|
}
|
|
32
38
|
|
|
@@ -37,21 +43,40 @@ export class QdrantManager {
|
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
// Convert arbitrary string to deterministic UUID v5-like format
|
|
40
|
-
const hash = createHash(
|
|
46
|
+
const hash = createHash("sha256").update(id).digest("hex");
|
|
41
47
|
return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20, 32)}`;
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
async createCollection(
|
|
45
51
|
name: string,
|
|
46
52
|
vectorSize: number,
|
|
47
|
-
distance:
|
|
53
|
+
distance: "Cosine" | "Euclid" | "Dot" = "Cosine",
|
|
54
|
+
enableSparse: boolean = false
|
|
48
55
|
): Promise<void> {
|
|
49
|
-
|
|
50
|
-
|
|
56
|
+
const config: any = {};
|
|
57
|
+
|
|
58
|
+
// When hybrid search is enabled, use named vectors
|
|
59
|
+
if (enableSparse) {
|
|
60
|
+
config.vectors = {
|
|
61
|
+
dense: {
|
|
62
|
+
size: vectorSize,
|
|
63
|
+
distance,
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
config.sparse_vectors = {
|
|
67
|
+
text: {
|
|
68
|
+
modifier: "idf",
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
} else {
|
|
72
|
+
// Standard unnamed vector configuration
|
|
73
|
+
config.vectors = {
|
|
51
74
|
size: vectorSize,
|
|
52
75
|
distance,
|
|
53
|
-
}
|
|
54
|
-
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await this.client.createCollection(name, config);
|
|
55
80
|
}
|
|
56
81
|
|
|
57
82
|
async collectionExists(name: string): Promise<boolean> {
|
|
@@ -74,11 +99,25 @@ export class QdrantManager {
|
|
|
74
99
|
|
|
75
100
|
// Handle both named and unnamed vector configurations
|
|
76
101
|
let size = 0;
|
|
77
|
-
let distance:
|
|
102
|
+
let distance: "Cosine" | "Euclid" | "Dot" = "Cosine";
|
|
103
|
+
let hybridEnabled = false;
|
|
78
104
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
105
|
+
// Check if sparse vectors are configured
|
|
106
|
+
if (info.config.params.sparse_vectors) {
|
|
107
|
+
hybridEnabled = true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (typeof vectorConfig === "object" && vectorConfig !== null) {
|
|
111
|
+
// Check for unnamed vector config (has 'size' directly)
|
|
112
|
+
if ("size" in vectorConfig) {
|
|
113
|
+
size = typeof vectorConfig.size === "number" ? vectorConfig.size : 0;
|
|
114
|
+
distance = vectorConfig.distance as "Cosine" | "Euclid" | "Dot";
|
|
115
|
+
} else if ("dense" in vectorConfig) {
|
|
116
|
+
// Named vector config for hybrid search
|
|
117
|
+
const denseConfig = vectorConfig.dense as any;
|
|
118
|
+
size = typeof denseConfig.size === "number" ? denseConfig.size : 0;
|
|
119
|
+
distance = denseConfig.distance as "Cosine" | "Euclid" | "Dot";
|
|
120
|
+
}
|
|
82
121
|
}
|
|
83
122
|
|
|
84
123
|
return {
|
|
@@ -86,6 +125,7 @@ export class QdrantManager {
|
|
|
86
125
|
vectorSize: size,
|
|
87
126
|
pointsCount: info.points_count || 0,
|
|
88
127
|
distance,
|
|
128
|
+
hybridEnabled,
|
|
89
129
|
};
|
|
90
130
|
}
|
|
91
131
|
|
|
@@ -103,7 +143,7 @@ export class QdrantManager {
|
|
|
103
143
|
): Promise<void> {
|
|
104
144
|
try {
|
|
105
145
|
// Normalize all IDs to ensure string IDs are in UUID format
|
|
106
|
-
const normalizedPoints = points.map(point => ({
|
|
146
|
+
const normalizedPoints = points.map((point) => ({
|
|
107
147
|
...point,
|
|
108
148
|
id: this.normalizeId(point.id),
|
|
109
149
|
}));
|
|
@@ -144,8 +184,11 @@ export class QdrantManager {
|
|
|
144
184
|
}
|
|
145
185
|
}
|
|
146
186
|
|
|
187
|
+
// Check if collection uses named vectors (hybrid mode)
|
|
188
|
+
const collectionInfo = await this.getCollectionInfo(collectionName);
|
|
189
|
+
|
|
147
190
|
const results = await this.client.search(collectionName, {
|
|
148
|
-
vector,
|
|
191
|
+
vector: collectionInfo.hybridEnabled ? { name: "dense", vector } : vector,
|
|
149
192
|
limit,
|
|
150
193
|
filter: qdrantFilter,
|
|
151
194
|
});
|
|
@@ -180,16 +223,113 @@ export class QdrantManager {
|
|
|
180
223
|
}
|
|
181
224
|
}
|
|
182
225
|
|
|
183
|
-
async deletePoints(
|
|
184
|
-
collectionName: string,
|
|
185
|
-
ids: (string | number)[]
|
|
186
|
-
): Promise<void> {
|
|
226
|
+
async deletePoints(collectionName: string, ids: (string | number)[]): Promise<void> {
|
|
187
227
|
// Normalize IDs to ensure string IDs are in UUID format
|
|
188
|
-
const normalizedIds = ids.map(id => this.normalizeId(id));
|
|
228
|
+
const normalizedIds = ids.map((id) => this.normalizeId(id));
|
|
189
229
|
|
|
190
230
|
await this.client.delete(collectionName, {
|
|
191
231
|
wait: true,
|
|
192
232
|
points: normalizedIds,
|
|
193
233
|
});
|
|
194
234
|
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Performs hybrid search combining semantic vector search with sparse vector (keyword) search
|
|
238
|
+
* using Reciprocal Rank Fusion (RRF) to combine results
|
|
239
|
+
*/
|
|
240
|
+
async hybridSearch(
|
|
241
|
+
collectionName: string,
|
|
242
|
+
denseVector: number[],
|
|
243
|
+
sparseVector: SparseVector,
|
|
244
|
+
limit: number = 5,
|
|
245
|
+
filter?: Record<string, any>,
|
|
246
|
+
_semanticWeight: number = 0.7
|
|
247
|
+
): Promise<SearchResult[]> {
|
|
248
|
+
// Convert simple key-value filter to Qdrant filter format
|
|
249
|
+
let qdrantFilter;
|
|
250
|
+
if (filter && Object.keys(filter).length > 0) {
|
|
251
|
+
if (filter.must || filter.should || filter.must_not) {
|
|
252
|
+
qdrantFilter = filter;
|
|
253
|
+
} else {
|
|
254
|
+
qdrantFilter = {
|
|
255
|
+
must: Object.entries(filter).map(([key, value]) => ({
|
|
256
|
+
key,
|
|
257
|
+
match: { value },
|
|
258
|
+
})),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Calculate prefetch limits based on weights
|
|
264
|
+
// We fetch more results than needed to ensure good fusion results
|
|
265
|
+
const prefetchLimit = Math.max(20, limit * 4);
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const results = await this.client.query(collectionName, {
|
|
269
|
+
prefetch: [
|
|
270
|
+
{
|
|
271
|
+
query: denseVector,
|
|
272
|
+
using: "dense",
|
|
273
|
+
limit: prefetchLimit,
|
|
274
|
+
filter: qdrantFilter,
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
query: sparseVector,
|
|
278
|
+
using: "text",
|
|
279
|
+
limit: prefetchLimit,
|
|
280
|
+
filter: qdrantFilter,
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
query: {
|
|
284
|
+
fusion: "rrf",
|
|
285
|
+
},
|
|
286
|
+
limit: limit,
|
|
287
|
+
with_payload: true,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
return results.points.map((result: any) => ({
|
|
291
|
+
id: result.id,
|
|
292
|
+
score: result.score,
|
|
293
|
+
payload: result.payload || undefined,
|
|
294
|
+
}));
|
|
295
|
+
} catch (error: any) {
|
|
296
|
+
const errorMessage = error?.data?.status?.error || error?.message || String(error);
|
|
297
|
+
throw new Error(`Hybrid search failed on collection "${collectionName}": ${errorMessage}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Adds points with both dense and sparse vectors for hybrid search
|
|
303
|
+
*/
|
|
304
|
+
async addPointsWithSparse(
|
|
305
|
+
collectionName: string,
|
|
306
|
+
points: Array<{
|
|
307
|
+
id: string | number;
|
|
308
|
+
vector: number[];
|
|
309
|
+
sparseVector: SparseVector;
|
|
310
|
+
payload?: Record<string, any>;
|
|
311
|
+
}>
|
|
312
|
+
): Promise<void> {
|
|
313
|
+
try {
|
|
314
|
+
// Normalize all IDs to ensure string IDs are in UUID format
|
|
315
|
+
const normalizedPoints = points.map((point) => ({
|
|
316
|
+
id: this.normalizeId(point.id),
|
|
317
|
+
vector: {
|
|
318
|
+
dense: point.vector,
|
|
319
|
+
text: point.sparseVector,
|
|
320
|
+
},
|
|
321
|
+
payload: point.payload,
|
|
322
|
+
}));
|
|
323
|
+
|
|
324
|
+
await this.client.upsert(collectionName, {
|
|
325
|
+
wait: true,
|
|
326
|
+
points: normalizedPoints,
|
|
327
|
+
});
|
|
328
|
+
} catch (error: any) {
|
|
329
|
+
const errorMessage = error?.data?.status?.error || error?.message || String(error);
|
|
330
|
+
throw new Error(
|
|
331
|
+
`Failed to add points with sparse vectors to collection "${collectionName}": ${errorMessage}`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
195
335
|
}
|
package/vitest.config.ts
CHANGED
|
@@ -14,10 +14,10 @@ export default defineConfig({
|
|
|
14
14
|
"**/*.test.ts",
|
|
15
15
|
"**/*.spec.ts",
|
|
16
16
|
"vitest.config.ts",
|
|
17
|
-
"
|
|
18
|
-
"
|
|
17
|
+
"commitlint.config.js",
|
|
18
|
+
"src/index.ts",
|
|
19
|
+
"scripts/**",
|
|
19
20
|
],
|
|
20
|
-
// Set thresholds for core business logic modules
|
|
21
21
|
thresholds: {
|
|
22
22
|
"src/qdrant/client.ts": {
|
|
23
23
|
lines: 90,
|