From 88fa97e4d3e444d2b45e32e6cf18ced6531970a2 Mon Sep 17 00:00:00 2001 From: chengke <404835780@qq.com> Date: Mon, 21 Sep 2026 10:31:02 +0800 Subject: [PATCH] feat(retrieval): expose composed evidence and keep raw results Agents consume assembled evidence; results stay placeholder path chunks for debug. Local MCP search emits that evidence as text/image content. Co-authored-by: Cursor --- .changeset/retrieval-evidence-compose.md | 6 +++ README.md | 5 +- packages/mcp/README.md | 25 ++++++--- packages/mcp/src/__tests__/mcp.test.ts | 31 +++++++---- packages/mcp/src/index.ts | 2 +- packages/mcp/src/tool-result-formatter.ts | 47 +++++++++++----- src/index.ts | 1 + src/knowledge/__tests__/knowledge.test.ts | 2 + src/knowledge/knowledge.ts | 1 + src/knowledge/types.ts | 2 + .../__tests__/retrieval-wire.test.ts | 54 +++++++++++++++++++ src/resources/__tests__/retrieval.test.ts | 8 +++ src/types/retrieval.ts | 26 +++++++-- 13 files changed, 173 insertions(+), 37 deletions(-) create mode 100644 .changeset/retrieval-evidence-compose.md diff --git a/.changeset/retrieval-evidence-compose.md b/.changeset/retrieval-evidence-compose.md new file mode 100644 index 0000000..0590648 --- /dev/null +++ b/.changeset/retrieval-evidence-compose.md @@ -0,0 +1,6 @@ +--- +"@ontos-ai/knowhere-sdk": minor +"@ontos-ai/knowhere-mcp": minor +--- + +Expose composed retrieval `evidence` for agents and keep `results` as placeholder debug chunks. Local `knowhere_search` now returns that evidence as MCP text/image content. diff --git a/README.md b/README.md index b981054..f8f2924 100644 --- a/README.md +++ b/README.md @@ -311,12 +311,13 @@ const response = await client.retrieval.query({ console.log(response.answerText); // LLM-generated answer console.log(response.referencedChunks); // cited evidence chunks -console.log(response.evidenceText); // rendered evidence context, when returned +console.log(response.evidence); // composed parts to consume (text/HTML and inline images) +console.log(response.evidenceText); // text projection of those parts console.log(response.stopReason); // agentic termination reason, when returned console.log(response.failureReason); // no-answer reason, when returned for (const result of response.results) { - console.log(result.content); + console.log(result.content); // raw path chunk for debug console.log(result.contentSource); console.log(result.score); console.log(result.metadata?.pageNums); diff --git a/packages/mcp/README.md b/packages/mcp/README.md index aeb8094..4ac20ae 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -204,13 +204,13 @@ When logged in with Read only permission, the MCP server exposes only passing `localDocumentId`, published `documentId`, or completed `jobId`. Broad workspace search belongs to `knowhere_search`. - `knowhere_search`: search published documents through the Knowhere API - retrieval query. Page results and references are marked - `hasPageAssets="true"` when a follow-up `knowhere_read_chunks` call should be - used to inspect readable page asset URLs and chunk storage locations. + retrieval query. Compose already happened in Knowhere. The tool returns + assembled evidence as MCP text/image content, then a debug XML item with the + raw results list. ## Response Contract -All tools return a single MCP text content item: +Most tools return a single MCP text content item: ```json { @@ -218,9 +218,22 @@ All tools return a single MCP text content item: } ``` +`knowhere_search` returns assembled evidence first, then the debug XML: + +```json +{ + "content": [ + { "type": "text", "text": "composed table or text" }, + { "type": "image", "data": "...", "mimeType": "image/png" }, + { "type": "text", "text": "..." } + ] +} +``` + The MCP package does not expose `structuredContent` or tool `outputSchema` -fields. Each response is tagged text rooted at -``, using SDK-native camelCase field names such as +fields. Non-search tools return tagged text rooted at +``. Search prepends assembled evidence content, then +the same tagged debug XML. Field names stay SDK-native camelCase such as `documentId`, `jobId`, `localDocumentId`, `chunkId`, `assetUrl`, `chunkPath`, `filePath`, and `storageRoot`. diff --git a/packages/mcp/src/__tests__/mcp.test.ts b/packages/mcp/src/__tests__/mcp.test.ts index f2eb519..6b968d5 100644 --- a/packages/mcp/src/__tests__/mcp.test.ts +++ b/packages/mcp/src/__tests__/mcp.test.ts @@ -47,7 +47,8 @@ describe('knowhere MCP wrapper', () => { expect(readTool?.description).toContain('configured parsed storage first'); expect(readTool?.description).toContain('returns asset URLs'); expect(readTool?.description).toContain(''); - expect(searchTool?.description).toContain('hasPageAssets="true"'); + expect(searchTool?.description).toContain('assembled evidence'); + expect(searchTool?.description).toContain('debug XML'); await client.close(); await server.close(); }); @@ -683,11 +684,15 @@ describe('knowhere MCP wrapper', () => { await server.close(); }); - it('should format search evidence and page-result guidance', async () => { + it('should emit assembled evidence then debug search results', async () => { const knowhereClient = createClient(); knowhereClient.knowledge.search.mockResolvedValueOnce({ namespace: 'support-center', query: 'revenue', + evidence: [ + { type: 'text', text: 'Revenue increased' }, + { type: 'image', mediaType: 'image/png', data: 'abc' }, + ], evidenceText: 'Evidence ', references: [ { @@ -739,24 +744,30 @@ describe('knowhere MCP wrapper', () => { localDocumentIds: undefined, useAgentic: undefined, }); - expectToolText( - response, - ` + expect(response).not.toHaveProperty('structuredContent'); + if (!('content' in response)) { + throw new Error('Expected MCP tool response to include content'); + } + expect(response.content).toEqual([ + { type: 'text', text: 'Revenue increased' }, + { type: 'image', data: 'abc', mimeType: 'image/png' }, + { + type: 'text', + text: ` - Page results and references marked hasPageAssets="true" only include preview text here. Call knowhere_read_chunks with the documentId and chunkId to get readable page asset URLs and chunk storage locations. - Evidence <tree> - + - + Page preview `, - ); + }, + ]); await client.close(); await server.close(); }); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index db604f7..445f2f3 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -234,7 +234,7 @@ export async function createKnowhereMcpServer( 'knowhere_search', { description: - 'Search published Knowhere documents with the Knowhere API retrieval query. localDocumentIds only map returned server document IDs back to local cache IDs when available. Page results are marked with hasPageAssets="true"; use follow-up read calls to get tagged entries.', + 'Search published Knowhere documents. Compose already happened in Knowhere. The tool returns assembled evidence as MCP text/image content, then a debug XML item with the raw results list. localDocumentIds only map returned server document IDs back to local cache IDs when available.', inputSchema: { query: z.string(), namespace: z.string().optional(), diff --git a/packages/mcp/src/tool-result-formatter.ts b/packages/mcp/src/tool-result-formatter.ts index 32ed231..4c147a7 100644 --- a/packages/mcp/src/tool-result-formatter.ts +++ b/packages/mcp/src/tool-result-formatter.ts @@ -37,6 +37,10 @@ export function createKnowhereToolResult(params: { readonly operation: string; readonly result: unknown; }): CallToolResult { + if (params.operation === 'search') { + return { content: toSearchToolContent(params.result) }; + } + return { content: [ { @@ -47,6 +51,35 @@ export function createKnowhereToolResult(params: { }; } +function toSearchToolContent(result: unknown): CallToolResult['content'] { + const response: UnknownRecord | undefined = toRecord(result); + const content: CallToolResult['content'] = []; + + for (const part of readRecordArray(response, 'evidence')) { + const type: string | undefined = readString(part, 'type'); + if (type === 'text') { + const text: string | undefined = readString(part, 'text'); + if (text !== undefined) { + content.push({ type: 'text', text }); + } + continue; + } + if (type === 'image') { + const data: string | undefined = readString(part, 'data'); + const mediaType: string | undefined = readString(part, 'mediaType'); + if (data !== undefined && mediaType !== undefined) { + content.push({ type: 'image', data, mimeType: mediaType }); + } + } + } + + content.push({ + type: 'text', + text: formatOperationResult('search', result), + }); + return content; +} + function formatOperationResult(operation: string, result: unknown): string { const lines: string[] = [``]; @@ -223,7 +256,6 @@ function appendSearchResult(lines: string[], result: unknown): void { const response: UnknownRecord | undefined = toRecord(result); const references: readonly UnknownRecord[] = readRecordArray(response, 'references'); const results: readonly UnknownRecord[] = readRecordArray(response, 'results'); - const hasPageAssets: boolean = references.some(isPageRecord) || results.some(isPageRecord); lines.push( `${indent(1)}`, ); - if (hasPageAssets) { - appendTextTag(lines, 2, { - name: 'instruction', - text: 'Page results and references marked hasPageAssets="true" only include preview text here. Call knowhere_read_chunks with the documentId and chunkId to get readable page asset URLs and chunk storage locations.', - }); - } - appendOptionalTextTag(lines, 2, 'evidenceText', readString(response, 'evidenceText')); appendSearchReferences(lines, references, 2); appendSearchResults(lines, results, 2); lines.push(`${indent(1)}`); @@ -451,7 +476,6 @@ function appendSearchReferences( chunkType: readString(reference, 'chunkType'), sectionPath: readString(reference, 'sectionPath'), score: readNumber(reference, 'score'), - hasPageAssets: isPageRecord(reference) ? true : undefined, }, }); } @@ -475,7 +499,6 @@ function appendSearchResults( sectionPath: readString(result, 'sectionPath'), sourceFileName: readString(result, 'sourceFileName'), score: readNumber(result, 'score'), - hasPageAssets: isPageRecord(result) ? true : undefined, })}>`, ); appendTextTag(lines, depth + 2, { @@ -608,10 +631,6 @@ function formatAttributes( return renderedAttributes.length > 0 ? ` ${renderedAttributes.join(' ')}` : ''; } -function isPageRecord(record: UnknownRecord): boolean { - return readString(record, 'chunkType') === 'page'; -} - function isMediaChunk(chunkType: string | undefined): boolean { return chunkType === 'image' || chunkType === 'table'; } diff --git a/src/index.ts b/src/index.ts index 5dafb61..025876e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,7 @@ export type { RetrievalSectionExclusion, RetrievalQueryParams, RetrievalSource, + RetrievalEvidencePart, RetrievalResult, RetrievalReferencedChunk, RetrievalQueryResponse, diff --git a/src/knowledge/__tests__/knowledge.test.ts b/src/knowledge/__tests__/knowledge.test.ts index 586f9db..aa17729 100644 --- a/src/knowledge/__tests__/knowledge.test.ts +++ b/src/knowledge/__tests__/knowledge.test.ts @@ -889,6 +889,7 @@ describe('Knowledge', () => { topK: 2, useAgentic: true, }); + expect(response.evidence).toEqual([{ type: 'text', text: 'Margin guidance improved.' }]); expect(response.results).toHaveLength(1); expect(response.results[0]).toMatchObject({ localDocumentId: 'local-report', @@ -1199,6 +1200,7 @@ function createClient(parseResult: ParseResult): { query: 'margin', routerUsed: 'legacy', answerText: null, + evidence: [{ type: 'text', text: 'Margin guidance improved.' }], evidenceText: '[report.md / Revenue]\nMargin guidance improved.', referencedChunks: [ { diff --git a/src/knowledge/knowledge.ts b/src/knowledge/knowledge.ts index 198580c..5130f9e 100644 --- a/src/knowledge/knowledge.ts +++ b/src/knowledge/knowledge.ts @@ -484,6 +484,7 @@ export class Knowledge { return { namespace: rawResponse.namespace, query: rawResponse.query, + evidence: rawResponse.evidence ?? [], evidenceText: rawResponse.evidenceText, references: [ ...rawResponse.referencedChunks.map( diff --git a/src/knowledge/types.ts b/src/knowledge/types.ts index e889a81..14af7cb 100644 --- a/src/knowledge/types.ts +++ b/src/knowledge/types.ts @@ -1,3 +1,4 @@ +import type { RetrievalEvidencePart } from '../types/retrieval.js'; import type { ParseParams } from '../types/params.js'; import type { Job, JobResult } from '../types/job.js'; import type { Chunk, DocumentChunkType, ParseResult } from '../types/index.js'; @@ -238,6 +239,7 @@ export interface KnowledgeSearchReference { export interface KnowledgeSearchResponse { namespace?: string; query: string; + evidence?: RetrievalEvidencePart[]; evidenceText?: string | null; references: KnowledgeSearchReference[]; results: KnowledgeSearchResult[]; diff --git a/src/resources/__tests__/retrieval-wire.test.ts b/src/resources/__tests__/retrieval-wire.test.ts index 54aa7b6..91b024a 100644 --- a/src/resources/__tests__/retrieval-wire.test.ts +++ b/src/resources/__tests__/retrieval-wire.test.ts @@ -81,3 +81,57 @@ describe.each(['apiKey', 'authTokenProvider'] as const)('Retrieval wire payload } }); }); + +describe('Retrieval wire response', () => { + it('keeps composed evidence and camelCases image media type', async () => { + const server = createServer((_request, response) => { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + namespace: 'default', + query: 'refund policy', + router_used: 'small_corpus_all', + evidence: [ + { type: 'text', text: 'before' }, + { type: 'image', media_type: 'image/png', data: 'abc' }, + ], + evidence_text: 'beforedata:image/png;base64,abc', + results: [ + { + content: '[images/a.png]', + chunk_type: 'text', + score: 1, + source: { document_id: 'doc-1' }, + }, + ], + }), + ); + }); + + try { + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected TCP server address'); + const client = new Knowhere({ + baseURL: `http://127.0.0.1:${address.port}`, + apiKey: 'test-key', + maxRetries: 0, + }); + + const result = await client.retrieval.query({ query: 'refund policy' }); + + expect(result.evidence).toEqual([ + { type: 'text', text: 'before' }, + { type: 'image', mediaType: 'image/png', data: 'abc' }, + ]); + expect(result.results[0]?.content).toBe('[images/a.png]'); + expect(result.results[0]).not.toHaveProperty('composed'); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections(); + }); + } + }); +}); diff --git a/src/resources/__tests__/retrieval.test.ts b/src/resources/__tests__/retrieval.test.ts index 60ce4c1..b87d059 100644 --- a/src/resources/__tests__/retrieval.test.ts +++ b/src/resources/__tests__/retrieval.test.ts @@ -22,6 +22,10 @@ describe('Retrieval Resource', () => { query: 'refund policy', routerUsed: 'discovery+agent', answerText: null, + evidence: [ + { type: 'text', text: 'Annual plans may be refunded within 30 days.' }, + { type: 'image', mediaType: 'image/png', data: 'abc' }, + ], referencedChunks: [], results: [ { @@ -83,6 +87,10 @@ describe('Retrieval Resource', () => { ], }); expect(response.routerUsed).toBe('discovery+agent'); + expect(response.evidence).toEqual([ + { type: 'text', text: 'Annual plans may be refunded within 30 days.' }, + { type: 'image', mediaType: 'image/png', data: 'abc' }, + ]); expect(response.results[0]).toEqual({ content: 'Annual plans may be refunded within 30 days.', chunkType: 'text', diff --git a/src/types/retrieval.ts b/src/types/retrieval.ts index 2038211..234ec9d 100644 --- a/src/types/retrieval.ts +++ b/src/types/retrieval.ts @@ -84,13 +84,27 @@ export interface RetrievalSource { sectionPath?: string | null; } +/** + * One composed evidence part. Tables stay in text as HTML; images are inline bytes. + */ +export type RetrievalEvidencePart = + | { + type: 'text'; + text: string; + } + | { + type: 'image'; + mediaType: string; + data: string; + }; + /** * Canonical chunk result returned by retrieval query. */ export interface RetrievalResult { /** Parser-provided chunk identifier when included by the API */ chunkId?: string; - /** Knowledge content to use directly in the caller's answer */ + /** Raw chunk body for debug. Placeholders stay intact. */ content: string; /** Chunk type, for example text, image, table, or page */ chunkType: string; @@ -139,8 +153,10 @@ export interface RetrievalReferencedChunk { /** * Response from POST /v2/retrieval/query. * - * Three PRIMARY output fields for downstream agent consumption: - * - `evidenceText`: hierarchical evidence tree for LLM context + * Downstream agents consume: + * - `evidence`: composed parts (text/HTML and inline images) + * - `evidenceText`: text projection of those parts + * - `results`: raw path chunks for debug * - `decisionTrace`: per-step navigation decisions (includes stop/failure) * - `referencedChunks`: structured chunk citations for follow-up queries */ @@ -151,11 +167,13 @@ export interface RetrievalQueryResponse { query: string; /** Retrieval router path used by the API for this query */ routerUsed: string; + /** Composed evidence parts in result order */ + evidence?: RetrievalEvidencePart[]; /** LLM-generated natural-language answer, or null when no answer was produced */ answerText: string | null; /** Cited evidence chunks with asset URLs when available */ referencedChunks: RetrievalReferencedChunk[]; - /** Tree-structured evidence text rendered by the agentic navigator */ + /** Text projection of evidence. Tables stay as HTML; images are data URLs. */ evidenceText?: string | null; /** Reason why the agentic run stopped (e.g. answer_done, not_found) */ stopReason?: string | null;