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 committed 2026-08-19 19:00:29 +07:00
1 parent ec3a2867d4
commit 9397303f01
32 files changed
+1120 -42

No files matched your search

@@ -0,0 +1,13 @@
CREATE TABLE "document_chunks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"document_id" text NOT NULL,
"slug" text NOT NULL,
"chunk_index" integer NOT NULL,
"content" text NOT NULL,
"embedding" real[],
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "document_chunks_slug_idx" ON "document_chunks" USING btree ("slug");
--> statement-breakpoint
ALTER TABLE "document_chunks" ADD CONSTRAINT "document_chunks_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade;
+260
View File
@@ -0,0 +1,260 @@
{
"id": "fd210ce3-31f0-434d-9e80-6f5a0bb07504",
"prevId": "249f80c0-d953-42f2-a98b-6701e2115856",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.document_chunks": {
"name": "document_chunks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"document_id": {
"name": "document_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"chunk_index": {
"name": "chunk_index",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true
},
"embedding": {
"name": "embedding",
"type": "vector(2048)",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"document_chunks_embedding_idx": {
"name": "document_chunks_embedding_idx",
"columns": [
{
"expression": "embedding",
"isExpression": false,
"asc": true,
"nulls": "last",
"opclass": "vector_cosine_ops"
}
],
"isUnique": false,
"concurrently": false,
"method": "hnsw",
"with": {}
},
"document_chunks_slug_idx": {
"name": "document_chunks_slug_idx",
"columns": [
{
"expression": "slug",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"document_chunks_document_id_documents_id_fk": {
"name": "document_chunks_document_id_documents_id_fk",
"tableFrom": "document_chunks",
"tableTo": "documents",
"columnsFrom": [
"document_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.documents": {
"name": "documents",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"section": {
"name": "section",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'published'"
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"tags": {
"name": "tags",
"type": "text[]",
"primaryKey": false,
"notNull": true,
"default": "'{}'"
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"search_vector": {
"name": "search_vector",
"type": "tsvector",
"primaryKey": false,
"notNull": true,
"generated": {
"as": "setweight(to_tsvector('simple', coalesce(\"documents\".\"title\", '')), 'A') || setweight(to_tsvector('simple', coalesce(\"documents\".\"body\", '')), 'B')",
"type": "stored"
}
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"documents_search_idx": {
"name": "documents_search_idx",
"columns": [
{
"expression": "search_vector",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "gin",
"with": {}
},
"documents_section_idx": {
"name": "documents_section_idx",
"columns": [
{
"expression": "section",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"documents_slug_unique": {
"name": "documents_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+8 -1
View File
@@ -8,6 +8,13 @@
"when": 1787133375079,
"tag": "0000_grey_toro",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1787137149735,
"tag": "0001_document_chunks",
"breakpoints": true
}
]
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"exports": {
".": "./src/client.ts",
".": "./src/index.ts",
"./schema": "./src/schema.ts"
},
"dependencies": {
+2
View File
@@ -0,0 +1,2 @@
export { db, schema, client } from "./client";
export * from "./schema";
+30 -8
View File
@@ -4,8 +4,11 @@ import {
index,
integer,
pgTable,
real,
text,
timestamp,
uuid,
vector,
} from "drizzle-orm/pg-core";
// tsvector isn't a first-class drizzle type; wrap the raw Postgres type.
@@ -46,14 +49,33 @@ export const documents = pgTable(
}),
);
// Phase 2 (semantic search) — defined here for reference, NOT created yet:
// export const documentChunks = pgTable("document_chunks", {
// id: text("id").primaryKey(),
// documentId: text("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
// content: text("content").notNull(),
// position: integer("position").notNull(),
// embedding: customType<{ data: number[] }>({ dataType: () => "vector(1536)" })("embedding"),
// });
// Phase 2: semantic search chunks. Each row is an embedded slice of a document
// body. `embedding` is a plain float array (real[]). We compute cosine
// similarity in the application layer — pgvector isn't available on the shared
// imrnes Postgres, and brute-force cosine is instant for a KB-sized corpus.
// (pgvector/HNSW is the Phase-4 scale-out path.)
export const documentChunks = pgTable(
"document_chunks",
{
id: uuid("id").primaryKey().defaultRandom(),
documentId: text("document_id")
.notNull()
.references(() => documents.id, { onDelete: "cascade" }),
slug: text("slug").notNull(),
chunkIndex: integer("chunk_index").notNull(),
content: text("content").notNull(),
embedding: real("embedding").array(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
slugIdx: index("document_chunks_slug_idx").on(t.slug),
}),
);
export type DocumentChunkRow = typeof documentChunks.$inferSelect;
export type NewDocumentChunkRow = typeof documentChunks.$inferInsert;
export type DocumentRow = typeof documents.$inferSelect;
export type NewDocumentRow = typeof documents.$inferInsert;