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
@@ -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