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:
asepharyana
2026-08-19 19:00:29 +07:00
parent ec3a2867d4
commit 9397303f01
32 changed files with 1120 additions and 42 deletions
+48
View File
@@ -0,0 +1,48 @@
import type { EmbeddingProvider } from "./provider";
/**
* Split text into overlapping chunks for embedding. Keeps paragraphs/words
* intact where possible; never splits a chunk mid-word by more than `overlap`.
*/
export function chunkText(
text: string,
opts: { size?: number; overlap?: number } = {},
): string[] {
const size = opts.size ?? 1000;
const overlap = opts.overlap ?? 150;
const clean = text.replace(/\r\n/g, "\n").trim();
if (!clean) return [];
if (clean.length <= size) return [clean];
const chunks: string[] = [];
let start = 0;
while (start < clean.length) {
let end = Math.min(start + size, clean.length);
// Prefer to break on a newline/space near the boundary.
if (end < clean.length) {
const nl = clean.lastIndexOf("\n", end);
const sp = clean.lastIndexOf(" ", end);
const breakAt = nl > start + size * 0.5 ? nl : sp > start + size * 0.5 ? sp : end;
if (breakAt > start) end = breakAt;
}
chunks.push(clean.slice(start, end).trim());
if (end >= clean.length) break;
start = Math.max(end - overlap, start + 1);
}
return chunks.filter(Boolean);
}
/** Embed a list of chunks in batches to avoid oversized requests. */
export async function embedChunks(
provider: EmbeddingProvider,
chunks: string[],
batchSize = 16,
): Promise<number[][]> {
const out: number[][] = [];
for (let i = 0; i < chunks.length; i += batchSize) {
const batch = chunks.slice(i, i + batchSize);
const vecs = await provider.embed(batch);
out.push(...vecs);
}
return out;
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./provider";
export * from "./chunk";
+82
View File
@@ -0,0 +1,82 @@
import {
EMBED_API_KEY,
EMBED_BASE_URL,
EMBED_MODEL,
} from "@mcpedia/config";
export interface EmbeddingProvider {
/** Embed a batch of texts into vectors of fixed dimension. */
embed(texts: string[]): Promise<number[][]>;
readonly model: string;
readonly dimensions: number;
}
/** Pinned embedding dimension for the configured OpenRouter model. */
export const EMBED_DIM = 2048;
/**
* OpenRouter embeddings provider (we route through 9router's OpenAI-compatible
* /v1 endpoint). `encoding_format: "float"` is REQUIRED — the Nvidia-backed
* model rejects base64.
*/
export class OpenRouterEmbeddingProvider implements EmbeddingProvider {
readonly model: string;
private readonly baseUrl: string;
private readonly apiKey: string;
constructor(opts?: {
baseUrl?: string;
apiKey?: string;
model?: string;
}) {
this.baseUrl = (opts?.baseUrl ?? EMBED_BASE_URL).replace(/\/$/, "");
this.apiKey = opts?.apiKey ?? EMBED_API_KEY;
this.model = opts?.model ?? EMBED_MODEL;
if (!this.baseUrl || !this.apiKey || !this.model) {
throw new Error(
"OpenRouterEmbeddingProvider: missing EMBED_BASE_URL / EMBED_API_KEY / EMBED_MODEL",
);
}
}
get dimensions(): number {
return EMBED_DIM;
}
async embed(texts: string[]): Promise<number[][]> {
if (texts.length === 0) return [];
const res = await fetch(`${this.baseUrl}/embeddings`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: this.model,
input: texts,
encoding_format: "float",
}),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(
`embedding request failed (${res.status}): ${body.slice(0, 300)}`,
);
}
const json = (await res.json()) as {
data?: { embedding: number[] }[];
};
const data = json.data;
if (!data || data.length !== texts.length) {
throw new Error(
`embedding response mismatch: expected ${texts.length}, got ${data?.length ?? 0}`,
);
}
return data.map((d) => d.embedding);
}
}
/** Default singleton provider. */
export function createEmbeddingProvider(): EmbeddingProvider {
return new OpenRouterEmbeddingProvider();
}