From c0c93df55f236ef758cb15006d16d94264ab8f6c Mon Sep 17 00:00:00 2001 From: chen Date: Wed, 25 Feb 2026 19:47:53 +0800 Subject: [PATCH 1/8] 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) --- README.md | 75 +++++++++++-- scripts/test-search.ts | 22 ++++ src/constants.ts | 3 + src/index.ts | 26 +++-- src/pinecone-client.ts | 85 +++++++++++++++ src/server.ts | 2 + src/server/tools/keyword-search-tool.ts | 138 ++++++++++++++++++++++++ src/types.ts | 12 +++ 8 files changed, 345 insertions(+), 18 deletions(-) create mode 100644 src/server/tools/keyword-search-tool.ts diff --git a/README.md b/README.md index 668bf24..558d271 100644 --- a/README.md +++ b/README.md @@ -58,12 +58,13 @@ The server requires a Pinecone API key and supports the following configuration ### Environment Variables -| Variable | Required | Default | Description | -| ---------------------------------- | -------- | -------------------- | --------------------- | -| `PINECONE_API_KEY` | Yes | - | Your Pinecone API key | -| `PINECONE_INDEX_NAME` | No | `rag-hybrid` | Pinecone index name | -| `PINECONE_RERANK_MODEL` | No | `bge-reranker-v2-m3` | Reranking model | -| `PINECONE_READ_ONLY_MCP_LOG_LEVEL` | No | `INFO` | Logging level | +| Variable | Required | Default | Description | +| ---------------------------------- | -------- | ---------------------- | ------------------------------------------------ | +| `PINECONE_API_KEY` | Yes | - | Your Pinecone API key | +| `PINECONE_INDEX_NAME` | No | `rag-hybrid` | Pinecone index name (dense + sparse for hybrid) | +| `PINECONE_SPARSE_INDEX_NAME` | No | `pinecone-rag-sparse` | Sparse index for `keyword_search` tool | +| `PINECONE_RERANK_MODEL` | No | `bge-reranker-v2-m3` | Reranking model | +| `PINECONE_READ_ONLY_MCP_LOG_LEVEL` | No | `INFO` | Logging level | ### Claude Desktop Configuration @@ -143,11 +144,12 @@ node node_modules/@will-cppa/pinecone-read-only-mcp/dist/index.js --api-key YOUR ### Available Options ``` ---api-key TEXT Pinecone API key (or set PINECONE_API_KEY env var) ---index-name TEXT Pinecone index name [default: rag-hybrid] ---rerank-model TEXT Reranking model [default: bge-reranker-v2-m3] ---log-level TEXT Logging level [default: INFO] ---help, -h Show help message +--api-key TEXT Pinecone API key (or set PINECONE_API_KEY env var) +--index-name TEXT Pinecone index name [default: rag-hybrid] +--sparse-index-name TEXT Sparse index for keyword_search [default: pinecone-rag-sparse] +--rerank-model TEXT Reranking model [default: bge-reranker-v2-m3] +--log-level TEXT Logging level [default: INFO] +--help, -h Show help message ``` ## Deployment @@ -338,6 +340,45 @@ Returns the **unique document count** matching a metadata filter and semantic qu } ``` +### `keyword_search` + +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. + +**Parameters:** + +| Parameter | Type | Required | Default | Description | +| ----------------- | -------- | -------- | ------- | --------------------------------------------------------------------------- | +| `query_text` | string | Yes | - | Search query text (keyword/lexical match) | +| `namespace` | string | Yes | - | Namespace to search (use `list_namespaces` to discover) | +| `top_k` | integer | No | `10` | Number of results to return (1-100) | +| `metadata_filter` | object | No | - | Optional metadata filter (same operators as `query`) | +| `fields` | string[] | No | - | Optional field names to return; omit for all fields | + +**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`). + +**Example response:** + +```json +{ + "status": "success", + "query": "contracts C++", + "namespace": "wg21-papers", + "index": "pinecone-rag-sparse", + "result_count": 5, + "results": [ + { + "paper_number": "P0548", + "title": "Contracts for C++", + "author": "John Doe", + "url": "https://...", + "content": "...", + "score": 0.85, + "reranked": false + } + ] +} +``` + ### `query` 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 npm test ``` +### Testing the keyword_search tool + +1. **Connectivity and keyword search (script):** + Run the search test script (includes a keyword search step against the sparse index): + ```bash + PINECONE_API_KEY=your-key npm run test:search + ``` + 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. + +2. **Via MCP client:** + 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`). + ### Code Quality ```bash diff --git a/scripts/test-search.ts b/scripts/test-search.ts index b486847..8dbb6ef 100644 --- a/scripts/test-search.ts +++ b/scripts/test-search.ts @@ -152,6 +152,28 @@ async function test() { ); } + // Test 5: Keyword (sparse-only) search on pinecone-rag-sparse + console.log(`\n🔤 Test 5: Keyword search (sparse-only index)`); + console.log(` Namespace: "${testNamespace}"`); + console.log(` Query: "test query"`); + console.log(` Top K: 3`); + try { + const startTime5 = Date.now(); + const results5 = await client.keywordSearch({ + query: 'test query', + namespace: testNamespace, + topK: 3, + }); + const duration5 = Date.now() - startTime5; + console.log(`✅ Keyword search returned ${results5.length} result(s) in ${duration5}ms`); + if (results5.length > 0) { + console.log(` First result score: ${results5[0].score.toFixed(4)}, reranked: ${results5[0].reranked}`); + } + } catch (kwError) { + console.log(`⚠️ Keyword search skipped: ${kwError instanceof Error ? kwError.message : String(kwError)}`); + console.log(` Ensure PINECONE_SPARSE_INDEX_NAME (e.g. pinecone-rag-sparse) exists and has data.`); + } + console.log('\n✨ All tests completed successfully!'); console.log(`\nPerformance comparison:`); console.log(` Without reranking: ${duration1}ms`); diff --git a/src/constants.ts b/src/constants.ts index 0aa7ade..8c0cb55 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,6 +3,8 @@ */ export const DEFAULT_INDEX_NAME = 'rag-hybrid'; +/** Default index name for keyword (sparse-only) search. Used by the keyword_search tool. */ +export const DEFAULT_SPARSE_INDEX_NAME = 'pinecone-rag-sparse'; export const DEFAULT_RERANK_MODEL = 'bge-reranker-v2-m3'; export const DEFAULT_TOP_K = 10; export const MAX_TOP_K = 100; @@ -45,6 +47,7 @@ Features: - 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. - URL Generation: Use generate_urls to synthesize URLs for namespaces that support it when metadata lacks url. - Document reassembly: Use query_documents to get whole documents (chunks grouped and merged by document_number/doc_id/url) for content analysis or summarization. +- Keyword search: Use keyword_search to query the sparse index (e.g. pinecone-rag-sparse) for lexical/keyword-only retrieval without reranking. Usage: 1. Use list_namespaces (cached for 30 minutes) to discover available namespaces in the index diff --git a/src/index.ts b/src/index.ts index 0b9163c..aead501 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { PineconeClient } from './pinecone-client.js'; import { setupServer, setPineconeClient } from './server.js'; -import { DEFAULT_INDEX_NAME, DEFAULT_RERANK_MODEL } from './constants.js'; +import { DEFAULT_INDEX_NAME, DEFAULT_RERANK_MODEL, DEFAULT_SPARSE_INDEX_NAME } from './constants.js'; import type { LogLevel } from './config.js'; import { setLogLevel } from './logger.js'; import * as dotenv from 'dotenv'; @@ -22,6 +22,7 @@ dotenv.config(); interface CLIOptions { apiKey?: string; indexName?: string; + sparseIndexName?: string; rerankModel?: string; logLevel?: string; } @@ -44,6 +45,10 @@ function parseArgs(): CLIOptions { options.indexName = nextArg; i++; break; + case '--sparse-index-name': + options.sparseIndexName = nextArg; + i++; + break; case '--rerank-model': options.rerankModel = nextArg; i++; @@ -71,15 +76,17 @@ Pinecone Read-Only MCP Server Usage: pinecone-read-only-mcp [options] Options: - --api-key TEXT Pinecone API key (or set PINECONE_API_KEY env var) - --index-name TEXT Pinecone index name [default: ${DEFAULT_INDEX_NAME}] - --rerank-model TEXT Reranking model [default: ${DEFAULT_RERANK_MODEL}] - --log-level TEXT Logging level [default: INFO] - --help, -h Show this help message + --api-key TEXT Pinecone API key (or set PINECONE_API_KEY env var) + --index-name TEXT Pinecone index name [default: ${DEFAULT_INDEX_NAME}] + --sparse-index-name TEXT Sparse index for keyword_search [default: ${DEFAULT_SPARSE_INDEX_NAME}] + --rerank-model TEXT Reranking model [default: ${DEFAULT_RERANK_MODEL}] + --log-level TEXT Logging level [default: INFO] + --help, -h Show this help message Environment Variables: PINECONE_API_KEY Pinecone API key PINECONE_INDEX_NAME Pinecone index name + PINECONE_SPARSE_INDEX_NAME Sparse index for keyword_search tool PINECONE_RERANK_MODEL Reranking model name PINECONE_READ_ONLY_MCP_LOG_LEVEL Logging level @@ -119,6 +126,10 @@ async function main(): Promise { // Get configuration const indexName = options.indexName || process.env['PINECONE_INDEX_NAME'] || DEFAULT_INDEX_NAME; + const sparseIndexName = + options.sparseIndexName || + process.env['PINECONE_SPARSE_INDEX_NAME'] || + DEFAULT_SPARSE_INDEX_NAME; const rerankModel = options.rerankModel || process.env['PINECONE_RERANK_MODEL'] || DEFAULT_RERANK_MODEL; @@ -126,12 +137,13 @@ async function main(): Promise { const client = new PineconeClient({ apiKey, indexName, + sparseIndexName, rerankModel, }); setPineconeClient(client); console.error(`Starting Pinecone Read-Only MCP server with stdio transport`); - console.error(`Using Pinecone index: ${indexName}`); + console.error(`Using Pinecone index: ${indexName} (keyword search: ${sparseIndexName})`); console.error(`Log level: ${logLevel}`); // Setup server diff --git a/src/pinecone-client.ts b/src/pinecone-client.ts index 4980880..a77f676 100644 --- a/src/pinecone-client.ts +++ b/src/pinecone-client.ts @@ -20,6 +20,7 @@ import type { QueryParams, CountParams, CountResult, + KeywordSearchParams, MergedHit, NamespaceHandle, SearchableIndex, @@ -28,6 +29,7 @@ import type { import { DEFAULT_INDEX_NAME, DEFAULT_RERANK_MODEL, + DEFAULT_SPARSE_INDEX_NAME, DEFAULT_TOP_K, MAX_TOP_K, COUNT_TOP_K, @@ -55,6 +57,7 @@ function inferMetadataFieldType(value: unknown): string { export class PineconeClient { private apiKey: string; private indexName: string; + private sparseIndexName: string; private rerankModel: string; private defaultTopK: number; @@ -62,12 +65,17 @@ export class PineconeClient { private pc: Pinecone | null = null; private denseIndex: SearchableIndex | null = null; private sparseIndex: SearchableIndex | null = null; + private keywordIndex: SearchableIndex | null = null; private initialized = false; /** Create a client with the given config; env vars override index name, rerank model, and top-k. */ constructor(config: PineconeClientConfig) { this.apiKey = config.apiKey; this.indexName = config.indexName || process.env['PINECONE_INDEX_NAME'] || DEFAULT_INDEX_NAME; + this.sparseIndexName = + config.sparseIndexName || + process.env['PINECONE_SPARSE_INDEX_NAME'] || + DEFAULT_SPARSE_INDEX_NAME; this.rerankModel = config.rerankModel || process.env['PINECONE_RERANK_MODEL'] || DEFAULT_RERANK_MODEL; this.defaultTopK = @@ -115,6 +123,21 @@ export class PineconeClient { return { denseIndex: dense, sparseIndex: sparse }; } + /** + * Ensure the dedicated keyword (sparse-only) index is initialized. + * Used by the keyword_search tool to query pinecone-rag-sparse (or configured name). + */ + private async ensureKeywordIndex(): Promise { + if (this.keywordIndex !== null) { + return this.keywordIndex; + } + const pc = this.ensureClient(); + const index = pc.index(this.sparseIndexName) as unknown as SearchableIndex; + this.keywordIndex = index; + logInfo(`Keyword search index: ${this.sparseIndexName}`); + return index; + } + /** * List all available namespaces with their metadata information * @@ -453,6 +476,68 @@ export class PineconeClient { return documents; } + /** + * Keyword (sparse-only) search against the dedicated sparse index. + * Performs lexical/keyword retrieval only—no dense index, no reranking. + * Use for exact or keyword-style queries on the pinecone-rag-sparse index. + */ + async keywordSearch(params: KeywordSearchParams): Promise { + const { + query, + namespace, + topK: requestedTopK, + metadataFilter, + fields: requestedFields, + } = params; + + if (!query || !query.trim()) { + throw new Error('Query cannot be empty'); + } + + let topK = requestedTopK !== undefined ? requestedTopK : this.defaultTopK; + if (topK < 1) { + throw new Error('topK must be at least 1'); + } + if (topK > MAX_TOP_K) { + topK = MAX_TOP_K; + } + + const keywordIndex = await this.ensureKeywordIndex(); + const searchOptions = requestedFields?.length ? { fields: requestedFields } : undefined; + + const hits = await this.searchIndex( + keywordIndex, + query.trim(), + topK, + namespace, + metadataFilter, + searchOptions + ); + + const documents: SearchResult[] = hits.map((hit) => { + const fields = hit.fields || {}; + let content = ''; + const metadata: Record = {}; + for (const [key, value] of Object.entries(fields)) { + if (key === 'chunk_text') { + content = typeof value === 'string' ? value : ''; + } else { + metadata[key] = value as PineconeMetadataValue; + } + } + return { + id: hit._id || '', + content, + score: hit._score || 0, + metadata, + reranked: false, + }; + }); + + logInfo(`Keyword search returned ${documents.length} results from ${this.sparseIndexName}`); + return documents; + } + /** * Return the number of unique documents matching the query and optional metadata filter. * Uses semantic search only (dense index), requests minimal fields (document_number, url, doc_id) diff --git a/src/server.ts b/src/server.ts index a9beb82..3459677 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,6 +10,7 @@ import { SERVER_INSTRUCTIONS, SERVER_NAME, SERVER_VERSION } from './constants.js import { registerCountTool } from './server/tools/count-tool.js'; import { registerGuidedQueryTool } from './server/tools/guided-query-tool.js'; import { registerGenerateUrlsTool } from './server/tools/generate-urls-tool.js'; +import { registerKeywordSearchTool } from './server/tools/keyword-search-tool.js'; import { registerListNamespacesTool } from './server/tools/list-namespaces-tool.js'; import { registerNamespaceRouterTool } from './server/tools/namespace-router-tool.js'; import { registerQueryDocumentsTool } from './server/tools/query-documents-tool.js'; @@ -38,6 +39,7 @@ export async function setupServer(): Promise { registerSuggestQueryParamsTool(server); registerCountTool(server); registerQueryTool(server); + registerKeywordSearchTool(server); registerQueryDocumentsTool(server); registerGuidedQueryTool(server); registerGenerateUrlsTool(server); diff --git a/src/server/tools/keyword-search-tool.ts b/src/server/tools/keyword-search-tool.ts new file mode 100644 index 0000000..cce809b --- /dev/null +++ b/src/server/tools/keyword-search-tool.ts @@ -0,0 +1,138 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { DEFAULT_SPARSE_INDEX_NAME, MAX_TOP_K, MIN_TOP_K } from '../../constants.js'; +import { getPineconeClient } from '../client-context.js'; +import { formatQueryResultRows } from '../format-query-result.js'; +import { metadataFilterSchema, validateMetadataFilter } from '../metadata-filter.js'; +import { getToolErrorMessage, logToolError } from '../tool-error.js'; +import { jsonErrorResponse, jsonResponse } from '../tool-response.js'; + +/** Response shape for keyword_search: same as query tool for consistency. */ +export interface KeywordSearchResponse { + status: 'success' | 'error'; + query?: string; + namespace?: string; + index?: string; + metadata_filter?: Record; + result_count?: number; + results?: Array<{ + paper_number: string | null; + title: string; + author: string; + url: string; + content: string; + score: number; + reranked: boolean; + metadata?: Record; + }>; + fields?: string[]; + message?: string; +} + +async function executeKeywordSearch(params: { + query_text: string; + namespace: string; + top_k: number; + metadata_filter?: Record; + fields?: string[]; +}): Promise { + const { query_text, namespace, top_k, metadata_filter, fields } = params; + + if (!query_text.trim()) { + return { + status: 'error', + message: 'Query text cannot be empty', + }; + } + + if (metadata_filter) { + const filterError = validateMetadataFilter(metadata_filter); + if (filterError) { + return { status: 'error', message: filterError }; + } + } + + const client = getPineconeClient(); + const results = await client.keywordSearch({ + query: query_text.trim(), + namespace, + topK: top_k, + metadataFilter: metadata_filter, + fields: fields?.length ? fields : undefined, + }); + + const formattedResults = formatQueryResultRows(results, { namespace }); + + const response: KeywordSearchResponse = { + status: 'success', + query: query_text, + namespace, + index: DEFAULT_SPARSE_INDEX_NAME, + metadata_filter: metadata_filter, + result_count: formattedResults.length, + results: formattedResults, + }; + if (fields?.length) { + response.fields = fields; + } + return response; +} + +/** Register the keyword_search tool on the MCP server. */ +export function registerKeywordSearchTool(server: McpServer): void { + server.registerTool( + 'keyword_search', + { + description: + 'Keyword (lexical/sparse-only) search over the Pinecone sparse index (e.g. pinecone-rag-sparse). ' + + 'Use for exact or keyword-style queries. Does not use semantic reranking. ' + + 'Call list_namespaces first to discover namespaces; suggest_query_params is optional.', + inputSchema: { + query_text: z.string().describe('Search query text (keyword/lexical match).'), + namespace: z + .string() + .describe( + 'Namespace to search. Use list_namespaces to discover available namespaces.' + ), + top_k: z + .number() + .int() + .min(MIN_TOP_K) + .max(MAX_TOP_K) + .default(10) + .describe('Number of results to return (1-100). Default: 10'), + metadata_filter: metadataFilterSchema + .optional() + .describe('Optional metadata filter to narrow results.'), + fields: z + .array(z.string()) + .optional() + .describe( + 'Optional field names to return. Omit for all fields; use suggest_query_params for suggestions.' + ), + }, + }, + async (params) => { + try { + const response = await executeKeywordSearch({ + query_text: params.query_text, + namespace: params.namespace, + top_k: params.top_k ?? 10, + metadata_filter: params.metadata_filter, + fields: params.fields, + }); + if (response.status === 'error') { + return jsonErrorResponse(response); + } + return jsonResponse(response); + } catch (error) { + logToolError('keyword_search', error); + const response: KeywordSearchResponse = { + status: 'error', + message: getToolErrorMessage(error, 'Keyword search failed'), + }; + return jsonErrorResponse(response); + } + } + ); +} diff --git a/src/types.ts b/src/types.ts index 485e8a1..90e6d8d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,6 +8,8 @@ export type PineconeMetadataValue = string | number | boolean | string[]; export interface PineconeClientConfig { apiKey: string; indexName?: string; + /** Dedicated sparse index for keyword_search tool (e.g. pinecone-rag-sparse). */ + sparseIndexName?: string; rerankModel?: string; defaultTopK?: number; } @@ -53,6 +55,16 @@ export interface CountParams { metadataFilter?: Record; } +/** Parameters for keyword (sparse-only) search against the dedicated sparse index. */ +export interface KeywordSearchParams { + query: string; + namespace: string; + topK?: number; + metadataFilter?: Record; + /** If set, only these fields are returned. Omit for all fields. */ + fields?: string[]; +} + /** Result of a count request: unique document count (deduped by doc id/url); truncated when at least COUNT_TOP_K. */ export interface CountResult { count: number; From 5b97159a7c490bdaaba6d7d5f749a4cbce84aafb Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 26 Feb 2026 19:08:26 +0800 Subject: [PATCH 2/8] update 2026-02-26 19-08-26 Made-with: Cursor --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 558d271..e54be71 100644 --- a/README.md +++ b/README.md @@ -511,7 +511,7 @@ npm run build npm test ``` -### Testing the keyword_search tool ++### Testing the keyword_search tool 1. **Connectivity and keyword search (script):** Run the search test script (includes a keyword search step against the sparse index): From 423dda287f039060e1db349eac3c9b52ecbb22f2 Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 26 Feb 2026 19:46:51 +0800 Subject: [PATCH 3/8] fix: address CodeRabbit feedback (Prettier, response.index, clampTopK, test summary) fix: address CodeRabbit feedback for keyword_search and tooling - Prettier: format keyword-search-tool.ts and index.ts - response.index: add getSparseIndexName() on PineconeClient; use in keyword_search - pinecone-client: extract clampTopK(), use in query() and keywordSearch() - keyword-search-tool: remove redundant top_k ?? 10 (Zod default) - test-search: add Test 5 timing to performance summary (duration5 / skipped) --- scripts/test-search.ts | 22 ++++++++++++--- src/index.ts | 6 ++++- src/pinecone-client.ts | 36 ++++++++++++++----------- src/server/tools/keyword-search-tool.ts | 10 +++---- 4 files changed, 48 insertions(+), 26 deletions(-) diff --git a/scripts/test-search.ts b/scripts/test-search.ts index 8dbb6ef..9f07519 100644 --- a/scripts/test-search.ts +++ b/scripts/test-search.ts @@ -153,6 +153,8 @@ async function test() { } // Test 5: Keyword (sparse-only) search on pinecone-rag-sparse + let duration5: number | undefined; + let test5Skipped = false; console.log(`\n🔤 Test 5: Keyword search (sparse-only index)`); console.log(` Namespace: "${testNamespace}"`); console.log(` Query: "test query"`); @@ -164,14 +166,21 @@ async function test() { namespace: testNamespace, topK: 3, }); - const duration5 = Date.now() - startTime5; + duration5 = Date.now() - startTime5; console.log(`✅ Keyword search returned ${results5.length} result(s) in ${duration5}ms`); if (results5.length > 0) { - console.log(` First result score: ${results5[0].score.toFixed(4)}, reranked: ${results5[0].reranked}`); + console.log( + ` First result score: ${results5[0].score.toFixed(4)}, reranked: ${results5[0].reranked}` + ); } } catch (kwError) { - console.log(`⚠️ Keyword search skipped: ${kwError instanceof Error ? kwError.message : String(kwError)}`); - console.log(` Ensure PINECONE_SPARSE_INDEX_NAME (e.g. pinecone-rag-sparse) exists and has data.`); + test5Skipped = true; + console.log( + `⚠️ Keyword search skipped: ${kwError instanceof Error ? kwError.message : String(kwError)}` + ); + console.log( + ` Ensure PINECONE_SPARSE_INDEX_NAME (e.g. pinecone-rag-sparse) exists and has data.` + ); } console.log('\n✨ All tests completed successfully!'); @@ -181,6 +190,11 @@ async function test() { if (duration3 !== undefined) { console.log(` With metadata filter: ${duration3}ms`); } + if (duration5 !== undefined) { + console.log(` Keyword search: ${duration5}ms`); + } else if (test5Skipped) { + console.log(` Keyword search: skipped`); + } console.log(` Reranking overhead: ${duration2 - duration1}ms`); } catch (error) { console.error('\n❌ Error during testing:', error); diff --git a/src/index.ts b/src/index.ts index aead501..2b498b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,11 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { PineconeClient } from './pinecone-client.js'; import { setupServer, setPineconeClient } from './server.js'; -import { DEFAULT_INDEX_NAME, DEFAULT_RERANK_MODEL, DEFAULT_SPARSE_INDEX_NAME } from './constants.js'; +import { + DEFAULT_INDEX_NAME, + DEFAULT_RERANK_MODEL, + DEFAULT_SPARSE_INDEX_NAME, +} from './constants.js'; import type { LogLevel } from './config.js'; import { setLogLevel } from './logger.js'; import * as dotenv from 'dotenv'; diff --git a/src/pinecone-client.ts b/src/pinecone-client.ts index a77f676..a76e5a8 100644 --- a/src/pinecone-client.ts +++ b/src/pinecone-client.ts @@ -82,6 +82,25 @@ export class PineconeClient { config.defaultTopK || parseInt(process.env['PINECONE_TOP_K'] || String(DEFAULT_TOP_K)); } + /** Returns the configured sparse index name used for keyword search (runtime value). */ + getSparseIndexName(): string { + return this.sparseIndexName; + } + + /** + * Normalize and clamp topK from request (validates >= 1, caps at MAX_TOP_K). + */ + private clampTopK(requested: number | undefined): number { + let topK = requested !== undefined ? requested : this.defaultTopK; + if (topK < 1) { + throw new Error('topK must be at least 1'); + } + if (topK > MAX_TOP_K) { + topK = MAX_TOP_K; + } + return topK; + } + /** * Ensure Pinecone client is initialized */ @@ -413,14 +432,7 @@ export class PineconeClient { throw new Error('Query cannot be empty'); } - let topK = requestedTopK !== undefined ? requestedTopK : this.defaultTopK; - if (topK < 1) { - throw new Error('topK must be at least 1'); - } - - if (topK > MAX_TOP_K) { - topK = MAX_TOP_K; - } + const topK = this.clampTopK(requestedTopK); // When reranking, Pinecone requires chunk_text in returned fields; add it if user specified fields without it const searchFields = @@ -494,13 +506,7 @@ export class PineconeClient { throw new Error('Query cannot be empty'); } - let topK = requestedTopK !== undefined ? requestedTopK : this.defaultTopK; - if (topK < 1) { - throw new Error('topK must be at least 1'); - } - if (topK > MAX_TOP_K) { - topK = MAX_TOP_K; - } + const topK = this.clampTopK(requestedTopK); const keywordIndex = await this.ensureKeywordIndex(); const searchOptions = requestedFields?.length ? { fields: requestedFields } : undefined; diff --git a/src/server/tools/keyword-search-tool.ts b/src/server/tools/keyword-search-tool.ts index cce809b..06ef9ee 100644 --- a/src/server/tools/keyword-search-tool.ts +++ b/src/server/tools/keyword-search-tool.ts @@ -1,6 +1,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; -import { DEFAULT_SPARSE_INDEX_NAME, MAX_TOP_K, MIN_TOP_K } from '../../constants.js'; +import { MAX_TOP_K, MIN_TOP_K } from '../../constants.js'; import { getPineconeClient } from '../client-context.js'; import { formatQueryResultRows } from '../format-query-result.js'; import { metadataFilterSchema, validateMetadataFilter } from '../metadata-filter.js'; @@ -67,7 +67,7 @@ async function executeKeywordSearch(params: { status: 'success', query: query_text, namespace, - index: DEFAULT_SPARSE_INDEX_NAME, + index: client.getSparseIndexName(), metadata_filter: metadata_filter, result_count: formattedResults.length, results: formattedResults, @@ -91,9 +91,7 @@ export function registerKeywordSearchTool(server: McpServer): void { query_text: z.string().describe('Search query text (keyword/lexical match).'), namespace: z .string() - .describe( - 'Namespace to search. Use list_namespaces to discover available namespaces.' - ), + .describe('Namespace to search. Use list_namespaces to discover available namespaces.'), top_k: z .number() .int() @@ -117,7 +115,7 @@ export function registerKeywordSearchTool(server: McpServer): void { const response = await executeKeywordSearch({ query_text: params.query_text, namespace: params.namespace, - top_k: params.top_k ?? 10, + top_k: params.top_k, metadata_filter: params.metadata_filter, fields: params.fields, }); From 645737670c5f4d07738137511103c1cf3b7a99ef Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 26 Feb 2026 19:49:06 +0800 Subject: [PATCH 4/8] Revert "update 2026-02-26 19-08-26" This reverts commit 5b97159a7c490bdaaba6d7d5f749a4cbce84aafb. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e54be71..558d271 100644 --- a/README.md +++ b/README.md @@ -511,7 +511,7 @@ npm run build npm test ``` -+### Testing the keyword_search tool +### Testing the keyword_search tool 1. **Connectivity and keyword search (script):** Run the search test script (includes a keyword search step against the sparse index): From 54d214d5d103116bc3708130e1b2a346f7faf123 Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 26 Feb 2026 20:21:13 +0800 Subject: [PATCH 5/8] fix: use sparse index namespaces for Test 5 keyword search fix: use sparse index namespaces for Test 5 keyword search - Add PineconeClient.listNamespacesFromKeywordIndex() to list namespaces from the keyword (sparse) index - Test 5 now uses a namespace from the sparse index for keywordSearch; skip with clear message if sparse index has no namespaces - Avoids using dense-derived testNamespace for sparse-only query (CodeRabbit feedback) --- scripts/test-search.ts | 57 ++++++++++++++++++++++++++---------------- src/pinecone-client.ts | 23 +++++++++++++++++ 2 files changed, 58 insertions(+), 22 deletions(-) diff --git a/scripts/test-search.ts b/scripts/test-search.ts index 9f07519..3b92a1c 100644 --- a/scripts/test-search.ts +++ b/scripts/test-search.ts @@ -152,35 +152,48 @@ async function test() { ); } - // Test 5: Keyword (sparse-only) search on pinecone-rag-sparse + // Test 5: Keyword (sparse-only) search — use namespace from sparse index, not dense let duration5: number | undefined; let test5Skipped = false; - console.log(`\n🔤 Test 5: Keyword search (sparse-only index)`); - console.log(` Namespace: "${testNamespace}"`); - console.log(` Query: "test query"`); - console.log(` Top K: 3`); - try { - const startTime5 = Date.now(); - const results5 = await client.keywordSearch({ - query: 'test query', - namespace: testNamespace, - topK: 3, - }); - duration5 = Date.now() - startTime5; - console.log(`✅ Keyword search returned ${results5.length} result(s) in ${duration5}ms`); - if (results5.length > 0) { - console.log( - ` First result score: ${results5[0].score.toFixed(4)}, reranked: ${results5[0].reranked}` - ); - } - } catch (kwError) { + const sparseNamespaces = await client.listNamespacesFromKeywordIndex(); + if (sparseNamespaces.length === 0) { test5Skipped = true; + console.log(`\n🔤 Test 5: Keyword search (sparse-only index)`); console.log( - `⚠️ Keyword search skipped: ${kwError instanceof Error ? kwError.message : String(kwError)}` + `⚠️ Keyword search skipped: sparse index has no namespaces (or index unavailable).` ); console.log( - ` Ensure PINECONE_SPARSE_INDEX_NAME (e.g. pinecone-rag-sparse) exists and has data.` + ` Ensure PINECONE_SPARSE_INDEX_NAME (e.g. pinecone-rag-sparse or rag-hybrid-sparse) exists and has data.` ); + } else { + const sparseTestNamespace = sparseNamespaces[0].namespace; + console.log(`\n🔤 Test 5: Keyword search (sparse-only index)`); + console.log(` Namespace: "${sparseTestNamespace}" (from sparse index)`); + console.log(` Query: "test query"`); + console.log(` Top K: 3`); + try { + const startTime5 = Date.now(); + const results5 = await client.keywordSearch({ + query: 'test query', + namespace: sparseTestNamespace, + topK: 3, + }); + duration5 = Date.now() - startTime5; + console.log(`✅ Keyword search returned ${results5.length} result(s) in ${duration5}ms`); + if (results5.length > 0) { + console.log( + ` First result score: ${results5[0].score.toFixed(4)}, reranked: ${results5[0].reranked}` + ); + } + } catch (kwError) { + test5Skipped = true; + console.log( + `⚠️ Keyword search skipped: ${kwError instanceof Error ? kwError.message : String(kwError)}` + ); + console.log( + ` Ensure PINECONE_SPARSE_INDEX_NAME exists and namespace "${sparseTestNamespace}" has data.` + ); + } } console.log('\n✨ All tests completed successfully!'); diff --git a/src/pinecone-client.ts b/src/pinecone-client.ts index a76e5a8..ecefb45 100644 --- a/src/pinecone-client.ts +++ b/src/pinecone-client.ts @@ -157,6 +157,29 @@ export class PineconeClient { return index; } + /** + * List namespaces present on the keyword (sparse) index used for keyword_search. + * Use this to choose a namespace for sparse-only queries instead of the dense index list. + */ + async listNamespacesFromKeywordIndex(): Promise< + Array<{ namespace: string; recordCount: number }> + > { + try { + const keywordIndex = await this.ensureKeywordIndex(); + const stats = keywordIndex.describeIndexStats + ? await keywordIndex.describeIndexStats() + : undefined; + const namespaces = stats?.namespaces ?? {}; + return Object.entries(namespaces).map(([namespace, info]) => ({ + namespace, + recordCount: info?.recordCount ?? 0, + })); + } catch (error) { + logError('Error listing namespaces from keyword index', error); + return []; + } + } + /** * List all available namespaces with their metadata information * From 2609e7bd10ad3f48e1b79284a29fad82d940f0ca Mon Sep 17 00:00:00 2001 From: chen Date: Fri, 6 Mar 2026 22:14:01 +0800 Subject: [PATCH 6/8] refactor: use single sparse index for hybrid and keyword search (reviewer feedback) refactor: use single sparse index for hybrid and keyword search - Remove keywordIndex; use sparseIndex from ensureIndexes() for keywordSearch and listNamespacesFromKeywordIndex - Remove sparseIndexName config (PINECONE_SPARSE_INDEX_NAME, --sparse-index-name) and DEFAULT_SPARSE_INDEX_NAME - getSparseIndexName() now returns ${indexName}-sparse. Addresses reviewer: sparse_index and keyword_index are the same index --- README.md | 8 ++--- scripts/test-search.ts | 4 +-- src/constants.ts | 4 +-- src/index.ts | 24 +++----------- src/pinecone-client.ts | 42 ++++++------------------- src/server/tools/keyword-search-tool.ts | 2 +- src/types.ts | 2 -- 7 files changed, 21 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 558d271..c5169c4 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,6 @@ The server requires a Pinecone API key and supports the following configuration | ---------------------------------- | -------- | ---------------------- | ------------------------------------------------ | | `PINECONE_API_KEY` | Yes | - | Your Pinecone API key | | `PINECONE_INDEX_NAME` | No | `rag-hybrid` | Pinecone index name (dense + sparse for hybrid) | -| `PINECONE_SPARSE_INDEX_NAME` | No | `pinecone-rag-sparse` | Sparse index for `keyword_search` tool | | `PINECONE_RERANK_MODEL` | No | `bge-reranker-v2-m3` | Reranking model | | `PINECONE_READ_ONLY_MCP_LOG_LEVEL` | No | `INFO` | Logging level | @@ -146,7 +145,6 @@ node node_modules/@will-cppa/pinecone-read-only-mcp/dist/index.js --api-key YOUR ``` --api-key TEXT Pinecone API key (or set PINECONE_API_KEY env var) --index-name TEXT Pinecone index name [default: rag-hybrid] ---sparse-index-name TEXT Sparse index for keyword_search [default: pinecone-rag-sparse] --rerank-model TEXT Reranking model [default: bge-reranker-v2-m3] --log-level TEXT Logging level [default: INFO] --help, -h Show help message @@ -342,7 +340,7 @@ Returns the **unique document count** matching a metadata filter and semantic qu ### `keyword_search` -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. +Performs **keyword (lexical/sparse-only)** search over the dedicated sparse index (default: `rag-hybrid-sparse`, i.e. `{PINECONE_INDEX_NAME}-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. **Parameters:** @@ -363,7 +361,7 @@ Performs **keyword (lexical/sparse-only)** search over the dedicated sparse inde "status": "success", "query": "contracts C++", "namespace": "wg21-papers", - "index": "pinecone-rag-sparse", + "index": "rag-hybrid-sparse", "result_count": 5, "results": [ { @@ -518,7 +516,7 @@ npm test ```bash PINECONE_API_KEY=your-key npm run test:search ``` - 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. + If the sparse index (`rag-hybrid-sparse` by default) does not exist or has no data, the keyword search step is skipped with a warning. 2. **Via MCP client:** 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`). diff --git a/scripts/test-search.ts b/scripts/test-search.ts index 3b92a1c..8623369 100644 --- a/scripts/test-search.ts +++ b/scripts/test-search.ts @@ -163,7 +163,7 @@ async function test() { `⚠️ Keyword search skipped: sparse index has no namespaces (or index unavailable).` ); console.log( - ` Ensure PINECONE_SPARSE_INDEX_NAME (e.g. pinecone-rag-sparse or rag-hybrid-sparse) exists and has data.` + ` Ensure the sparse index (e.g. rag-hybrid-sparse) exists and has data.` ); } else { const sparseTestNamespace = sparseNamespaces[0].namespace; @@ -191,7 +191,7 @@ async function test() { `⚠️ Keyword search skipped: ${kwError instanceof Error ? kwError.message : String(kwError)}` ); console.log( - ` Ensure PINECONE_SPARSE_INDEX_NAME exists and namespace "${sparseTestNamespace}" has data.` + ` Ensure the sparse index exists and namespace "${sparseTestNamespace}" has data.` ); } } diff --git a/src/constants.ts b/src/constants.ts index 8c0cb55..01122ab 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,8 +3,6 @@ */ export const DEFAULT_INDEX_NAME = 'rag-hybrid'; -/** Default index name for keyword (sparse-only) search. Used by the keyword_search tool. */ -export const DEFAULT_SPARSE_INDEX_NAME = 'pinecone-rag-sparse'; export const DEFAULT_RERANK_MODEL = 'bge-reranker-v2-m3'; export const DEFAULT_TOP_K = 10; export const MAX_TOP_K = 100; @@ -47,7 +45,7 @@ Features: - 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. - URL Generation: Use generate_urls to synthesize URLs for namespaces that support it when metadata lacks url. - Document reassembly: Use query_documents to get whole documents (chunks grouped and merged by document_number/doc_id/url) for content analysis or summarization. -- Keyword search: Use keyword_search to query the sparse index (e.g. pinecone-rag-sparse) for lexical/keyword-only retrieval without reranking. +- Keyword search: Use keyword_search to query the sparse index (default: rag-hybrid-sparse) for lexical/keyword-only retrieval without reranking. Usage: 1. Use list_namespaces (cached for 30 minutes) to discover available namespaces in the index diff --git a/src/index.ts b/src/index.ts index 2b498b0..959a7fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,11 +11,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { PineconeClient } from './pinecone-client.js'; import { setupServer, setPineconeClient } from './server.js'; -import { - DEFAULT_INDEX_NAME, - DEFAULT_RERANK_MODEL, - DEFAULT_SPARSE_INDEX_NAME, -} from './constants.js'; +import { DEFAULT_INDEX_NAME, DEFAULT_RERANK_MODEL } from './constants.js'; import type { LogLevel } from './config.js'; import { setLogLevel } from './logger.js'; import * as dotenv from 'dotenv'; @@ -26,7 +22,6 @@ dotenv.config(); interface CLIOptions { apiKey?: string; indexName?: string; - sparseIndexName?: string; rerankModel?: string; logLevel?: string; } @@ -49,10 +44,6 @@ function parseArgs(): CLIOptions { options.indexName = nextArg; i++; break; - case '--sparse-index-name': - options.sparseIndexName = nextArg; - i++; - break; case '--rerank-model': options.rerankModel = nextArg; i++; @@ -81,16 +72,14 @@ Usage: pinecone-read-only-mcp [options] Options: --api-key TEXT Pinecone API key (or set PINECONE_API_KEY env var) - --index-name TEXT Pinecone index name [default: ${DEFAULT_INDEX_NAME}] - --sparse-index-name TEXT Sparse index for keyword_search [default: ${DEFAULT_SPARSE_INDEX_NAME}] + --index-name TEXT Pinecone index name [default: ${DEFAULT_INDEX_NAME}]; sparse index is {index-name}-sparse --rerank-model TEXT Reranking model [default: ${DEFAULT_RERANK_MODEL}] --log-level TEXT Logging level [default: INFO] --help, -h Show this help message Environment Variables: PINECONE_API_KEY Pinecone API key - PINECONE_INDEX_NAME Pinecone index name - PINECONE_SPARSE_INDEX_NAME Sparse index for keyword_search tool + PINECONE_INDEX_NAME Pinecone index name (sparse index: {name}-sparse) PINECONE_RERANK_MODEL Reranking model name PINECONE_READ_ONLY_MCP_LOG_LEVEL Logging level @@ -130,10 +119,6 @@ async function main(): Promise { // Get configuration const indexName = options.indexName || process.env['PINECONE_INDEX_NAME'] || DEFAULT_INDEX_NAME; - const sparseIndexName = - options.sparseIndexName || - process.env['PINECONE_SPARSE_INDEX_NAME'] || - DEFAULT_SPARSE_INDEX_NAME; const rerankModel = options.rerankModel || process.env['PINECONE_RERANK_MODEL'] || DEFAULT_RERANK_MODEL; @@ -141,13 +126,12 @@ async function main(): Promise { const client = new PineconeClient({ apiKey, indexName, - sparseIndexName, rerankModel, }); setPineconeClient(client); console.error(`Starting Pinecone Read-Only MCP server with stdio transport`); - console.error(`Using Pinecone index: ${indexName} (keyword search: ${sparseIndexName})`); + console.error(`Using Pinecone index: ${indexName} (sparse: ${indexName}-sparse)`); console.error(`Log level: ${logLevel}`); // Setup server diff --git a/src/pinecone-client.ts b/src/pinecone-client.ts index ecefb45..c4fbc9c 100644 --- a/src/pinecone-client.ts +++ b/src/pinecone-client.ts @@ -29,7 +29,6 @@ import type { import { DEFAULT_INDEX_NAME, DEFAULT_RERANK_MODEL, - DEFAULT_SPARSE_INDEX_NAME, DEFAULT_TOP_K, MAX_TOP_K, COUNT_TOP_K, @@ -57,7 +56,6 @@ function inferMetadataFieldType(value: unknown): string { export class PineconeClient { private apiKey: string; private indexName: string; - private sparseIndexName: string; private rerankModel: string; private defaultTopK: number; @@ -65,26 +63,21 @@ export class PineconeClient { private pc: Pinecone | null = null; private denseIndex: SearchableIndex | null = null; private sparseIndex: SearchableIndex | null = null; - private keywordIndex: SearchableIndex | null = null; private initialized = false; /** Create a client with the given config; env vars override index name, rerank model, and top-k. */ constructor(config: PineconeClientConfig) { this.apiKey = config.apiKey; this.indexName = config.indexName || process.env['PINECONE_INDEX_NAME'] || DEFAULT_INDEX_NAME; - this.sparseIndexName = - config.sparseIndexName || - process.env['PINECONE_SPARSE_INDEX_NAME'] || - DEFAULT_SPARSE_INDEX_NAME; this.rerankModel = config.rerankModel || process.env['PINECONE_RERANK_MODEL'] || DEFAULT_RERANK_MODEL; this.defaultTopK = config.defaultTopK || parseInt(process.env['PINECONE_TOP_K'] || String(DEFAULT_TOP_K)); } - /** Returns the configured sparse index name used for keyword search (runtime value). */ + /** Returns the sparse index name (same as hybrid sparse: {indexName}-sparse). Used for keyword_search response. */ getSparseIndexName(): string { - return this.sparseIndexName; + return `${this.indexName}-sparse`; } /** @@ -143,31 +136,16 @@ export class PineconeClient { } /** - * Ensure the dedicated keyword (sparse-only) index is initialized. - * Used by the keyword_search tool to query pinecone-rag-sparse (or configured name). - */ - private async ensureKeywordIndex(): Promise { - if (this.keywordIndex !== null) { - return this.keywordIndex; - } - const pc = this.ensureClient(); - const index = pc.index(this.sparseIndexName) as unknown as SearchableIndex; - this.keywordIndex = index; - logInfo(`Keyword search index: ${this.sparseIndexName}`); - return index; - } - - /** - * List namespaces present on the keyword (sparse) index used for keyword_search. + * List namespaces present on the sparse index (same index used for hybrid sparse and keyword_search). * Use this to choose a namespace for sparse-only queries instead of the dense index list. */ async listNamespacesFromKeywordIndex(): Promise< Array<{ namespace: string; recordCount: number }> > { try { - const keywordIndex = await this.ensureKeywordIndex(); - const stats = keywordIndex.describeIndexStats - ? await keywordIndex.describeIndexStats() + const { sparseIndex } = await this.ensureIndexes(); + const stats = sparseIndex.describeIndexStats + ? await sparseIndex.describeIndexStats() : undefined; const namespaces = stats?.namespaces ?? {}; return Object.entries(namespaces).map(([namespace, info]) => ({ @@ -514,7 +492,7 @@ export class PineconeClient { /** * Keyword (sparse-only) search against the dedicated sparse index. * Performs lexical/keyword retrieval only—no dense index, no reranking. - * Use for exact or keyword-style queries on the pinecone-rag-sparse index. + * Use for exact or keyword-style queries on the configured sparse index. */ async keywordSearch(params: KeywordSearchParams): Promise { const { @@ -531,11 +509,11 @@ export class PineconeClient { const topK = this.clampTopK(requestedTopK); - const keywordIndex = await this.ensureKeywordIndex(); + const { sparseIndex } = await this.ensureIndexes(); const searchOptions = requestedFields?.length ? { fields: requestedFields } : undefined; const hits = await this.searchIndex( - keywordIndex, + sparseIndex, query.trim(), topK, namespace, @@ -563,7 +541,7 @@ export class PineconeClient { }; }); - logInfo(`Keyword search returned ${documents.length} results from ${this.sparseIndexName}`); + logInfo(`Keyword search returned ${documents.length} results from ${this.getSparseIndexName()}`); return documents; } diff --git a/src/server/tools/keyword-search-tool.ts b/src/server/tools/keyword-search-tool.ts index 06ef9ee..4271645 100644 --- a/src/server/tools/keyword-search-tool.ts +++ b/src/server/tools/keyword-search-tool.ts @@ -84,7 +84,7 @@ export function registerKeywordSearchTool(server: McpServer): void { 'keyword_search', { description: - 'Keyword (lexical/sparse-only) search over the Pinecone sparse index (e.g. pinecone-rag-sparse). ' + + 'Keyword (lexical/sparse-only) search over the Pinecone sparse index (default: rag-hybrid-sparse). ' + 'Use for exact or keyword-style queries. Does not use semantic reranking. ' + 'Call list_namespaces first to discover namespaces; suggest_query_params is optional.', inputSchema: { diff --git a/src/types.ts b/src/types.ts index 90e6d8d..b737265 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,8 +8,6 @@ export type PineconeMetadataValue = string | number | boolean | string[]; export interface PineconeClientConfig { apiKey: string; indexName?: string; - /** Dedicated sparse index for keyword_search tool (e.g. pinecone-rag-sparse). */ - sparseIndexName?: string; rerankModel?: string; defaultTopK?: number; } From e44291ee0edf0ee83e05db789aa4b06ba94ef375 Mon Sep 17 00:00:00 2001 From: chen Date: Fri, 6 Mar 2026 22:24:20 +0800 Subject: [PATCH 7/8] format test --- src/pinecone-client.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pinecone-client.ts b/src/pinecone-client.ts index c4fbc9c..461f6b8 100644 --- a/src/pinecone-client.ts +++ b/src/pinecone-client.ts @@ -541,7 +541,9 @@ export class PineconeClient { }; }); - logInfo(`Keyword search returned ${documents.length} results from ${this.getSparseIndexName()}`); + logInfo( + `Keyword search returned ${documents.length} results from ${this.getSparseIndexName()}` + ); return documents; } From 00a5abd6a5861eb37206a07fc6e4402f361faa50 Mon Sep 17 00:00:00 2001 From: chen Date: Mon, 9 Mar 2026 10:24:51 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20reviewer=20feedback=20=E2=80=93=20sp?= =?UTF-8?q?arse=20index=20default,=20single=20sparse=20ref,=20clampTopK,?= =?UTF-8?q?=20keyword=5Fsearch=20normalization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pinecone-client.ts | 5 ++++- src/server/tools/keyword-search-tool.ts | 24 ++++++++++++++++++------ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/pinecone-client.ts b/src/pinecone-client.ts index 461f6b8..4b7b1ef 100644 --- a/src/pinecone-client.ts +++ b/src/pinecone-client.ts @@ -84,6 +84,9 @@ export class PineconeClient { * Normalize and clamp topK from request (validates >= 1, caps at MAX_TOP_K). */ private clampTopK(requested: number | undefined): number { + if (requested !== undefined && !Number.isFinite(requested)) { + throw new Error('topK must be a finite number >= 1'); + } let topK = requested !== undefined ? requested : this.defaultTopK; if (topK < 1) { throw new Error('topK must be at least 1'); @@ -123,7 +126,7 @@ export class PineconeClient { const pc = this.ensureClient(); const denseName = this.indexName; - const sparseName = `${this.indexName}-sparse`; + const sparseName = this.getSparseIndexName(); const dense = pc.index(denseName) as unknown as SearchableIndex; const sparse = pc.index(sparseName) as unknown as SearchableIndex; diff --git a/src/server/tools/keyword-search-tool.ts b/src/server/tools/keyword-search-tool.ts index 4271645..1d6c257 100644 --- a/src/server/tools/keyword-search-tool.ts +++ b/src/server/tools/keyword-search-tool.ts @@ -38,13 +38,23 @@ async function executeKeywordSearch(params: { }): Promise { const { query_text, namespace, top_k, metadata_filter, fields } = params; - if (!query_text.trim()) { + const normalizedQuery = query_text.trim(); + const normalizedNamespace = namespace?.trim() ?? ''; + + if (!normalizedQuery) { return { status: 'error', message: 'Query text cannot be empty', }; } + if (!normalizedNamespace) { + return { + status: 'error', + message: 'Namespace cannot be empty', + }; + } + if (metadata_filter) { const filterError = validateMetadataFilter(metadata_filter); if (filterError) { @@ -54,19 +64,21 @@ async function executeKeywordSearch(params: { const client = getPineconeClient(); const results = await client.keywordSearch({ - query: query_text.trim(), - namespace, + query: normalizedQuery, + namespace: normalizedNamespace, topK: top_k, metadataFilter: metadata_filter, fields: fields?.length ? fields : undefined, }); - const formattedResults = formatQueryResultRows(results, { namespace }); + const formattedResults = formatQueryResultRows(results, { + namespace: normalizedNamespace, + }); const response: KeywordSearchResponse = { status: 'success', - query: query_text, - namespace, + query: normalizedQuery, + namespace: normalizedNamespace, index: client.getSparseIndexName(), metadata_filter: metadata_filter, result_count: formattedResults.length,