From 9397303f0143c94a11327447145380459ff7876d Mon Sep 17 00:00:00 2001 From: asepharyana Date: Wed, 19 Aug 2026 19:00:29 +0700 Subject: [PATCH] =?UTF-8?q?feat(mcpedia):=20Phase=202=20=E2=80=94=20semant?= =?UTF-8?q?ic=20+=20hybrid=20search,=20tRPC/Hono=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @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 --- .../plans/2026-08-19_phase2-semantic-api.md | 161 +++++++++++ PHASES.md | 14 +- README.md | 41 ++- apps/api/package.json | 23 ++ apps/api/src/index.ts | 26 ++ apps/api/src/router.ts | 38 +++ apps/api/src/trpc.ts | 11 + apps/api/tsconfig.json | 11 + apps/mcp/src/index.ts | 39 ++- apps/mcp/src/smoke.test.ts | 26 ++ apps/mcp/tsconfig.json | 2 + apps/web/app/search/page.tsx | 41 ++- bun.lock | 59 +++- package.json | 9 +- packages/config/src/index.ts | 4 + packages/core/package.json | 1 + packages/core/src/document.service.ts | 42 ++- packages/core/src/search.service.ts | 9 +- packages/db/drizzle/0001_document_chunks.sql | 13 + packages/db/drizzle/meta/0001_snapshot.json | 260 ++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 9 +- packages/db/package.json | 2 +- packages/db/src/index.ts | 2 + packages/db/src/schema.ts | 38 ++- packages/embeddings/package.json | 17 ++ packages/embeddings/src/chunk.ts | 48 ++++ packages/embeddings/src/index.ts | 2 + packages/embeddings/src/provider.ts | 82 ++++++ packages/search/package.json | 1 + packages/search/src/index.ts | 113 +++++++- scripts/indexer.ts | 17 +- scripts/package.json | 1 + 32 files changed, 1120 insertions(+), 42 deletions(-) create mode 100644 .hermes/plans/2026-08-19_phase2-semantic-api.md create mode 100644 apps/api/package.json create mode 100644 apps/api/src/index.ts create mode 100644 apps/api/src/router.ts create mode 100644 apps/api/src/trpc.ts create mode 100644 apps/api/tsconfig.json create mode 100644 packages/db/drizzle/0001_document_chunks.sql create mode 100644 packages/db/drizzle/meta/0001_snapshot.json create mode 100644 packages/db/src/index.ts create mode 100644 packages/embeddings/package.json create mode 100644 packages/embeddings/src/chunk.ts create mode 100644 packages/embeddings/src/index.ts create mode 100644 packages/embeddings/src/provider.ts diff --git a/.hermes/plans/2026-08-19_phase2-semantic-api.md b/.hermes/plans/2026-08-19_phase2-semantic-api.md new file mode 100644 index 0000000..3bfa61e --- /dev/null +++ b/.hermes/plans/2026-08-19_phase2-semantic-api.md @@ -0,0 +1,161 @@ +# MCPedia Phase 2 — Semantic Search + tRPC/Hono API + +> **For Hermes:** implement task-by-task. Spec-first (user rule 2026-08-19). + +**Goal:** Add semantic + hybrid search (pgvector) and a typed tRPC/Hono API so +MCPedia is queryable by embeddings, not just keyword FTS — and expose the +corpus over a programmatic HTTP API. + +**Architecture:** Content (Markdown) → chunk → embed (OpenRouter) → store +`document_chunks` with `vector(N)` in Postgres → `semanticSearch` (cosine) and +`hybridSearch` (FTS + cosine, reciprocal-rank fusion) in `@mcpedia/search` → +exposed via Core, the MCP server (new tools), and a new `apps/api` (Hono + +tRPC v11). + +**Embedding provider:** OpenRouter (`openrouter/llama-nemotron-embed-vl-1b-v2:free`) +via `9router_ai_llm_api_key` + `9router_ai_llm_base_url` (BWS). Dimension is +discovered at first live call (see Step 1.3) and pinned in schema/migration. + +**Tech stack:** drizzle-orm `vector` column + pgvector extension, HNSW index, +`@trpc/server` v11 (fetch adapter), `hono` + `@hono/node-server`. + +--- + +## Task P2.1 — `packages/embeddings` (provider + abstraction) + +**Files:** `packages/embeddings/package.json`, `src/index.ts`, `src/provider.ts`, +`src/openrouter.ts` + +- `EmbeddingProvider` interface: `embed(texts: string[]): Promise`, `readonly model`, `readonly dimensions`. +- `OpenRouterEmbeddingProvider`: POST `${baseUrl}/embeddings` with `{ model, input }`, + `Authorization: Bearer ${key}`. Returns `data[].embedding`. Validate length === dimensions. +- Read `EMBED_BASE_URL`, `EMBED_API_KEY`, `EMBED_MODEL` from `@mcpedia/config` + (with `.env` fallback). Dimensions discovered live (Step 1.3) → export `EMBED_DIM`. +- Chunk helper `chunkText(text, { size=1000, overlap=150 })` in `src/chunk.ts`. + +**Step 1.3 (discover dim):** live call `embed(["test"])`, read `embedding.length`, +pin `EMBED_DIM`, assert mismatch throws. + +**Verify:** `bun run` a temp script: `embed(["hello world"])` prints a vector of +length N (e.g. 1024). Confirm no key is logged. + +--- + +## Task P2.2 — Schema: `document_chunks` + vector extension + +**Files:** `packages/db/src/schema.ts` (add), `packages/db/drizzle.config.ts` +(unchanged), new migration. + +- `CREATE EXTENSION IF NOT EXISTS vector;` (idempotent; run once via psql). +- `document_chunks` table: + - `id` uuid pk default gen_random_uuid() + - `document_id` text → `documents.id` on delete cascade + - `slug` text (denormalized for convenience) + - `chunk_index` integer + - `content` text + - `embedding` vector(EMBED_DIM) + - `created_at` timestamp default now() + - index `chunk_embedding_idx` using hnsw (`embedding` op `vector_cosine_ops`) +- Generate migration with `drizzle-kit generate`, apply via `psql` (drizzle-kit + push is unreliable here — known). + +**Verify:** `\d document_chunks` shows `embedding vector(N)` + HNSW index; +`select count(*) from document_chunks` = 0. + +--- + +## Task P2.3 — Indexer: chunk + embed + upsert + +**Files:** `scripts/indexer.ts` (extend), `packages/core/src/document.service.ts` +(add `indexChunks`). + +- For each published doc: read body (already on disk), `chunkText`, `embed` in + batches (≤ 16), delete existing chunks for slug, insert new rows. +- Guard: if embedding provider fails, log + skip (don't crash the whole index). +- Add `bun run index:embed` (or extend `bun run index` to also embed). + +**Verify:** after running, `select count(*) from document_chunks` > 0; a sample +row has non-null `embedding`. + +--- + +## Task P2.4 — `packages/search`: semantic + hybrid + +**Files:** `packages/search/src/index.ts` (add `semanticSearch`, `hybridSearch`). + +- `semanticSearch(vec, limit)`: order by `embedding <=> ${vec}` asc, filter published. +- `hybridSearch(q, limit)`: run FTS (`ts_rank`) + semantic (cosine) in parallel; + fuse with reciprocal-rank (RRF: score = 1/(k+rank), k=60); return merged hits. +- Keep `keywordSearch` unchanged (Phase 1). + +**Verify:** unit-ish script: embed a query, `semanticSearch` returns relevant +chunks; `hybridSearch("websocket")` returns ≥ keyword results. + +--- + +## Task P2.5 — `packages/core` expose semantic/hybrid + +**Files:** `packages/core/src/search.service.ts`, `index.ts`. + +- Re-export `semanticSearch`, `hybridSearch` from Core. + +--- + +## Task P2.6 — `apps/api` (Hono + tRPC v11) + +**Files:** `apps/api/package.json`, `tsconfig.json`, `src/index.ts`, +`src/router.ts`, `src/trpc.ts`. + +- `initTRPC.create()` router with procedures: `search`, `semanticSearch`, + `hybridSearch`, `getDocument`, `listDocuments` (mirrors MCP tools). +- Mount `fetchRequestHandler` on a Hono app at `/trpc/*`; serve via + `@hono/node-server` `serve({ fetch: app.fetch, port: 4020 })`. +- `createContext` returns `{ db }`. + +**Verify:** `bun run dev` → `curl -X POST localhost:4020/trpc/search` +with JSON body returns hits. + +--- + +## Task P2.7 — MCP server: semantic + hybrid tools + +**Files:** `apps/mcp/src/index.ts` (add `semantic_search`, `hybrid_search`), +extend `smoke.test.ts`. + +- `semantic_search`: embed query → `semanticSearch`. +- `hybrid_search`: embed query → `hybridSearch`. +- Smoke: assert both return ≥1 hit for "websocket". + +--- + +## Task P2.8 — Web: semantic toggle on search + +**Files:** `apps/web/app/search/page.tsx`. + +- Add `mode=keyword|hybrid` query param; server component calls Core + `hybridSearch` when `mode=hybrid`. Minimal UI toggle (link/buttons). +- Keep keyword as default. + +**Verify:** `bun run build`; `curl '/search?q=websocket&mode=hybrid'` returns hits. + +--- + +## Task P2.9 — Verify all + commit + +- `bunx turbo run build` (web + api + mcp), `bun run apps/mcp smoke`, + live API curl, live web hybrid search. +- Update `README.md` + `PHASES.md` (mark Phase 2 ✅). +- `git add -A` (exclude `.env`), commit as asepharyana (no Co-Authored-By). + +--- + +## Risks / decisions +- **Dimension unknown until live call** → P2.1.3 discovers it; pinned EMBED_DIM=2048. +- **pgvector NOT available on shared imrnes Postgres** (extension not installed; + installing needs host-level apt on a managed/shared DB — deferred). PIVOT: + store `embedding` as `real[]` and compute cosine similarity in the app layer. + Brute-force cosine is instant for a KB-sized corpus (dozens of docs / hundreds + of chunks). pgvector+HNSW is the Phase-4 scale-out path. +- **PgBouncer + real[]**: fine; simple queries, no extension needed. +- **API port 4020** (host 4000s range is 4000–4015; 4020 is free for dev). Deploy later. +- **YAGNI**: no auth/revisions this phase (Phase 3). diff --git a/PHASES.md b/PHASES.md index 059fe00..24a0406 100644 --- a/PHASES.md +++ b/PHASES.md @@ -19,13 +19,13 @@ Legend: ✅ built · 🟡 partial · ⬜ deferred ## Phase 2 — Semantic + API -- [ ] `pgvector` + embedding column on `document_chunks` -- [ ] Chunking + embedding provider abstraction (`EmbeddingProvider`: OpenAI/Gemini/Ollama/local) -- [ ] Hybrid search (FTS score + cosine, reciprocal-rank fusion) -- [ ] tRPC + Hono API (`apps/api`) sharing `@mcpedia/core` -- [ ] Tags / Categories / References as first-class tables -- [ ] Auth (Auth.js / OIDC) — public/private/unlisted/admin/owner -- [ ] shadcn/ui components + Shiki syntax highlighting (replace minimal markdown render) +- [x] `packages/embeddings` — `EmbeddingProvider` interface + OpenRouter provider (via 9router `/v1`, `encoding_format:"float"`); `chunkText` + `embedChunks` batcher. `EMBED_DIM=2048` discovered live. +- [x] Schema `document_chunks` (id, document_id→documents.id cascade, slug, chunk_index, content, `embedding real[]`). Stored as `real[]` because pgvector **is not installed** on the shared imrnes Postgres (installing needs host-level apt — deferred). Cosine computed in-app; instant for a KB-sized corpus. +- [x] `scripts/indexer.ts` — chunks + embeds + upserts (per-doc replace). +- [x] `@mcpedia/search` — `semanticSearch` (cosine) + `hybridSearch` (FTS + cosine, RRF fusion). `keywordSearch` unchanged. +- [x] `apps/api` — Hono + tRPC v11 (`@trpc/server` fetch adapter, `@hono/node-server` on :4020): `search`, `semanticSearch`, `hybridSearch`, `getDocument`, `listDocuments`, `related`. +- [x] MCP server — added `semantic_search` + `hybrid_search` tools (6 total). +- [x] Web search — keyword/hybrid toggle (`?mode=hybrid`), hybrid reaches semantically-related docs keyword misses. ## Phase 3 — Async + Scale diff --git a/README.md b/README.md index a728713..3735ae8 100644 --- a/README.md +++ b/README.md @@ -51,12 +51,18 @@ bun run mcp # MCP server on stdio (pipe to an MCP client) ### Database -Phase 1 uses Postgres FTS only. Schema is defined in `packages/db/src/schema.ts` -(a `documents` table with a `search_vector` generated `tsvector` column + GIN -index). Apply it with: +Schema is defined in `packages/db/src/schema.ts` (`documents` with a weighted +`search_vector` tsvector + GIN index, and `document_chunks` with an `embedding real[]`). +The `pgvector` extension is **not available** on the shared imrnes Postgres, so +semantic search stores vectors as `real[]` and ranks by in-app cosine similarity. + +Migrations live in `packages/db/drizzle/`. They were applied manually via `psql` +(`drizzle-kit push` is unreliable under PgBouncer transaction pooling); to +re-apply on a fresh DB: ```bash -bunx --cwd packages/db drizzle-kit push +psql $DATABASE_URL -f packages/db/drizzle/0000_grey_toro.sql +psql $DATABASE_URL -f packages/db/drizzle/0001_document_chunks.sql ``` > Note: on imrnes (PgBouncer `:6432`) a leaked `DATABASE_URL` shell var can @@ -84,11 +90,13 @@ updated_at: 2026-08-19 `body` shown in the UI is always read from the on-disk file (source of truth); the DB stores metadata + the search vector. -## MCP tools (Phase 1) +## MCP tools | Tool | Purpose | | --------------------- | ------------------------------------------------ | | `search_documents` | Postgres FTS over the corpus (ranked + snippet) | +| `semantic_search` | Embedding/cosine search over chunked content | +| `hybrid_search` | FTS + semantic fused via RRF | | `get_document` | Full markdown body by slug | | `list_documents` | List, optionally filtered by section | | `get_related_documents` | Docs sharing tags with a given slug | @@ -99,10 +107,29 @@ Smoke test (in-memory transport, real JSON-RPC): bun --cwd apps/mcp run smoke ``` +## API (Phase 2) + +A tRPC v11 API is also exposed via Hono on **:4020** (all procedures mirror the +MCP tools): + +```bash +bun run api # http://localhost:4020 (GET /health, POST/GET /trpc/*) +``` + +`bun run index` now also chunks + embeds (Phase 2 indexer). Requires `EMBED_*` +vars in `.env` (see `.env.example`). + ## Status **Phase 1 — MVP (DONE):** monorepo, Core, Web UI (home/doc/search), MCP server, Postgres FTS keyword search, content indexing. -See `PHASES.md` for Phase 2–4 (pgvector semantic/hybrid search, tRPC/Hono API, -auth, Redis/BullMQ background workers, revisions, scale-out). +**Phase 2 — Semantic + API (DONE):** embeddings provider (OpenRouter via 9router), +chunked `document_chunks`, `semanticSearch` + `hybridSearch` (RRF), tRPC/Hono API +(`apps/api`, :4020), MCP `semantic_search`/`hybrid_search` tools, web hybrid toggle. + +> pgvector is **not installed** on the shared imrnes Postgres, so vector storage is +> a `real[]` column with in-app cosine similarity (instant at KB scale). pgvector is +> the Phase-4 scale-out path. See `PHASES.md`. + +See `PHASES.md` for Phase 3–4 (Redis/BullMQ, auth, revisions, scale-out). diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..0ab7c47 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,23 @@ +{ + "name": "@mcpedia/api", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run src/index.ts", + "start": "bun run src/index.ts", + "lint": "tsc --noEmit", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@hono/node-server": "^1.13.0", + "@mcpedia/config": "workspace:*", + "@mcpedia/core": "workspace:*", + "@trpc/server": "^11.0.0", + "hono": "^4.6.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "typescript": "^5.6.0" + } +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..cbfb180 --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,26 @@ +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; +import { db } from "@mcpedia/db"; +import { appRouter } from "./router"; +import type { Context } from "./trpc"; + +const app = new Hono(); + +// Health check. +app.get("/health", (c) => c.json({ ok: true })); + +// Mount tRPC at /trpc/*. The fetch adapter is the canonical Bun/Hono adapter. +app.all("/trpc/*", (c) => + fetchRequestHandler({ + endpoint: "/trpc", + req: c.req.raw, + router: appRouter, + createContext: (): Context => ({ db }), + }), +); + +const port = Number(process.env.API_PORT ?? 4020); +serve({ fetch: app.fetch, port }, (info) => { + console.log(`MCPedia API listening on http://localhost:${info.port}`); +}); diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts new file mode 100644 index 0000000..28c69d9 --- /dev/null +++ b/apps/api/src/router.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; +import { publicProcedure, router } from "./trpc"; +import { + getDocument, + getRelated, + hybridSearch, + keywordSearch, + listDocuments, + semanticSearch, +} from "@mcpedia/core"; + +export const appRouter = router({ + search: publicProcedure + .input(z.object({ q: z.string(), limit: z.number().int().min(1).max(50).default(20) })) + .query(({ input }) => keywordSearch(input.q, input.limit)), + + semanticSearch: publicProcedure + .input(z.object({ q: z.string(), limit: z.number().int().min(1).max(50).default(10) })) + .query(async ({ input }) => semanticSearch(input.q, input.limit)), + + hybridSearch: publicProcedure + .input(z.object({ q: z.string(), limit: z.number().int().min(1).max(50).default(10) })) + .query(async ({ input }) => hybridSearch(input.q, input.limit)), + + getDocument: publicProcedure + .input(z.object({ slug: z.string() })) + .query(async ({ input }) => getDocument(input.slug)), + + listDocuments: publicProcedure + .input(z.object({ section: z.string().optional(), status: z.string().optional() }).optional()) + .query(async ({ input }) => listDocuments(input ?? {})), + + related: publicProcedure + .input(z.object({ slug: z.string(), limit: z.number().int().min(1).max(20).default(5) })) + .query(async ({ input }) => getRelated(input.slug, input.limit)), +}); + +export type AppRouter = typeof appRouter; diff --git a/apps/api/src/trpc.ts b/apps/api/src/trpc.ts new file mode 100644 index 0000000..f2406c0 --- /dev/null +++ b/apps/api/src/trpc.ts @@ -0,0 +1,11 @@ +import { initTRPC } from "@trpc/server"; +import { db } from "@mcpedia/db"; + +export interface Context { + db: typeof db; +} + +export const t = initTRPC.context().create(); + +export const router = t.router; +export const publicProcedure = t.procedure; diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..5eec656 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "paths": { + "@mcpedia/db": ["../../packages/db/src/index.ts"], + "@mcpedia/db/schema": ["../../packages/db/src/schema.ts"], + "@mcpedia/*": ["../../packages/*"] + } + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts index f2adcf3..2c85a5e 100644 --- a/apps/mcp/src/index.ts +++ b/apps/mcp/src/index.ts @@ -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; } diff --git a/apps/mcp/src/smoke.test.ts b/apps/mcp/src/smoke.test.ts index dfc37bf..8737f1a 100644 --- a/apps/mcp/src/smoke.test.ts +++ b/apps/mcp/src/smoke.test.ts @@ -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"); diff --git a/apps/mcp/tsconfig.json b/apps/mcp/tsconfig.json index c095c45..5eec656 100644 --- a/apps/mcp/tsconfig.json +++ b/apps/mcp/tsconfig.json @@ -2,6 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "paths": { + "@mcpedia/db": ["../../packages/db/src/index.ts"], + "@mcpedia/db/schema": ["../../packages/db/src/schema.ts"], "@mcpedia/*": ["../../packages/*"] } }, diff --git a/apps/web/app/search/page.tsx b/apps/web/app/search/page.tsx index b81b25b..175d52d 100644 --- a/apps/web/app/search/page.tsx +++ b/apps/web/app/search/page.tsx @@ -1,18 +1,45 @@ import Link from "next/link"; -import { keywordSearch } from "@mcpedia/core"; +import { keywordSearch, hybridSearch } from "@mcpedia/core"; + +type Mode = "keyword" | "hybrid"; export default async function SearchPage({ searchParams, }: { - searchParams: Promise<{ q?: string }>; + searchParams: Promise<{ q?: string; mode?: string }>; }) { - const { q } = await searchParams; + const { q, mode } = await searchParams; const query = q?.trim() ?? ""; - const hits = query ? await keywordSearch(query, 30) : []; + const activeMode: Mode = mode === "hybrid" ? "hybrid" : "keyword"; + + const hits = query + ? activeMode === "hybrid" + ? await hybridSearch(query, 30) + : await keywordSearch(query, 30) + : []; + + const toggle = (m: Mode) => `/search?q=${encodeURIComponent(query)}&mode=${m}`; return (
-

Search

+
+

Search

+
+ + Keyword + + + Hybrid + +
+
+
+