@mhalder/qdrant-mcp-server 1.1.0 → 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.
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { BM25SparseVectorGenerator } from "./sparse.js";
3
+
4
+ describe("BM25SparseVectorGenerator", () => {
5
+ it("should generate sparse vectors for simple text", () => {
6
+ const generator = new BM25SparseVectorGenerator();
7
+ const result = generator.generate("hello world");
8
+
9
+ expect(result.indices).toBeDefined();
10
+ expect(result.values).toBeDefined();
11
+ expect(result.indices.length).toBeGreaterThan(0);
12
+ expect(result.values.length).toBe(result.indices.length);
13
+ });
14
+
15
+ it("should generate different vectors for different texts", () => {
16
+ const generator = new BM25SparseVectorGenerator();
17
+ const result1 = generator.generate("hello world");
18
+ const result2 = generator.generate("goodbye world");
19
+
20
+ // Different texts should have different sparse representations
21
+ expect(result1.indices).not.toEqual(result2.indices);
22
+ });
23
+
24
+ it("should generate consistent vectors for the same text", () => {
25
+ const generator = new BM25SparseVectorGenerator();
26
+ const result1 = generator.generate("hello world");
27
+ const result2 = generator.generate("hello world");
28
+
29
+ expect(result1.indices).toEqual(result2.indices);
30
+ expect(result1.values).toEqual(result2.values);
31
+ });
32
+
33
+ it("should handle empty strings", () => {
34
+ const generator = new BM25SparseVectorGenerator();
35
+ const result = generator.generate("");
36
+
37
+ expect(result.indices).toHaveLength(0);
38
+ expect(result.values).toHaveLength(0);
39
+ });
40
+
41
+ it("should handle special characters and punctuation", () => {
42
+ const generator = new BM25SparseVectorGenerator();
43
+ const result = generator.generate("hello, world! how are you?");
44
+
45
+ expect(result.indices).toBeDefined();
46
+ expect(result.values).toBeDefined();
47
+ expect(result.indices.length).toBeGreaterThan(0);
48
+ });
49
+
50
+ it("should train on corpus and generate IDF scores", () => {
51
+ const generator = new BM25SparseVectorGenerator();
52
+ const corpus = ["the quick brown fox", "jumps over the lazy dog", "the fox is quick"];
53
+
54
+ generator.train(corpus);
55
+ const result = generator.generate("quick fox");
56
+
57
+ expect(result.indices).toBeDefined();
58
+ expect(result.values).toBeDefined();
59
+ expect(result.indices.length).toBeGreaterThan(0);
60
+ });
61
+
62
+ it("should use static generateSimple method", () => {
63
+ const result = BM25SparseVectorGenerator.generateSimple("hello world");
64
+
65
+ expect(result.indices).toBeDefined();
66
+ expect(result.values).toBeDefined();
67
+ expect(result.indices.length).toBeGreaterThan(0);
68
+ });
69
+
70
+ it("should lowercase and tokenize text properly", () => {
71
+ const generator = new BM25SparseVectorGenerator();
72
+ const result1 = generator.generate("HELLO WORLD");
73
+ const result2 = generator.generate("hello world");
74
+
75
+ // Should produce same results due to lowercasing
76
+ expect(result1.indices).toEqual(result2.indices);
77
+ });
78
+
79
+ it("should generate positive values", () => {
80
+ const generator = new BM25SparseVectorGenerator();
81
+ const result = generator.generate("hello world");
82
+
83
+ result.values.forEach((value) => {
84
+ expect(value).toBeGreaterThan(0);
85
+ });
86
+ });
87
+ });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * BM25 Sparse Vector Generator
3
+ *
4
+ * This module provides a simple BM25-like sparse vector generation for keyword search.
5
+ * For production use, consider using a proper BM25 implementation or Qdrant's built-in
6
+ * sparse vector generation via FastEmbed.
7
+ */
8
+
9
+ import type { SparseVector } from "../qdrant/client.js";
10
+
11
+ interface TokenFrequency {
12
+ [token: string]: number;
13
+ }
14
+
15
+ export class BM25SparseVectorGenerator {
16
+ private vocabulary: Map<string, number>;
17
+ private idfScores: Map<string, number>;
18
+ private documentCount: number;
19
+ private k1: number;
20
+ private b: number;
21
+
22
+ constructor(k1: number = 1.2, b: number = 0.75) {
23
+ this.vocabulary = new Map();
24
+ this.idfScores = new Map();
25
+ this.documentCount = 0;
26
+ this.k1 = k1;
27
+ this.b = b;
28
+ }
29
+
30
+ /**
31
+ * Tokenize text into words (simple whitespace tokenization + lowercase)
32
+ */
33
+ private tokenize(text: string): string[] {
34
+ return text
35
+ .toLowerCase()
36
+ .replace(/[^\w\s]/g, " ")
37
+ .split(/\s+/)
38
+ .filter((token) => token.length > 0);
39
+ }
40
+
41
+ /**
42
+ * Calculate term frequency for a document
43
+ */
44
+ private getTermFrequency(tokens: string[]): TokenFrequency {
45
+ const tf: TokenFrequency = {};
46
+ for (const token of tokens) {
47
+ tf[token] = (tf[token] || 0) + 1;
48
+ }
49
+ return tf;
50
+ }
51
+
52
+ /**
53
+ * Build vocabulary from training documents (optional pre-training step)
54
+ * In a simple implementation, we can skip this and use on-the-fly vocabulary
55
+ */
56
+ train(documents: string[]): void {
57
+ this.documentCount = documents.length;
58
+ const documentFrequency = new Map<string, number>();
59
+
60
+ // Calculate document frequency for each term
61
+ for (const doc of documents) {
62
+ const tokens = this.tokenize(doc);
63
+ const uniqueTokens = new Set(tokens);
64
+
65
+ for (const token of uniqueTokens) {
66
+ if (!this.vocabulary.has(token)) {
67
+ this.vocabulary.set(token, this.vocabulary.size);
68
+ }
69
+ documentFrequency.set(token, (documentFrequency.get(token) || 0) + 1);
70
+ }
71
+ }
72
+
73
+ // Calculate IDF scores
74
+ for (const [token, df] of documentFrequency.entries()) {
75
+ const idf = Math.log((this.documentCount - df + 0.5) / (df + 0.5) + 1.0);
76
+ this.idfScores.set(token, idf);
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Generate sparse vector for a query or document
82
+ * Returns indices and values for non-zero dimensions
83
+ */
84
+ generate(text: string, avgDocLength: number = 50): SparseVector {
85
+ const tokens = this.tokenize(text);
86
+ const tf = this.getTermFrequency(tokens);
87
+ const docLength = tokens.length;
88
+
89
+ const indices: number[] = [];
90
+ const values: number[] = [];
91
+
92
+ // Calculate BM25 score for each term
93
+ for (const [token, freq] of Object.entries(tf)) {
94
+ // Ensure token is in vocabulary
95
+ if (!this.vocabulary.has(token)) {
96
+ // For unseen tokens, add them to vocabulary dynamically
97
+ this.vocabulary.set(token, this.vocabulary.size);
98
+ }
99
+
100
+ const index = this.vocabulary.get(token)!;
101
+
102
+ // Use a default IDF if not trained
103
+ const idf = this.idfScores.get(token) || 1.0;
104
+
105
+ // BM25 formula
106
+ const numerator = freq * (this.k1 + 1);
107
+ const denominator = freq + this.k1 * (1 - this.b + this.b * (docLength / avgDocLength));
108
+ const score = idf * (numerator / denominator);
109
+
110
+ if (score > 0) {
111
+ indices.push(index);
112
+ values.push(score);
113
+ }
114
+ }
115
+
116
+ return { indices, values };
117
+ }
118
+
119
+ /**
120
+ * Simple static method for generating sparse vectors without training
121
+ * Useful for quick implementation
122
+ */
123
+ static generateSimple(text: string): SparseVector {
124
+ const generator = new BM25SparseVectorGenerator();
125
+ return generator.generate(text);
126
+ }
127
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
3
6
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
7
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
8
  import {
@@ -8,24 +11,18 @@ import {
8
11
  ListToolsRequestSchema,
9
12
  ReadResourceRequestSchema,
10
13
  } from "@modelcontextprotocol/sdk/types.js";
11
- import { QdrantManager } from "./qdrant/client.js";
12
- import { EmbeddingProviderFactory } from "./embeddings/factory.js";
13
14
  import { z } from "zod";
14
- import { readFileSync } from "fs";
15
- import { fileURLToPath } from "url";
16
- import { dirname, join } from "path";
15
+ import { EmbeddingProviderFactory } from "./embeddings/factory.js";
16
+ import { BM25SparseVectorGenerator } from "./embeddings/sparse.js";
17
+ import { QdrantManager } from "./qdrant/client.js";
17
18
 
18
19
  // Read package.json for version
19
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
- const pkg = JSON.parse(
21
- readFileSync(join(__dirname, "../package.json"), "utf-8"),
22
- );
21
+ const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
23
22
 
24
23
  // Validate environment variables
25
24
  const QDRANT_URL = process.env.QDRANT_URL || "http://localhost:6333";
26
- const EMBEDDING_PROVIDER = (
27
- process.env.EMBEDDING_PROVIDER || "ollama"
28
- ).toLowerCase();
25
+ const EMBEDDING_PROVIDER = (process.env.EMBEDDING_PROVIDER || "ollama").toLowerCase();
29
26
 
30
27
  // Check for required API keys based on provider
31
28
  if (EMBEDDING_PROVIDER !== "ollama") {
@@ -47,15 +44,13 @@ if (EMBEDDING_PROVIDER !== "ollama") {
47
44
  break;
48
45
  default:
49
46
  console.error(
50
- `Error: Unknown embedding provider "${EMBEDDING_PROVIDER}". Supported providers: openai, cohere, voyage, ollama.`,
47
+ `Error: Unknown embedding provider "${EMBEDDING_PROVIDER}". Supported providers: openai, cohere, voyage, ollama.`
51
48
  );
52
49
  process.exit(1);
53
50
  }
54
51
 
55
52
  if (!apiKey) {
56
- console.error(
57
- `Error: ${requiredKeyName} is required for ${EMBEDDING_PROVIDER} provider.`,
58
- );
53
+ console.error(`Error: ${requiredKeyName} is required for ${EMBEDDING_PROVIDER} provider.`);
59
54
  process.exit(1);
60
55
  }
61
56
  }
@@ -64,8 +59,7 @@ if (EMBEDDING_PROVIDER !== "ollama") {
64
59
  async function checkOllamaAvailability() {
65
60
  if (EMBEDDING_PROVIDER === "ollama") {
66
61
  const baseUrl = process.env.EMBEDDING_BASE_URL || "http://localhost:11434";
67
- const isLocalhost =
68
- baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1");
62
+ const isLocalhost = baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1");
69
63
 
70
64
  try {
71
65
  const response = await fetch(`${baseUrl}/api/version`);
@@ -78,7 +72,7 @@ async function checkOllamaAvailability() {
78
72
  const { models } = await tagsResponse.json();
79
73
  const modelName = process.env.EMBEDDING_MODEL || "nomic-embed-text";
80
74
  const modelExists = models.some(
81
- (m: any) => m.name === modelName || m.name.startsWith(`${modelName}:`),
75
+ (m: any) => m.name === modelName || m.name.startsWith(`${modelName}:`)
82
76
  );
83
77
 
84
78
  if (!modelExists) {
@@ -141,7 +135,7 @@ const server = new Server(
141
135
  tools: {},
142
136
  resources: {},
143
137
  },
144
- },
138
+ }
145
139
  );
146
140
 
147
141
  // Tool schemas
@@ -151,6 +145,10 @@ const CreateCollectionSchema = z.object({
151
145
  .enum(["Cosine", "Euclid", "Dot"])
152
146
  .optional()
153
147
  .describe("Distance metric (default: Cosine)"),
148
+ enableHybrid: z
149
+ .boolean()
150
+ .optional()
151
+ .describe("Enable hybrid search with sparse vectors (default: false)"),
154
152
  });
155
153
 
156
154
  const AddDocumentsSchema = z.object({
@@ -158,15 +156,13 @@ const AddDocumentsSchema = z.object({
158
156
  documents: z
159
157
  .array(
160
158
  z.object({
161
- id: z
162
- .union([z.string(), z.number()])
163
- .describe("Unique identifier for the document"),
159
+ id: z.union([z.string(), z.number()]).describe("Unique identifier for the document"),
164
160
  text: z.string().describe("Text content to embed and store"),
165
161
  metadata: z
166
162
  .record(z.any())
167
163
  .optional()
168
164
  .describe("Optional metadata to store with the document"),
169
- }),
165
+ })
170
166
  )
171
167
  .describe("Array of documents to add"),
172
168
  });
@@ -174,10 +170,7 @@ const AddDocumentsSchema = z.object({
174
170
  const SemanticSearchSchema = z.object({
175
171
  collection: z.string().describe("Name of the collection to search"),
176
172
  query: z.string().describe("Search query text"),
177
- limit: z
178
- .number()
179
- .optional()
180
- .describe("Maximum number of results (default: 5)"),
173
+ limit: z.number().optional().describe("Maximum number of results (default: 5)"),
181
174
  filter: z.record(z.any()).optional().describe("Optional metadata filter"),
182
175
  });
183
176
 
@@ -191,9 +184,14 @@ const GetCollectionInfoSchema = z.object({
191
184
 
192
185
  const DeleteDocumentsSchema = z.object({
193
186
  collection: z.string().describe("Name of the collection"),
194
- ids: z
195
- .array(z.union([z.string(), z.number()]))
196
- .describe("Array of document IDs to delete"),
187
+ ids: z.array(z.union([z.string(), z.number()])).describe("Array of document IDs to delete"),
188
+ });
189
+
190
+ const HybridSearchSchema = z.object({
191
+ collection: z.string().describe("Name of the collection to search"),
192
+ query: z.string().describe("Search query text"),
193
+ limit: z.number().optional().describe("Maximum number of results (default: 5)"),
194
+ filter: z.record(z.any()).optional().describe("Optional metadata filter"),
197
195
  });
198
196
 
199
197
  // List available tools
@@ -203,7 +201,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
203
201
  {
204
202
  name: "create_collection",
205
203
  description:
206
- "Create a new vector collection in Qdrant. The collection will be configured with the embedding provider's dimensions automatically.",
204
+ "Create a new vector collection in Qdrant. The collection will be configured with the embedding provider's dimensions automatically. Set enableHybrid to true to enable hybrid search combining semantic and keyword search.",
207
205
  inputSchema: {
208
206
  type: "object",
209
207
  properties: {
@@ -216,6 +214,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
216
214
  enum: ["Cosine", "Euclid", "Dot"],
217
215
  description: "Distance metric (default: Cosine)",
218
216
  },
217
+ enableHybrid: {
218
+ type: "boolean",
219
+ description: "Enable hybrid search with sparse vectors (default: false)",
220
+ },
219
221
  },
220
222
  required: ["name"],
221
223
  },
@@ -323,8 +325,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
323
325
  },
324
326
  {
325
327
  name: "delete_documents",
326
- description:
327
- "Delete specific documents from a collection by their IDs.",
328
+ description: "Delete specific documents from a collection by their IDs.",
328
329
  inputSchema: {
329
330
  type: "object",
330
331
  properties: {
@@ -343,6 +344,33 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
343
344
  required: ["collection", "ids"],
344
345
  },
345
346
  },
347
+ {
348
+ name: "hybrid_search",
349
+ description:
350
+ "Perform hybrid search combining semantic vector search with keyword search using BM25. This provides better results by combining the strengths of both approaches. The collection must be created with enableHybrid set to true.",
351
+ inputSchema: {
352
+ type: "object",
353
+ properties: {
354
+ collection: {
355
+ type: "string",
356
+ description: "Name of the collection to search",
357
+ },
358
+ query: {
359
+ type: "string",
360
+ description: "Search query text",
361
+ },
362
+ limit: {
363
+ type: "number",
364
+ description: "Maximum number of results (default: 5)",
365
+ },
366
+ filter: {
367
+ type: "object",
368
+ description: "Optional metadata filter",
369
+ },
370
+ },
371
+ required: ["collection", "query"],
372
+ },
373
+ },
346
374
  ],
347
375
  };
348
376
  });
@@ -354,14 +382,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
354
382
  try {
355
383
  switch (name) {
356
384
  case "create_collection": {
357
- const { name, distance } = CreateCollectionSchema.parse(args);
385
+ const { name, distance, enableHybrid } = CreateCollectionSchema.parse(args);
358
386
  const vectorSize = embeddings.getDimensions();
359
- await qdrant.createCollection(name, vectorSize, distance);
387
+ await qdrant.createCollection(name, vectorSize, distance, enableHybrid || false);
388
+
389
+ let message = `Collection "${name}" created successfully with ${vectorSize} dimensions and ${distance || "Cosine"} distance metric.`;
390
+ if (enableHybrid) {
391
+ message += " Hybrid search is enabled for this collection.";
392
+ }
393
+
360
394
  return {
361
395
  content: [
362
396
  {
363
397
  type: "text",
364
- text: `Collection "${name}" created successfully with ${vectorSize} dimensions and ${distance || "Cosine"} distance metric.`,
398
+ text: message,
365
399
  },
366
400
  ],
367
401
  };
@@ -370,7 +404,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
370
404
  case "add_documents": {
371
405
  const { collection, documents } = AddDocumentsSchema.parse(args);
372
406
 
373
- // Check if collection exists
407
+ // Check if collection exists and get info
374
408
  const exists = await qdrant.collectionExists(collection);
375
409
  if (!exists) {
376
410
  return {
@@ -384,21 +418,41 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
384
418
  };
385
419
  }
386
420
 
421
+ const collectionInfo = await qdrant.getCollectionInfo(collection);
422
+
387
423
  // Generate embeddings for all documents
388
424
  const texts = documents.map((doc) => doc.text);
389
425
  const embeddingResults = await embeddings.embedBatch(texts);
390
426
 
391
- // Prepare points for insertion
392
- const points = documents.map((doc, index) => ({
393
- id: doc.id,
394
- vector: embeddingResults[index].embedding,
395
- payload: {
396
- text: doc.text,
397
- ...doc.metadata,
398
- },
399
- }));
427
+ // If hybrid search is enabled, generate sparse vectors and use appropriate method
428
+ if (collectionInfo.hybridEnabled) {
429
+ const sparseGenerator = new BM25SparseVectorGenerator();
430
+
431
+ // Prepare points with both dense and sparse vectors
432
+ const points = documents.map((doc, index) => ({
433
+ id: doc.id,
434
+ vector: embeddingResults[index].embedding,
435
+ sparseVector: sparseGenerator.generate(doc.text),
436
+ payload: {
437
+ text: doc.text,
438
+ ...doc.metadata,
439
+ },
440
+ }));
400
441
 
401
- await qdrant.addPoints(collection, points);
442
+ await qdrant.addPointsWithSparse(collection, points);
443
+ } else {
444
+ // Standard dense-only vectors
445
+ const points = documents.map((doc, index) => ({
446
+ id: doc.id,
447
+ vector: embeddingResults[index].embedding,
448
+ payload: {
449
+ text: doc.text,
450
+ ...doc.metadata,
451
+ },
452
+ }));
453
+
454
+ await qdrant.addPoints(collection, points);
455
+ }
402
456
 
403
457
  return {
404
458
  content: [
@@ -411,8 +465,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
411
465
  }
412
466
 
413
467
  case "semantic_search": {
414
- const { collection, query, limit, filter } =
415
- SemanticSearchSchema.parse(args);
468
+ const { collection, query, limit, filter } = SemanticSearchSchema.parse(args);
416
469
 
417
470
  // Check if collection exists
418
471
  const exists = await qdrant.collectionExists(collection);
@@ -432,12 +485,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
432
485
  const { embedding } = await embeddings.embed(query);
433
486
 
434
487
  // Search
435
- const results = await qdrant.search(
436
- collection,
437
- embedding,
438
- limit || 5,
439
- filter,
440
- );
488
+ const results = await qdrant.search(collection, embedding, limit || 5, filter);
441
489
 
442
490
  return {
443
491
  content: [
@@ -500,6 +548,63 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
500
548
  };
501
549
  }
502
550
 
551
+ case "hybrid_search": {
552
+ const { collection, query, limit, filter } = HybridSearchSchema.parse(args);
553
+
554
+ // Check if collection exists
555
+ const exists = await qdrant.collectionExists(collection);
556
+ if (!exists) {
557
+ return {
558
+ content: [
559
+ {
560
+ type: "text",
561
+ text: `Error: Collection "${collection}" does not exist.`,
562
+ },
563
+ ],
564
+ isError: true,
565
+ };
566
+ }
567
+
568
+ // Check if collection has hybrid search enabled
569
+ const collectionInfo = await qdrant.getCollectionInfo(collection);
570
+ if (!collectionInfo.hybridEnabled) {
571
+ return {
572
+ content: [
573
+ {
574
+ type: "text",
575
+ text: `Error: Collection "${collection}" does not have hybrid search enabled. Create a new collection with enableHybrid set to true.`,
576
+ },
577
+ ],
578
+ isError: true,
579
+ };
580
+ }
581
+
582
+ // Generate dense embedding for query
583
+ const { embedding } = await embeddings.embed(query);
584
+
585
+ // Generate sparse vector for query
586
+ const sparseGenerator = new BM25SparseVectorGenerator();
587
+ const sparseVector = sparseGenerator.generate(query);
588
+
589
+ // Perform hybrid search
590
+ const results = await qdrant.hybridSearch(
591
+ collection,
592
+ embedding,
593
+ sparseVector,
594
+ limit || 5,
595
+ filter
596
+ );
597
+
598
+ return {
599
+ content: [
600
+ {
601
+ type: "text",
602
+ text: JSON.stringify(results, null, 2),
603
+ },
604
+ ],
605
+ };
606
+ }
607
+
503
608
  default:
504
609
  return {
505
610
  content: [
@@ -513,8 +618,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
513
618
  }
514
619
  } catch (error: any) {
515
620
  // Enhanced error details for debugging
516
- const errorDetails =
517
- error instanceof Error ? error.message : JSON.stringify(error, null, 2);
621
+ const errorDetails = error instanceof Error ? error.message : JSON.stringify(error, null, 2);
518
622
 
519
623
  console.error("Tool execution error:", {
520
624
  tool: name,