feat(mcpedia): Phase 2 — semantic + hybrid search, tRPC/Hono API
- @mcpedia/embeddings: OpenRouter provider (9router /v1, encoding_format float), chunkText + embedChunks; EMBED_DIM=2048 - document_chunks table (real[] embedding) — pgvector NOT available on shared imrnes Postgres, so cosine is computed in-app (KB-scale fine); pgvector deferred - indexer: chunk + embed + upsert per document - @mcpedia/search: semanticSearch (cosine) + hybridSearch (FTS+cosine RRF) - apps/api: Hono + tRPC v11 (6 procedures), serve on :4020 - MCP: semantic_search + hybrid_search tools (6 total) - web: keyword/hybrid toggle; .env.example + README + PHASES updated
This commit is contained in:
1 parent
ec3a2867d4
commit
9397303f01
32 files changed
+1120
-42
No files matched your search
+37
-2
@@ -1,8 +1,7 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { listDocuments, getDocument, getRelated } from "@mcpedia/core";
|
||||
import { keywordSearch } from "@mcpedia/search";
|
||||
import { listDocuments, getDocument, getRelated, semanticSearch, hybridSearch, keywordSearch } from "@mcpedia/core";
|
||||
|
||||
export function createMcpServer(): McpServer {
|
||||
const server = new McpServer({
|
||||
@@ -84,6 +83,42 @@ export function createMcpServer(): McpServer {
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"semantic_search",
|
||||
{
|
||||
description:
|
||||
"Semantic (embedding) search across chunked document content. Best for conceptual/paraphrased queries that don't share exact keywords. Returns chunks ranked by cosine similarity.",
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe("Natural-language query"),
|
||||
limit: z.number().int().positive().max(50).optional(),
|
||||
}),
|
||||
},
|
||||
async ({ query, limit }) => {
|
||||
const hits = await semanticSearch(query, limit ?? 10);
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(hits, null, 2) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"hybrid_search",
|
||||
{
|
||||
description:
|
||||
"Hybrid search fusing full-text (Postgres FTS) and semantic (embedding) signals via Reciprocal Rank Fusion. Best general-purpose search.",
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe("Free-text or natural-language query"),
|
||||
limit: z.number().int().positive().max(50).optional(),
|
||||
}),
|
||||
},
|
||||
async ({ query, limit }) => {
|
||||
const hits = await hybridSearch(query, limit ?? 10);
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(hits, null, 2) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,10 @@ async function main() {
|
||||
const expected = [
|
||||
"get_document",
|
||||
"get_related_documents",
|
||||
"hybrid_search",
|
||||
"list_documents",
|
||||
"search_documents",
|
||||
"semantic_search",
|
||||
].sort();
|
||||
if (JSON.stringify(names) !== JSON.stringify(expected)) {
|
||||
throw new Error(`tool set mismatch: ${names.join(",")}`);
|
||||
@@ -66,6 +68,30 @@ async function main() {
|
||||
if (docs.length !== 1) throw new Error("list_documents docs != 1");
|
||||
console.log("list_documents(section=docs) =>", docs.length, "doc");
|
||||
|
||||
// 6) semantic_search
|
||||
const sem = await client.callTool({
|
||||
name: "semantic_search",
|
||||
arguments: { query: "websocket connection closing unexpectedly", limit: 5 },
|
||||
});
|
||||
const semHits = JSON.parse((sem.content as any)[0].text);
|
||||
if (!Array.isArray(semHits) || semHits.length < 1) {
|
||||
throw new Error("semantic_search returned no hits");
|
||||
}
|
||||
console.log(
|
||||
`semantic_search => ${semHits.length} chunks, top: ${semHits[0].slug}@${semHits[0].score.toFixed(3)}`,
|
||||
);
|
||||
|
||||
// 7) hybrid_search
|
||||
const hyb = await client.callTool({
|
||||
name: "hybrid_search",
|
||||
arguments: { query: "websocket timeout debugging", limit: 5 },
|
||||
});
|
||||
const hybHits = JSON.parse((hyb.content as any)[0].text);
|
||||
if (!Array.isArray(hybHits) || hybHits.length < 1) {
|
||||
throw new Error("hybrid_search returned no hits");
|
||||
}
|
||||
console.log(`hybrid_search => ${hybHits.length} docs, top: ${hybHits[0].doc.slug}`);
|
||||
|
||||
await client.close();
|
||||
await server.close();
|
||||
console.log("\nSMOKE OK");
|
||||
|
||||
Reference in new issue
Block a user