Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/0-requirements.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,18 @@ Durable Object + SQLite は次の structured record を保持する。
- `list_recent_activity`
- semantic search hit の enrichment

### 8. Graph Index(opt-in, D1 `doc_edges`)

判断知(Decision Structure = wiki の kebab エントリ)の関係を索引化する additive なグラフ層。dense(Vectorize)/ sparse(FTS5)に並ぶ第3の surface だが、**既定では retrieval から読まれない**。

- **スキーマ**: `doc_edges(src_vector_id, dst_vector_id, repo, src_slug, dst_slug, edge_kind, updated_at)`(`migrations/0002_graph_edges.sql`)。src/dst は wiki の決定的 vector_id(`wikiDocVectorId`)。
- **エッジ抽出**: wiki ページ index 時(`processAndUpsertWikiDoc`)、同 repo の既知 wiki slug が本文に出現したら A→B の "mention" エッジを生成(`src/graph.ts` の `indexWikiEdges`)。**決定的 slug-match(LLM 不要・ロスなし)**。dst は計算で求まるので未 index でも記録可(dangling 可)。typed(supersede/depend/conflict)は将来スコープ。
- **traversal**: `queryNeighbors` が `WITH RECURSIVE`(標準 SQLite、拡張不要)で seed の 1–2 hop neighbor を無向に辿る。
- **retrieval 統合**: `search` の `graph_expand`(既定 false)/ `graph_hops`(既定 1)。true の時のみ、RRF 後の最終結果を seed に neighbor を辿り、関連 wiki ページを `graph_hop` / `graph_from` 付きで末尾に append。**false の時は既存挙動と完全同一(回帰なし)**。
- **delete fan-out**: wiki ページ削除時に `deleteEdgesForVector`(当該 vector を端点に持つエッジを除去)。
- **backfill**: `POST /admin/backfill-edges?repo=owner/repo`(GITHUB_TOKEN ヘッダ)。既存 index 済み wiki の content から一括抽出(GitHub 再取得不要)。
- **評価**: 本番 ship 後の実運用観測(judgment-learning が関連判断を拾えるか)。offline eval harness は作らない。

## Retrieval Model

retrieval layer は hybrid search(dense + sparse)+ cross-encoder rerank + structured filter を 3 段で支える(2026 production baseline)。
Expand Down
12 changes: 12 additions & 0 deletions docs/0-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,18 @@ This store supports:
- `list_recent_activity`
- enrichment of semantic search hits

### 8. Graph Index (opt-in, D1 `doc_edges`)

An additive graph layer that indexes relationships between Decision-Structure wiki entries (kebab-case wiki pages). A third surface alongside dense (Vectorize) and sparse (FTS5), but **never read by retrieval unless explicitly requested**.

- **Schema**: `doc_edges(src_vector_id, dst_vector_id, repo, src_slug, dst_slug, edge_kind, updated_at)` (`migrations/0002_graph_edges.sql`). src/dst are deterministic wiki vector IDs (`wikiDocVectorId`).
- **Edge extraction**: at wiki index time (`processAndUpsertWikiDoc`), when another known wiki slug in the same repo appears in the content, an A→B "mention" edge is written (`indexWikiEdges` in `src/graph.ts`). **Deterministic slug-match (no LLM, no lossy extraction)**; the dst ID is computed, so dangling edges to not-yet-indexed pages are allowed. Typed edges (supersede/depend/conflict) are future scope.
- **Traversal**: `queryNeighbors` walks 1–2 hop undirected neighbors of the seeds via `WITH RECURSIVE` (standard SQLite, no extension).
- **Retrieval integration**: `search` gains `graph_expand` (default false) / `graph_hops` (default 1). Only when true, the final result set seeds a traversal and related wiki pages are appended (tagged `graph_hop` / `graph_from`). **When false the behavior is byte-identical to before (no regression).**
- **Delete fan-out**: on wiki page deletion, `deleteEdgesForVector` removes edges touching that vector.
- **Backfill**: `POST /admin/backfill-edges?repo=owner/repo` (GITHUB_TOKEN header) re-extracts edges from the stored content of already-indexed wiki pages (no GitHub refetch).
- **Evaluation**: real-use observation after ship (does judgment-learning surface related decisions), not an offline eval harness.

## Retrieval Model

The retrieval layer supports a 3-tier pipeline: hybrid search (dense + sparse), RRF fusion, and cross-encoder reranking, with structured filtering applied along the way (the 2026 production baseline).
Expand Down
49 changes: 49 additions & 0 deletions migrations/0002_graph_edges.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
-- D1 graph edges migration — opt-in GraphRAG layer (additive).
--
-- Layer = L4 Operations (graph retrieval surface, sibling of the FTS5 sparse side)
--
-- Overview:
--
-- doc_edges — directed "mention" references between wiki pages (Decision Structure
-- entries). src/dst are deterministic Vectorize vector IDs computed by
-- wikiDocVectorId() in src/pipeline/vector-id.ts, so an edge can be
-- recorded without a lookup and may point at a not-yet-indexed page
-- (dangling allowed). Traversal (recursive CTE in src/graph.ts) treats
-- edges as undirected.
--
-- Notes:
-- - Fully additive: no existing table or query depends on doc_edges. The default
-- retrieval path (dense + sparse RRF) never reads it. It is consulted only when
-- the `search` tool is called with `graph_expand=true`.
-- - Cloudflare D1 ships standard SQLite, so recursive CTE traversal needs no extension
-- (same as the pre-compiled FTS5 used by 0001).
-- - PRIMARY KEY (src_vector_id, dst_vector_id) gives the src-prefix index for free;
-- the extra dst index supports the undirected reverse hop.

CREATE TABLE IF NOT EXISTS doc_edges (
-- Deterministic Vectorize vector ID of the referencing page ("w:..." for wiki).
src_vector_id TEXT NOT NULL,

-- Deterministic Vectorize vector ID of the referenced page (wikiDocVectorId(repo, dst_slug)).
dst_vector_id TEXT NOT NULL,

-- Repository ("owner/repo"); edges only form within one repo's wiki.
repo TEXT NOT NULL DEFAULT '',

-- Human-readable slugs (wiki page names), kept for debugging / backfill clarity.
src_slug TEXT NOT NULL DEFAULT '',
dst_slug TEXT NOT NULL DEFAULT '',

-- Relationship kind. Currently only 'mention' (generic related-to). Typed
-- supersede/depend/conflict edges are a follow-up.
edge_kind TEXT NOT NULL DEFAULT 'mention',

updated_at TEXT NOT NULL DEFAULT '',

PRIMARY KEY (src_vector_id, dst_vector_id)
);

-- Reverse-direction lookups for undirected traversal.
CREATE INDEX IF NOT EXISTS idx_doc_edges_dst ON doc_edges (dst_vector_id);
-- Per-repo scoping of traversal.
CREATE INDEX IF NOT EXISTS idx_doc_edges_repo ON doc_edges (repo);
233 changes: 233 additions & 0 deletions src/graph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
/**
* D1 graph layer — opt-in GraphRAG over wiki Decision Structure entries.
*
* Layer = L4 Operations (graph retrieval surface)
*
* Additive sibling of fts.ts (sparse / BM25) and Vectorize (dense). The default
* retrieval path never touches this layer; it is consulted only when the `search`
* tool is called with `graph_expand=true`.
*
* Edges are deterministic slug-mention references between wiki pages: when page A's
* content mentions page B's slug, an A→B "mention" edge is recorded. The dst vector
* ID is computed with wikiDocVectorId() (no lookup, dangling allowed). Extraction is
* exact string matching — no LLM, no lossy relation extraction.
*
* Traversal uses a recursive CTE (standard SQLite, no extension) and treats edges
* as undirected so a query can reach both referencing and referenced neighbors.
*/

import { wikiDocVectorId } from "./pipeline/vector-id.js";

/** One outbound edge from a source page. */
export interface DocEdge {
dstVectorId: string;
dstSlug: string;
edgeKind: string; // "mention" for now
}

/** A neighbor produced by graph traversal. */
export interface GraphNeighbor {
vectorId: string;
hop: number;
fromVectorId: string;
}

/** Escape a string for safe inclusion in a RegExp. */
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

/**
* Whether `content` references the kebab-case `slug` as a standalone token.
* Requires the slug not to be flanked by [a-z0-9-] so that a slug which is a
* substring of a longer slug (e.g. "decision-structure" inside
* "decision-structure-rename-rationale") does not produce a false edge.
*/
export function mentionsSlug(content: string, slug: string): boolean {
if (slug.length === 0 || !content.includes(slug)) return false;
const re = new RegExp(`(^|[^a-z0-9-])${escapeRegExp(slug)}([^a-z0-9-]|$)`);
return re.test(content);
}

/**
* Extract mention edges from a wiki page's content. For each known slug in the
* same repo (other than the source) that the content references, emit an edge to
* its deterministically-computed wiki vector ID.
*/
export async function extractMentionEdges(
repo: string,
srcSlug: string,
content: string,
knownSlugs: string[],
): Promise<DocEdge[]> {
const edges: DocEdge[] = [];
const seen = new Set<string>();
for (const slug of knownSlugs) {
if (slug === srcSlug || seen.has(slug)) continue;
if (mentionsSlug(content, slug)) {
seen.add(slug);
edges.push({
dstVectorId: await wikiDocVectorId(repo, slug),
dstSlug: slug,
edgeKind: "mention",
});
}
}
return edges;
}

/** Fetch the known wiki page slugs for a repo from the FTS content table. */
export async function knownWikiSlugs(
db: D1Database,
repo: string,
): Promise<string[]> {
const res = await db
.prepare(
`SELECT doc_path FROM search_docs WHERE type = 'wiki_doc' AND repo = ?`,
)
.bind(repo)
.all<{ doc_path: string }>();
return (res.results ?? [])
.map((r) => String(r.doc_path ?? ""))
.filter((s) => s.length > 0);
}

/**
* Replace all outbound edges for a source node (delete-then-insert) so a re-index
* of the same page does not accumulate stale edges.
*/
export async function upsertEdges(
db: D1Database,
srcVectorId: string,
repo: string,
srcSlug: string,
edges: DocEdge[],
): Promise<void> {
const now = new Date().toISOString();
const stmts: D1PreparedStatement[] = [
db.prepare(`DELETE FROM doc_edges WHERE src_vector_id = ?`).bind(srcVectorId),
];
for (const e of edges) {
stmts.push(
db
.prepare(
`INSERT OR REPLACE INTO doc_edges
(src_vector_id, dst_vector_id, repo, src_slug, dst_slug, edge_kind, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.bind(srcVectorId, e.dstVectorId, repo, srcSlug, e.dstSlug, e.edgeKind, now),
);
}
await db.batch(stmts);
}

/**
* Convenience: extract + upsert mention edges for one wiki page. Called from the
* ingest pipeline right after the page is written to Vectorize + FTS5.
*/
export async function indexWikiEdges(
db: D1Database,
repo: string,
srcSlug: string,
srcVectorId: string,
content: string,
): Promise<number> {
const known = await knownWikiSlugs(db, repo);
const edges = await extractMentionEdges(repo, srcSlug, content, known);
await upsertEdges(db, srcVectorId, repo, srcSlug, edges);
return edges.length;
}

/** Remove every edge touching a vector ID (as src or dst). For delete fan-out. */
export async function deleteEdgesForVector(
db: D1Database,
vectorId: string,
): Promise<void> {
await db
.prepare(`DELETE FROM doc_edges WHERE src_vector_id = ? OR dst_vector_id = ?`)
.bind(vectorId, vectorId)
.run();
}

/**
* Traverse up to `hops` (1–2) neighbors of the seed vector IDs via a recursive CTE.
* Edges are followed in both directions. Returns neighbors NOT in the seed set,
* each with its shortest hop distance and one originating seed.
*/
export async function queryNeighbors(
db: D1Database,
seedVectorIds: string[],
opts: { hops?: number; repo?: string; limit?: number } = {},
): Promise<GraphNeighbor[]> {
const seeds = [...new Set(seedVectorIds)].filter((s) => s.length > 0);
if (seeds.length === 0) return [];
const hops = Math.max(1, Math.min(2, opts.hops ?? 1));
const limit = Math.max(1, Math.min(200, opts.limit ?? 50));

// Anchor: each seed at depth 0 with itself as origin.
const seedValues = seeds.map(() => "(?, 0, ?)").join(", ");
const repoFilter = opts.repo ? "AND e.repo = ?" : "";

const sql = `
WITH RECURSIVE reach(id, depth, origin) AS (
SELECT * FROM (VALUES ${seedValues})
UNION
SELECT CASE WHEN e.src_vector_id = r.id THEN e.dst_vector_id
ELSE e.src_vector_id END,
r.depth + 1,
r.origin
FROM doc_edges e
JOIN reach r
ON (e.src_vector_id = r.id OR e.dst_vector_id = r.id)
WHERE r.depth < ? ${repoFilter}
)
SELECT id, MIN(depth) AS hop, origin
FROM reach
WHERE depth > 0
GROUP BY id
ORDER BY hop ASC
LIMIT ?
`;

const binds: (string | number)[] = [];
for (const s of seeds) binds.push(s, s); // (id, origin) per seed VALUES row
binds.push(hops);
if (opts.repo) binds.push(opts.repo);
binds.push(limit);

const res = await db
.prepare(sql)
.bind(...binds)
.all<{ id: string; hop: number; origin: string }>();

const seedSet = new Set(seeds);
return (res.results ?? [])
.map((r) => ({
vectorId: String(r.id ?? ""),
hop: Number(r.hop ?? 0),
fromVectorId: String(r.origin ?? ""),
}))
.filter((n) => n.vectorId.length > 0 && !seedSet.has(n.vectorId));
}

/**
* Fetch search_docs rows by vector ID, keyed by vector_id. Used to enrich graph
* neighbors that did not appear in the dense/sparse result sets.
*/
export async function getDocsByVectorIds(
db: D1Database,
vectorIds: string[],
): Promise<Map<string, Record<string, unknown>>> {
const ids = [...new Set(vectorIds)].filter((s) => s.length > 0);
const out = new Map<string, Record<string, unknown>>();
if (ids.length === 0) return out;
const placeholders = ids.map(() => "?").join(", ");
const res = await db
.prepare(`SELECT * FROM search_docs WHERE vector_id IN (${placeholders})`)
.bind(...ids)
.all<Record<string, unknown>>();
for (const r of res.results ?? []) {
out.set(String(r.vector_id ?? ""), r);
}
return out;
}
52 changes: 52 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
import { handleScheduled } from "./poller.js";
import { handleWebhook } from "./webhook.js";
import { RagMcpAgentV2 } from "./mcp.js";
import { indexWikiEdges } from "./graph.js";

// Durable Object: issue/PR state store (SQLite-backed)
export { IssueStore } from "./store.js";
Expand Down Expand Up @@ -126,6 +127,57 @@ const innerHandler: ExportedHandler<Env> = {
});
}

// -- Admin: backfill graph edges for a repo's already-indexed wiki pages --
// POST /admin/backfill-edges?repo=owner/repo (requires GITHUB_TOKEN header)
// Re-extracts mention edges from each wiki_doc row's stored content (no GitHub
// refetch) and writes them to doc_edges. One-off after deploy; live indexing
// keeps edges current thereafter.
if (request.method === "POST" && url.pathname === "/admin/backfill-edges") {
const authHeader = request.headers.get("GITHUB_TOKEN");
if (!authHeader || authHeader !== env.GITHUB_TOKEN) {
return new Response("Unauthorized", { status: 401 });
}

const repo = url.searchParams.get("repo");
if (!repo) {
return new Response("missing repo query parameter", { status: 400 });
}

let pages = 0;
let edges = 0;
try {
const rows = await env.DB_FTS
.prepare(
`SELECT vector_id, doc_path, content FROM search_docs WHERE type = 'wiki_doc' AND repo = ?`,
)
.bind(repo)
.all<{ vector_id: string; doc_path: string; content: string }>();
for (const r of rows.results ?? []) {
const n = await indexWikiEdges(
env.DB_FTS,
repo,
String(r.doc_path ?? ""),
String(r.vector_id ?? ""),
String(r.content ?? ""),
);
pages++;
edges += n;
}
} catch (err) {
return new Response(
JSON.stringify({
error: err instanceof Error ? err.message : String(err),
}),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}

return new Response(
JSON.stringify({ repo, wikiPagesProcessed: pages, edgesWritten: edges }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}

// -- MCP endpoint (OAuth-protected, ctx.props set by OAuthProvider) --
if (url.pathname.startsWith("/mcp")) {
const props = readGitHubProps(ctx);
Expand Down
Loading
Loading