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:
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./provider";
|
||||
export * from "./chunk";
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user