Skip to content

Commit 9b74d6c

Browse files
authored
Merge pull request #31 from CppDigest/chen/issue-29-keyword-search
feat: add keyword_search tool for pinecone-rag-sparse index (#29)
2 parents c544cf7 + 00a5abd commit 9b74d6c

8 files changed

Lines changed: 385 additions & 27 deletions

File tree

README.md

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,12 @@ 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_RERANK_MODEL` | No | `bge-reranker-v2-m3` | Reranking model |
66+
| `PINECONE_READ_ONLY_MCP_LOG_LEVEL` | No | `INFO` | Logging level |
6767

6868
### Claude Desktop Configuration
6969

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

145145
```
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
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
151151
```
152152

153153
## Deployment
@@ -338,6 +338,45 @@ Returns the **unique document count** matching a metadata filter and semantic qu
338338
}
339339
```
340340

341+
### `keyword_search`
342+
343+
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.
344+
345+
**Parameters:**
346+
347+
| Parameter | Type | Required | Default | Description |
348+
| ----------------- | -------- | -------- | ------- | --------------------------------------------------------------------------- |
349+
| `query_text` | string | Yes | - | Search query text (keyword/lexical match) |
350+
| `namespace` | string | Yes | - | Namespace to search (use `list_namespaces` to discover) |
351+
| `top_k` | integer | No | `10` | Number of results to return (1-100) |
352+
| `metadata_filter` | object | No | - | Optional metadata filter (same operators as `query`) |
353+
| `fields` | string[] | No | - | Optional field names to return; omit for all fields |
354+
355+
**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`).
356+
357+
**Example response:**
358+
359+
```json
360+
{
361+
"status": "success",
362+
"query": "contracts C++",
363+
"namespace": "wg21-papers",
364+
"index": "rag-hybrid-sparse",
365+
"result_count": 5,
366+
"results": [
367+
{
368+
"paper_number": "P0548",
369+
"title": "Contracts for C++",
370+
"author": "John Doe",
371+
"url": "https://...",
372+
"content": "...",
373+
"score": 0.85,
374+
"reranked": false
375+
}
376+
]
377+
}
378+
```
379+
341380
### `query`
342381

343382
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 +509,18 @@ npm run build
470509
npm test
471510
```
472511

512+
### Testing the keyword_search tool
513+
514+
1. **Connectivity and keyword search (script):**
515+
Run the search test script (includes a keyword search step against the sparse index):
516+
```bash
517+
PINECONE_API_KEY=your-key npm run test:search
518+
```
519+
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.
520+
521+
2. **Via MCP client:**
522+
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`).
523+
473524
### Code Quality
474525

475526
```bash

scripts/test-search.ts

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

155+
// Test 5: Keyword (sparse-only) search — use namespace from sparse index, not dense
156+
let duration5: number | undefined;
157+
let test5Skipped = false;
158+
const sparseNamespaces = await client.listNamespacesFromKeywordIndex();
159+
if (sparseNamespaces.length === 0) {
160+
test5Skipped = true;
161+
console.log(`\n🔤 Test 5: Keyword search (sparse-only index)`);
162+
console.log(
163+
`⚠️ Keyword search skipped: sparse index has no namespaces (or index unavailable).`
164+
);
165+
console.log(
166+
` Ensure the sparse index (e.g. rag-hybrid-sparse) exists and has data.`
167+
);
168+
} else {
169+
const sparseTestNamespace = sparseNamespaces[0].namespace;
170+
console.log(`\n🔤 Test 5: Keyword search (sparse-only index)`);
171+
console.log(` Namespace: "${sparseTestNamespace}" (from sparse index)`);
172+
console.log(` Query: "test query"`);
173+
console.log(` Top K: 3`);
174+
try {
175+
const startTime5 = Date.now();
176+
const results5 = await client.keywordSearch({
177+
query: 'test query',
178+
namespace: sparseTestNamespace,
179+
topK: 3,
180+
});
181+
duration5 = Date.now() - startTime5;
182+
console.log(`✅ Keyword search returned ${results5.length} result(s) in ${duration5}ms`);
183+
if (results5.length > 0) {
184+
console.log(
185+
` First result score: ${results5[0].score.toFixed(4)}, reranked: ${results5[0].reranked}`
186+
);
187+
}
188+
} catch (kwError) {
189+
test5Skipped = true;
190+
console.log(
191+
`⚠️ Keyword search skipped: ${kwError instanceof Error ? kwError.message : String(kwError)}`
192+
);
193+
console.log(
194+
` Ensure the sparse index exists and namespace "${sparseTestNamespace}" has data.`
195+
);
196+
}
197+
}
198+
155199
console.log('\n✨ All tests completed successfully!');
156200
console.log(`\nPerformance comparison:`);
157201
console.log(` Without reranking: ${duration1}ms`);
158202
console.log(` With reranking: ${duration2}ms`);
159203
if (duration3 !== undefined) {
160204
console.log(` With metadata filter: ${duration3}ms`);
161205
}
206+
if (duration5 !== undefined) {
207+
console.log(` Keyword search: ${duration5}ms`);
208+
} else if (test5Skipped) {
209+
console.log(` Keyword search: skipped`);
210+
}
162211
console.log(` Reranking overhead: ${duration2 - duration1}ms`);
163212
} catch (error) {
164213
console.error('\n❌ Error during testing:', error);

src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Features:
4545
- 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.
4646
- URL Generation: Use generate_urls to synthesize URLs for namespaces that support it when metadata lacks url.
4747
- Document reassembly: Use query_documents to get whole documents (chunks grouped and merged by document_number/doc_id/url) for content analysis or summarization.
48+
- Keyword search: Use keyword_search to query the sparse index (default: rag-hybrid-sparse) for lexical/keyword-only retrieval without reranking.
4849
4950
Usage:
5051
1. Use list_namespaces (cached for 30 minutes) to discover available namespaces in the index

src/index.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,15 @@ Pinecone Read-Only MCP Server
7171
Usage: pinecone-read-only-mcp [options]
7272
7373
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
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}]; sparse index is {index-name}-sparse
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
7979
8080
Environment Variables:
8181
PINECONE_API_KEY Pinecone API key
82-
PINECONE_INDEX_NAME Pinecone index name
82+
PINECONE_INDEX_NAME Pinecone index name (sparse index: {name}-sparse)
8383
PINECONE_RERANK_MODEL Reranking model name
8484
PINECONE_READ_ONLY_MCP_LOG_LEVEL Logging level
8585
@@ -131,7 +131,7 @@ async function main(): Promise<void> {
131131
setPineconeClient(client);
132132

133133
console.error(`Starting Pinecone Read-Only MCP server with stdio transport`);
134-
console.error(`Using Pinecone index: ${indexName}`);
134+
console.error(`Using Pinecone index: ${indexName} (sparse: ${indexName}-sparse)`);
135135
console.error(`Log level: ${logLevel}`);
136136

137137
// Setup server

src/pinecone-client.ts

Lines changed: 106 additions & 9 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,
@@ -74,6 +75,28 @@ export class PineconeClient {
7475
config.defaultTopK || parseInt(process.env['PINECONE_TOP_K'] || String(DEFAULT_TOP_K));
7576
}
7677

78+
/** Returns the sparse index name (same as hybrid sparse: {indexName}-sparse). Used for keyword_search response. */
79+
getSparseIndexName(): string {
80+
return `${this.indexName}-sparse`;
81+
}
82+
83+
/**
84+
* Normalize and clamp topK from request (validates >= 1, caps at MAX_TOP_K).
85+
*/
86+
private clampTopK(requested: number | undefined): number {
87+
if (requested !== undefined && !Number.isFinite(requested)) {
88+
throw new Error('topK must be a finite number >= 1');
89+
}
90+
let topK = requested !== undefined ? requested : this.defaultTopK;
91+
if (topK < 1) {
92+
throw new Error('topK must be at least 1');
93+
}
94+
if (topK > MAX_TOP_K) {
95+
topK = MAX_TOP_K;
96+
}
97+
return topK;
98+
}
99+
77100
/**
78101
* Ensure Pinecone client is initialized
79102
*/
@@ -103,7 +126,7 @@ export class PineconeClient {
103126

104127
const pc = this.ensureClient();
105128
const denseName = this.indexName;
106-
const sparseName = `${this.indexName}-sparse`;
129+
const sparseName = this.getSparseIndexName();
107130

108131
const dense = pc.index(denseName) as unknown as SearchableIndex;
109132
const sparse = pc.index(sparseName) as unknown as SearchableIndex;
@@ -115,6 +138,29 @@ export class PineconeClient {
115138
return { denseIndex: dense, sparseIndex: sparse };
116139
}
117140

141+
/**
142+
* List namespaces present on the sparse index (same index used for hybrid sparse and keyword_search).
143+
* Use this to choose a namespace for sparse-only queries instead of the dense index list.
144+
*/
145+
async listNamespacesFromKeywordIndex(): Promise<
146+
Array<{ namespace: string; recordCount: number }>
147+
> {
148+
try {
149+
const { sparseIndex } = await this.ensureIndexes();
150+
const stats = sparseIndex.describeIndexStats
151+
? await sparseIndex.describeIndexStats()
152+
: undefined;
153+
const namespaces = stats?.namespaces ?? {};
154+
return Object.entries(namespaces).map(([namespace, info]) => ({
155+
namespace,
156+
recordCount: info?.recordCount ?? 0,
157+
}));
158+
} catch (error) {
159+
logError('Error listing namespaces from keyword index', error);
160+
return [];
161+
}
162+
}
163+
118164
/**
119165
* List all available namespaces with their metadata information
120166
*
@@ -390,14 +436,7 @@ export class PineconeClient {
390436
throw new Error('Query cannot be empty');
391437
}
392438

393-
let topK = requestedTopK !== undefined ? requestedTopK : this.defaultTopK;
394-
if (topK < 1) {
395-
throw new Error('topK must be at least 1');
396-
}
397-
398-
if (topK > MAX_TOP_K) {
399-
topK = MAX_TOP_K;
400-
}
439+
const topK = this.clampTopK(requestedTopK);
401440

402441
// When reranking, Pinecone requires chunk_text in returned fields; add it if user specified fields without it
403442
const searchFields =
@@ -453,6 +492,64 @@ export class PineconeClient {
453492
return documents;
454493
}
455494

495+
/**
496+
* Keyword (sparse-only) search against the dedicated sparse index.
497+
* Performs lexical/keyword retrieval only—no dense index, no reranking.
498+
* Use for exact or keyword-style queries on the configured sparse index.
499+
*/
500+
async keywordSearch(params: KeywordSearchParams): Promise<SearchResult[]> {
501+
const {
502+
query,
503+
namespace,
504+
topK: requestedTopK,
505+
metadataFilter,
506+
fields: requestedFields,
507+
} = params;
508+
509+
if (!query || !query.trim()) {
510+
throw new Error('Query cannot be empty');
511+
}
512+
513+
const topK = this.clampTopK(requestedTopK);
514+
515+
const { sparseIndex } = await this.ensureIndexes();
516+
const searchOptions = requestedFields?.length ? { fields: requestedFields } : undefined;
517+
518+
const hits = await this.searchIndex(
519+
sparseIndex,
520+
query.trim(),
521+
topK,
522+
namespace,
523+
metadataFilter,
524+
searchOptions
525+
);
526+
527+
const documents: SearchResult[] = hits.map((hit) => {
528+
const fields = hit.fields || {};
529+
let content = '';
530+
const metadata: Record<string, PineconeMetadataValue> = {};
531+
for (const [key, value] of Object.entries(fields)) {
532+
if (key === 'chunk_text') {
533+
content = typeof value === 'string' ? value : '';
534+
} else {
535+
metadata[key] = value as PineconeMetadataValue;
536+
}
537+
}
538+
return {
539+
id: hit._id || '',
540+
content,
541+
score: hit._score || 0,
542+
metadata,
543+
reranked: false,
544+
};
545+
});
546+
547+
logInfo(
548+
`Keyword search returned ${documents.length} results from ${this.getSparseIndexName()}`
549+
);
550+
return documents;
551+
}
552+
456553
/**
457554
* Return the number of unique documents matching the query and optional metadata filter.
458555
* 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)