Skip to content

Commit c0c93df

Browse files
feat: add keyword_search tool for pinecone-rag-sparse index (#29)
feat: add keyword_search tool for pinecone-rag-sparse index (#29) - Add PineconeClient.keywordSearch() for sparse-only (lexical) search - Register keyword_search MCP tool (query_text, namespace, top_k, optional metadata_filter, fields) - Config: PINECONE_SPARSE_INDEX_NAME / --sparse-index-name (default: pinecone-rag-sparse) - Document tool in README; add Test 5 in scripts/test-search.ts - No reranking; returns same result shape as query tool (reranked: false)
1 parent b722783 commit c0c93df

8 files changed

Lines changed: 345 additions & 18 deletions

File tree

README.md

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,13 @@ The server requires a Pinecone API key and supports the following configuration
5858

5959
### Environment Variables
6060

61-
| Variable | Required | Default | Description |
62-
| ---------------------------------- | -------- | -------------------- | --------------------- |
63-
| `PINECONE_API_KEY` | Yes | - | Your Pinecone API key |
64-
| `PINECONE_INDEX_NAME` | No | `rag-hybrid` | Pinecone index name |
65-
| `PINECONE_RERANK_MODEL` | No | `bge-reranker-v2-m3` | Reranking model |
66-
| `PINECONE_READ_ONLY_MCP_LOG_LEVEL` | No | `INFO` | Logging level |
61+
| Variable | Required | Default | Description |
62+
| ---------------------------------- | -------- | ---------------------- | ------------------------------------------------ |
63+
| `PINECONE_API_KEY` | Yes | - | Your Pinecone API key |
64+
| `PINECONE_INDEX_NAME` | No | `rag-hybrid` | Pinecone index name (dense + sparse for hybrid) |
65+
| `PINECONE_SPARSE_INDEX_NAME` | No | `pinecone-rag-sparse` | Sparse index for `keyword_search` tool |
66+
| `PINECONE_RERANK_MODEL` | No | `bge-reranker-v2-m3` | Reranking model |
67+
| `PINECONE_READ_ONLY_MCP_LOG_LEVEL` | No | `INFO` | Logging level |
6768

6869
### Claude Desktop Configuration
6970

@@ -143,11 +144,12 @@ node node_modules/@will-cppa/pinecone-read-only-mcp/dist/index.js --api-key YOUR
143144
### Available Options
144145

145146
```
146-
--api-key TEXT Pinecone API key (or set PINECONE_API_KEY env var)
147-
--index-name TEXT Pinecone index name [default: rag-hybrid]
148-
--rerank-model TEXT Reranking model [default: bge-reranker-v2-m3]
149-
--log-level TEXT Logging level [default: INFO]
150-
--help, -h Show help message
147+
--api-key TEXT Pinecone API key (or set PINECONE_API_KEY env var)
148+
--index-name TEXT Pinecone index name [default: rag-hybrid]
149+
--sparse-index-name TEXT Sparse index for keyword_search [default: pinecone-rag-sparse]
150+
--rerank-model TEXT Reranking model [default: bge-reranker-v2-m3]
151+
--log-level TEXT Logging level [default: INFO]
152+
--help, -h Show help message
151153
```
152154

153155
## Deployment
@@ -338,6 +340,45 @@ Returns the **unique document count** matching a metadata filter and semantic qu
338340
}
339341
```
340342

343+
### `keyword_search`
344+
345+
Performs **keyword (lexical/sparse-only)** search over the dedicated sparse index (default: `pinecone-rag-sparse`). Use for exact or keyword-style queries. Does not use the dense index or semantic reranking. Call `list_namespaces` first to discover namespaces; `suggest_query_params` is optional.
346+
347+
**Parameters:**
348+
349+
| Parameter | Type | Required | Default | Description |
350+
| ----------------- | -------- | -------- | ------- | --------------------------------------------------------------------------- |
351+
| `query_text` | string | Yes | - | Search query text (keyword/lexical match) |
352+
| `namespace` | string | Yes | - | Namespace to search (use `list_namespaces` to discover) |
353+
| `top_k` | integer | No | `10` | Number of results to return (1-100) |
354+
| `metadata_filter` | object | No | - | Optional metadata filter (same operators as `query`) |
355+
| `fields` | string[] | No | - | Optional field names to return; omit for all fields |
356+
357+
**Returns:** JSON with `status`, `query`, `namespace`, `index` (sparse index name), `result_count`, and `results` (ids, metadata, scores). Result rows match the `query` tool shape (e.g. `paper_number`, `title`, `author`, `url`, `content`, `score`, `reranked: false`).
358+
359+
**Example response:**
360+
361+
```json
362+
{
363+
"status": "success",
364+
"query": "contracts C++",
365+
"namespace": "wg21-papers",
366+
"index": "pinecone-rag-sparse",
367+
"result_count": 5,
368+
"results": [
369+
{
370+
"paper_number": "P0548",
371+
"title": "Contracts for C++",
372+
"author": "John Doe",
373+
"url": "https://...",
374+
"content": "...",
375+
"score": 0.85,
376+
"reranked": false
377+
}
378+
]
379+
}
380+
```
381+
341382
### `query`
342383

343384
Performs hybrid semantic search over the specified namespace in the Pinecone index with optional metadata filtering. For **count** questions, use the `count` tool instead.
@@ -470,6 +511,18 @@ npm run build
470511
npm test
471512
```
472513

514+
### Testing the keyword_search tool
515+
516+
1. **Connectivity and keyword search (script):**
517+
Run the search test script (includes a keyword search step against the sparse index):
518+
```bash
519+
PINECONE_API_KEY=your-key npm run test:search
520+
```
521+
If the sparse index (`pinecone-rag-sparse` by default) does not exist or has no data, the keyword search step is skipped with a warning.
522+
523+
2. **Via MCP client:**
524+
Start the server and call the `keyword_search` tool with `query_text`, `namespace` (from `list_namespaces`), and optional `top_k` or `metadata_filter`. Response shape is the same as the `query` tool (e.g. `results` with ids, metadata, scores; `reranked` is always `false`).
525+
473526
### Code Quality
474527

475528
```bash

scripts/test-search.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,28 @@ async function test() {
152152
);
153153
}
154154

155+
// Test 5: Keyword (sparse-only) search on pinecone-rag-sparse
156+
console.log(`\n🔤 Test 5: Keyword search (sparse-only index)`);
157+
console.log(` Namespace: "${testNamespace}"`);
158+
console.log(` Query: "test query"`);
159+
console.log(` Top K: 3`);
160+
try {
161+
const startTime5 = Date.now();
162+
const results5 = await client.keywordSearch({
163+
query: 'test query',
164+
namespace: testNamespace,
165+
topK: 3,
166+
});
167+
const duration5 = Date.now() - startTime5;
168+
console.log(`✅ Keyword search returned ${results5.length} result(s) in ${duration5}ms`);
169+
if (results5.length > 0) {
170+
console.log(` First result score: ${results5[0].score.toFixed(4)}, reranked: ${results5[0].reranked}`);
171+
}
172+
} catch (kwError) {
173+
console.log(`⚠️ Keyword search skipped: ${kwError instanceof Error ? kwError.message : String(kwError)}`);
174+
console.log(` Ensure PINECONE_SPARSE_INDEX_NAME (e.g. pinecone-rag-sparse) exists and has data.`);
175+
}
176+
155177
console.log('\n✨ All tests completed successfully!');
156178
console.log(`\nPerformance comparison:`);
157179
console.log(` Without reranking: ${duration1}ms`);

src/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
*/
44

55
export const DEFAULT_INDEX_NAME = 'rag-hybrid';
6+
/** Default index name for keyword (sparse-only) search. Used by the keyword_search tool. */
7+
export const DEFAULT_SPARSE_INDEX_NAME = 'pinecone-rag-sparse';
68
export const DEFAULT_RERANK_MODEL = 'bge-reranker-v2-m3';
79
export const DEFAULT_TOP_K = 10;
810
export const MAX_TOP_K = 100;
@@ -45,6 +47,7 @@ Features:
4547
- Count: Use the count tool for "how many X?" questions; it uses semantic search only and minimal fields (no content) for performance, returning unique document count.
4648
- URL Generation: Use generate_urls to synthesize URLs for namespaces that support it when metadata lacks url.
4749
- Document reassembly: Use query_documents to get whole documents (chunks grouped and merged by document_number/doc_id/url) for content analysis or summarization.
50+
- Keyword search: Use keyword_search to query the sparse index (e.g. pinecone-rag-sparse) for lexical/keyword-only retrieval without reranking.
4851
4952
Usage:
5053
1. Use list_namespaces (cached for 30 minutes) to discover available namespaces in the index

src/index.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
1212
import { PineconeClient } from './pinecone-client.js';
1313
import { setupServer, setPineconeClient } from './server.js';
14-
import { DEFAULT_INDEX_NAME, DEFAULT_RERANK_MODEL } from './constants.js';
14+
import { DEFAULT_INDEX_NAME, DEFAULT_RERANK_MODEL, DEFAULT_SPARSE_INDEX_NAME } from './constants.js';
1515
import type { LogLevel } from './config.js';
1616
import { setLogLevel } from './logger.js';
1717
import * as dotenv from 'dotenv';
@@ -22,6 +22,7 @@ dotenv.config();
2222
interface CLIOptions {
2323
apiKey?: string;
2424
indexName?: string;
25+
sparseIndexName?: string;
2526
rerankModel?: string;
2627
logLevel?: string;
2728
}
@@ -44,6 +45,10 @@ function parseArgs(): CLIOptions {
4445
options.indexName = nextArg;
4546
i++;
4647
break;
48+
case '--sparse-index-name':
49+
options.sparseIndexName = nextArg;
50+
i++;
51+
break;
4752
case '--rerank-model':
4853
options.rerankModel = nextArg;
4954
i++;
@@ -71,15 +76,17 @@ Pinecone Read-Only MCP Server
7176
Usage: pinecone-read-only-mcp [options]
7277
7378
Options:
74-
--api-key TEXT Pinecone API key (or set PINECONE_API_KEY env var)
75-
--index-name TEXT Pinecone index name [default: ${DEFAULT_INDEX_NAME}]
76-
--rerank-model TEXT Reranking model [default: ${DEFAULT_RERANK_MODEL}]
77-
--log-level TEXT Logging level [default: INFO]
78-
--help, -h Show this help message
79+
--api-key TEXT Pinecone API key (or set PINECONE_API_KEY env var)
80+
--index-name TEXT Pinecone index name [default: ${DEFAULT_INDEX_NAME}]
81+
--sparse-index-name TEXT Sparse index for keyword_search [default: ${DEFAULT_SPARSE_INDEX_NAME}]
82+
--rerank-model TEXT Reranking model [default: ${DEFAULT_RERANK_MODEL}]
83+
--log-level TEXT Logging level [default: INFO]
84+
--help, -h Show this help message
7985
8086
Environment Variables:
8187
PINECONE_API_KEY Pinecone API key
8288
PINECONE_INDEX_NAME Pinecone index name
89+
PINECONE_SPARSE_INDEX_NAME Sparse index for keyword_search tool
8390
PINECONE_RERANK_MODEL Reranking model name
8491
PINECONE_READ_ONLY_MCP_LOG_LEVEL Logging level
8592
@@ -119,19 +126,24 @@ async function main(): Promise<void> {
119126

120127
// Get configuration
121128
const indexName = options.indexName || process.env['PINECONE_INDEX_NAME'] || DEFAULT_INDEX_NAME;
129+
const sparseIndexName =
130+
options.sparseIndexName ||
131+
process.env['PINECONE_SPARSE_INDEX_NAME'] ||
132+
DEFAULT_SPARSE_INDEX_NAME;
122133
const rerankModel =
123134
options.rerankModel || process.env['PINECONE_RERANK_MODEL'] || DEFAULT_RERANK_MODEL;
124135

125136
// Initialize Pinecone client
126137
const client = new PineconeClient({
127138
apiKey,
128139
indexName,
140+
sparseIndexName,
129141
rerankModel,
130142
});
131143
setPineconeClient(client);
132144

133145
console.error(`Starting Pinecone Read-Only MCP server with stdio transport`);
134-
console.error(`Using Pinecone index: ${indexName}`);
146+
console.error(`Using Pinecone index: ${indexName} (keyword search: ${sparseIndexName})`);
135147
console.error(`Log level: ${logLevel}`);
136148

137149
// Setup server

src/pinecone-client.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
QueryParams,
2121
CountParams,
2222
CountResult,
23+
KeywordSearchParams,
2324
MergedHit,
2425
NamespaceHandle,
2526
SearchableIndex,
@@ -28,6 +29,7 @@ import type {
2829
import {
2930
DEFAULT_INDEX_NAME,
3031
DEFAULT_RERANK_MODEL,
32+
DEFAULT_SPARSE_INDEX_NAME,
3133
DEFAULT_TOP_K,
3234
MAX_TOP_K,
3335
COUNT_TOP_K,
@@ -55,19 +57,25 @@ function inferMetadataFieldType(value: unknown): string {
5557
export class PineconeClient {
5658
private apiKey: string;
5759
private indexName: string;
60+
private sparseIndexName: string;
5861
private rerankModel: string;
5962
private defaultTopK: number;
6063

6164
// Lazy initialization
6265
private pc: Pinecone | null = null;
6366
private denseIndex: SearchableIndex | null = null;
6467
private sparseIndex: SearchableIndex | null = null;
68+
private keywordIndex: SearchableIndex | null = null;
6569
private initialized = false;
6670

6771
/** Create a client with the given config; env vars override index name, rerank model, and top-k. */
6872
constructor(config: PineconeClientConfig) {
6973
this.apiKey = config.apiKey;
7074
this.indexName = config.indexName || process.env['PINECONE_INDEX_NAME'] || DEFAULT_INDEX_NAME;
75+
this.sparseIndexName =
76+
config.sparseIndexName ||
77+
process.env['PINECONE_SPARSE_INDEX_NAME'] ||
78+
DEFAULT_SPARSE_INDEX_NAME;
7179
this.rerankModel =
7280
config.rerankModel || process.env['PINECONE_RERANK_MODEL'] || DEFAULT_RERANK_MODEL;
7381
this.defaultTopK =
@@ -115,6 +123,21 @@ export class PineconeClient {
115123
return { denseIndex: dense, sparseIndex: sparse };
116124
}
117125

126+
/**
127+
* Ensure the dedicated keyword (sparse-only) index is initialized.
128+
* Used by the keyword_search tool to query pinecone-rag-sparse (or configured name).
129+
*/
130+
private async ensureKeywordIndex(): Promise<SearchableIndex> {
131+
if (this.keywordIndex !== null) {
132+
return this.keywordIndex;
133+
}
134+
const pc = this.ensureClient();
135+
const index = pc.index(this.sparseIndexName) as unknown as SearchableIndex;
136+
this.keywordIndex = index;
137+
logInfo(`Keyword search index: ${this.sparseIndexName}`);
138+
return index;
139+
}
140+
118141
/**
119142
* List all available namespaces with their metadata information
120143
*
@@ -453,6 +476,68 @@ export class PineconeClient {
453476
return documents;
454477
}
455478

479+
/**
480+
* Keyword (sparse-only) search against the dedicated sparse index.
481+
* Performs lexical/keyword retrieval only—no dense index, no reranking.
482+
* Use for exact or keyword-style queries on the pinecone-rag-sparse index.
483+
*/
484+
async keywordSearch(params: KeywordSearchParams): Promise<SearchResult[]> {
485+
const {
486+
query,
487+
namespace,
488+
topK: requestedTopK,
489+
metadataFilter,
490+
fields: requestedFields,
491+
} = params;
492+
493+
if (!query || !query.trim()) {
494+
throw new Error('Query cannot be empty');
495+
}
496+
497+
let topK = requestedTopK !== undefined ? requestedTopK : this.defaultTopK;
498+
if (topK < 1) {
499+
throw new Error('topK must be at least 1');
500+
}
501+
if (topK > MAX_TOP_K) {
502+
topK = MAX_TOP_K;
503+
}
504+
505+
const keywordIndex = await this.ensureKeywordIndex();
506+
const searchOptions = requestedFields?.length ? { fields: requestedFields } : undefined;
507+
508+
const hits = await this.searchIndex(
509+
keywordIndex,
510+
query.trim(),
511+
topK,
512+
namespace,
513+
metadataFilter,
514+
searchOptions
515+
);
516+
517+
const documents: SearchResult[] = hits.map((hit) => {
518+
const fields = hit.fields || {};
519+
let content = '';
520+
const metadata: Record<string, PineconeMetadataValue> = {};
521+
for (const [key, value] of Object.entries(fields)) {
522+
if (key === 'chunk_text') {
523+
content = typeof value === 'string' ? value : '';
524+
} else {
525+
metadata[key] = value as PineconeMetadataValue;
526+
}
527+
}
528+
return {
529+
id: hit._id || '',
530+
content,
531+
score: hit._score || 0,
532+
metadata,
533+
reranked: false,
534+
};
535+
});
536+
537+
logInfo(`Keyword search returned ${documents.length} results from ${this.sparseIndexName}`);
538+
return documents;
539+
}
540+
456541
/**
457542
* Return the number of unique documents matching the query and optional metadata filter.
458543
* Uses semantic search only (dense index), requests minimal fields (document_number, url, doc_id)

src/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { SERVER_INSTRUCTIONS, SERVER_NAME, SERVER_VERSION } from './constants.js
1010
import { registerCountTool } from './server/tools/count-tool.js';
1111
import { registerGuidedQueryTool } from './server/tools/guided-query-tool.js';
1212
import { registerGenerateUrlsTool } from './server/tools/generate-urls-tool.js';
13+
import { registerKeywordSearchTool } from './server/tools/keyword-search-tool.js';
1314
import { registerListNamespacesTool } from './server/tools/list-namespaces-tool.js';
1415
import { registerNamespaceRouterTool } from './server/tools/namespace-router-tool.js';
1516
import { registerQueryDocumentsTool } from './server/tools/query-documents-tool.js';
@@ -38,6 +39,7 @@ export async function setupServer(): Promise<McpServer> {
3839
registerSuggestQueryParamsTool(server);
3940
registerCountTool(server);
4041
registerQueryTool(server);
42+
registerKeywordSearchTool(server);
4143
registerQueryDocumentsTool(server);
4244
registerGuidedQueryTool(server);
4345
registerGenerateUrlsTool(server);

0 commit comments

Comments
 (0)