trustgraph/ts/packages/flow/src/retrieval/graph-rag.ts

429 lines
12 KiB
TypeScript
Raw Normal View History

2026-04-05 21:09:33 -05:00
/**
* Graph RAG retrieval pipeline.
*
* Python reference: trustgraph-flow/trustgraph/retrieval/graph_rag/graph_rag.py
*/
import type {
EmbeddingsRequest,
EmbeddingsResponse,
2026-06-01 16:22:25 -05:00
FlowRequestor,
2026-04-05 21:09:33 -05:00
GraphEmbeddingsRequest,
GraphEmbeddingsResponse,
PromptRequest,
PromptResponse,
Term,
TextCompletionRequest,
TextCompletionResponse,
2026-04-05 21:09:33 -05:00
Triple,
TriplesQueryRequest,
TriplesQueryResponse,
2026-04-05 21:09:33 -05:00
} from "@trustgraph/base";
2026-06-01 16:22:25 -05:00
import { errorMessage } from "@trustgraph/base";
import { Context, Effect, Layer } from "effect";
import * as O from "effect/Option";
import * as S from "effect/Schema";
2026-04-05 21:09:33 -05:00
export interface GraphRagConfig {
entityLimit?: number;
tripleLimit?: number;
maxSubgraphSize?: number;
maxPathLength?: number;
edgeScoreLimit?: number;
edgeLimit?: number;
}
export interface GraphRagClients {
2026-05-12 08:06:58 -05:00
llm: FlowRequestor<TextCompletionRequest, TextCompletionResponse>;
embeddings: FlowRequestor<EmbeddingsRequest, EmbeddingsResponse>;
graphEmbeddings: FlowRequestor<GraphEmbeddingsRequest, GraphEmbeddingsResponse>;
triples: FlowRequestor<TriplesQueryRequest, TriplesQueryResponse>;
prompt: FlowRequestor<PromptRequest, PromptResponse>;
2026-04-05 21:09:33 -05:00
}
export type ChunkCallback = (text: string, endOfStream: boolean) => Promise<void>;
2026-06-01 16:22:25 -05:00
export interface GraphRagQueryOptions {
readonly collection?: string;
readonly streaming?: boolean;
readonly chunkCallback?: ChunkCallback;
}
export interface GraphRagResult {
answer: string;
subgraph: Triple[];
}
2026-06-01 16:22:25 -05:00
interface NormalizedGraphRagConfig {
entityLimit: number;
tripleLimit: number;
maxSubgraphSize: number;
maxPathLength: number;
edgeScoreLimit: number;
edgeLimit: number;
}
export class GraphRagEngineError extends S.TaggedErrorClass<GraphRagEngineError>()(
"GraphRagEngineError",
{
message: S.String,
operation: S.String,
cause: S.DefectWithStack,
},
) {}
export interface GraphRagEngineShape {
readonly query: (
clients: GraphRagClients,
queryText: string,
options?: GraphRagQueryOptions,
config?: GraphRagConfig,
) => Effect.Effect<GraphRagResult, GraphRagEngineError>;
}
export class GraphRagEngine extends Context.Service<GraphRagEngine, GraphRagEngineShape>()(
"@trustgraph/flow/retrieval/graph-rag/GraphRagEngine",
) {}
const graphRagError = (operation: string, cause: unknown) =>
new GraphRagEngineError({
operation,
cause,
message: errorMessage(cause),
});
export function normalizeGraphRagConfig(config: GraphRagConfig = {}): NormalizedGraphRagConfig {
return {
entityLimit: config.entityLimit ?? 50,
tripleLimit: config.tripleLimit ?? 30,
maxSubgraphSize: config.maxSubgraphSize ?? 1000,
maxPathLength: config.maxPathLength ?? 2,
edgeScoreLimit: config.edgeScoreLimit ?? 50,
edgeLimit: config.edgeLimit ?? 25,
};
}
export function makeGraphRagEngine(): GraphRagEngineShape {
return {
query: Effect.fn("GraphRagEngine.query")((
clients: GraphRagClients,
queryText: string,
options?: GraphRagQueryOptions,
config?: GraphRagConfig,
) =>
Effect.tryPromise({
try: () => queryGraphRag(clients, queryText, options, config),
catch: (cause) => graphRagError("query", cause),
}),
),
};
}
export const GraphRagLive: Layer.Layer<GraphRagEngine> = Layer.succeed(
GraphRagEngine,
GraphRagEngine.of(makeGraphRagEngine()),
);
2026-06-01 20:26:47 -05:00
export interface GraphRag {
readonly query: (
2026-04-05 21:09:33 -05:00
queryText: string,
2026-06-01 16:22:25 -05:00
options?: GraphRagQueryOptions,
2026-06-01 20:26:47 -05:00
) => Promise<GraphRagResult>;
}
export function makeGraphRag(
clients: GraphRagClients,
config: GraphRagConfig = {},
): GraphRag {
const engine = makeGraphRagEngine();
return {
query: (queryText, options) =>
Effect.runPromise(engine.query(clients, queryText, options, config)),
};
2026-06-01 16:22:25 -05:00
}
2026-04-05 21:09:33 -05:00
2026-06-01 16:22:25 -05:00
async function queryGraphRag(
clients: GraphRagClients,
queryText: string,
options?: GraphRagQueryOptions,
rawConfig?: GraphRagConfig,
): Promise<GraphRagResult> {
const config = normalizeGraphRagConfig(rawConfig);
console.log(`[GraphRag] Query: "${queryText.slice(0, 80)}..."`);
2026-04-05 21:09:33 -05:00
2026-06-01 16:22:25 -05:00
const concepts = await extractConcepts(clients, queryText);
console.log(`[GraphRag] Step 1: extracted ${concepts.length} concepts: ${concepts.slice(0, 5).join(", ")}`);
2026-04-05 21:09:33 -05:00
2026-06-01 16:22:25 -05:00
const vectors = await getVectors(clients, concepts);
console.log(`[GraphRag] Step 2: got ${vectors.length} vectors (dim=${vectors[0]?.length ?? 0})`);
2026-04-05 21:09:33 -05:00
2026-06-01 16:22:25 -05:00
const entities = await getEntities(clients, config, vectors, options?.collection);
console.log(`[GraphRag] Step 3: found ${entities.length} matching entities`);
2026-04-05 21:09:33 -05:00
2026-06-01 16:22:25 -05:00
const subgraph = await followEdges(clients, config, entities, options?.collection);
console.log(`[GraphRag] Step 4: traversed graph, ${subgraph.length} triples in subgraph`);
2026-04-05 21:09:33 -05:00
2026-06-01 16:22:25 -05:00
const scoredEdges = await scoreEdges(clients, config, queryText, subgraph);
console.log(`[GraphRag] Step 5: scored down to ${scoredEdges.length} edges`);
2026-04-05 22:44:45 -05:00
2026-06-01 16:22:25 -05:00
console.log(`[GraphRag] Step 6: synthesizing answer from ${scoredEdges.length} edges...`);
const answer = await synthesize(
clients,
queryText,
scoredEdges,
options?.chunkCallback,
);
console.log(`[GraphRag] Step 6: done (${answer.length} chars)`);
2026-04-05 22:44:45 -05:00
2026-06-01 16:22:25 -05:00
return { answer, subgraph: scoredEdges };
}
2026-04-05 22:44:45 -05:00
2026-06-01 16:22:25 -05:00
async function extractConcepts(clients: GraphRagClients, query: string): Promise<string[]> {
const promptResp = await clients.prompt.request({
name: "extract-concepts",
variables: { query },
});
const llmResp = await clients.llm.request({
system: promptResp.system,
prompt: promptResp.prompt,
});
return llmResp.response
.split("\n")
.map((concept) => concept.trim())
.filter((concept) => concept.length > 0);
}
2026-04-05 21:09:33 -05:00
2026-06-01 16:22:25 -05:00
async function getVectors(clients: GraphRagClients, concepts: string[]): Promise<number[][]> {
const resp = await clients.embeddings.request({ text: concepts });
return resp.vectors;
}
2026-04-05 21:09:33 -05:00
2026-06-01 16:22:25 -05:00
async function getEntities(
clients: GraphRagClients,
config: NormalizedGraphRagConfig,
vectors: number[][],
collection?: string,
): Promise<Term[]> {
const resp = await clients.graphEmbeddings.request({
vectors,
user: "default",
collection: collection ?? "default",
limit: config.entityLimit,
});
return resp.entities;
}
2026-04-05 22:44:45 -05:00
2026-06-01 16:22:25 -05:00
async function followEdges(
clients: GraphRagClients,
config: NormalizedGraphRagConfig,
entities: Term[],
collection?: string,
): Promise<Triple[]> {
const visited = new Set<string>();
const subgraph: Triple[] = [];
let currentLevel = new Set<string>(
entities.map((entity) => termToString(entity)),
);
for (let depth = 0; depth < config.maxPathLength; depth++) {
if (currentLevel.size === 0 || subgraph.length >= config.maxSubgraphSize) {
break;
2026-04-05 22:44:45 -05:00
}
2026-06-01 16:22:25 -05:00
const unvisited = [...currentLevel].filter((entity) => !visited.has(entity));
if (unvisited.length === 0) break;
const queries = unvisited.map((entityStr) => {
const term = stringToTerm(entityStr);
const request: TriplesQueryRequest = {
s: term,
limit: config.tripleLimit,
...(collection !== undefined ? { collection } : {}),
};
return clients.triples.request(request);
2026-04-05 22:44:45 -05:00
});
2026-06-01 16:22:25 -05:00
const results = await Promise.all(queries);
const nextLevel = new Set<string>();
2026-04-05 22:44:45 -05:00
2026-06-01 16:22:25 -05:00
for (const result of results) {
for (const triple of result.triples) {
subgraph.push(triple);
if (depth < config.maxPathLength - 1) {
const objStr = termToString(triple.o);
if (!visited.has(objStr)) {
nextLevel.add(objStr);
2026-04-05 22:44:45 -05:00
}
}
2026-06-01 16:22:25 -05:00
if (subgraph.length >= config.maxSubgraphSize) {
return subgraph;
2026-04-05 22:44:45 -05:00
}
}
}
2026-06-01 16:22:25 -05:00
for (const entity of currentLevel) {
visited.add(entity);
2026-04-05 22:44:45 -05:00
}
2026-06-01 16:22:25 -05:00
currentLevel = nextLevel;
}
return subgraph.slice(0, config.maxSubgraphSize);
}
async function scoreEdges(
clients: GraphRagClients,
config: NormalizedGraphRagConfig,
query: string,
triples: Triple[],
): Promise<Triple[]> {
if (triples.length === 0) return [];
if (triples.length <= 500) {
console.log(`[GraphRag] Skipping edge scoring - ${triples.length} triples fits in context directly`);
return triples;
}
const edgeDescriptions = triples.map((triple, index) => ({
id: String(index),
s: termToString(triple.s),
p: termToString(triple.p),
o: termToString(triple.o),
}));
const toScore = edgeDescriptions.slice(0, config.edgeScoreLimit);
const knowledgeJson = JSON.stringify(toScore, null, 2);
const promptResp = await clients.prompt.request({
name: "kg-edge-scoring",
variables: {
query,
knowledge: knowledgeJson,
},
});
const llmResp = await clients.llm.request({
system: promptResp.system,
prompt: promptResp.prompt,
});
2026-04-05 22:44:45 -05:00
2026-06-01 16:22:25 -05:00
console.log(`[GraphRag] Edge scoring LLM response (first 500 chars): ${llmResp.response.slice(0, 500)}`);
2026-06-01 16:22:25 -05:00
const scored = parseScoredEdges(llmResp.response);
scored.sort((a, b) => b.score - a.score);
const topN = scored.slice(0, config.edgeLimit);
const result: Triple[] = [];
for (const entry of topN) {
const idx = Number.parseInt(entry.id, 10);
if (!Number.isNaN(idx) && idx >= 0 && idx < triples.length) {
result.push(triples[idx]);
2026-04-05 22:44:45 -05:00
}
2026-06-01 16:22:25 -05:00
}
console.log(`[GraphRag] Edge scoring: LLM returned ${scored.length} scores, keeping top ${topN.length}, mapped ${result.length} triples`);
2026-04-05 22:44:45 -05:00
2026-06-01 16:22:25 -05:00
if (result.length === 0) {
return triples.slice(0, config.edgeLimit);
2026-04-05 21:09:33 -05:00
}
2026-06-01 16:22:25 -05:00
return result;
}
2026-04-05 21:09:33 -05:00
2026-06-01 16:22:25 -05:00
async function synthesize(
clients: GraphRagClients,
query: string,
edges: Triple[],
chunkCallback?: ChunkCallback,
): Promise<string> {
const context = edges
.map((triple) => `${termToString(triple.s)} -> ${termToString(triple.p)} -> ${termToString(triple.o)}`)
.join("\n");
const promptResp = await clients.prompt.request({
name: "graph-rag-synthesize",
variables: { query, context },
});
if (chunkCallback !== undefined) {
let fullText = "";
await clients.llm.request(
{
system: promptResp.system,
prompt: promptResp.prompt,
streaming: true,
},
{
recipient: async (resp) => {
if (resp.response.length > 0) {
fullText += resp.response;
await chunkCallback(resp.response, resp.endOfStream === true);
}
return resp.endOfStream === true;
2026-04-05 21:09:33 -05:00
},
2026-06-01 16:22:25 -05:00
},
);
return fullText;
}
2026-04-05 21:09:33 -05:00
2026-06-01 16:22:25 -05:00
const resp = await clients.llm.request({
system: promptResp.system,
prompt: promptResp.prompt,
});
2026-04-05 21:09:33 -05:00
2026-06-01 16:22:25 -05:00
return resp.response;
}
const ScoredEdge = S.Struct({
id: S.String,
score: S.Number,
});
const ScoredEdgesFromJson = S.Array(ScoredEdge).pipe(S.fromJsonString);
const ScoredEdgeFromJson = ScoredEdge.pipe(S.fromJsonString);
const decodeScoredEdges = S.decodeUnknownOption(ScoredEdgesFromJson);
const decodeScoredEdge = S.decodeUnknownOption(ScoredEdgeFromJson);
function parseScoredEdges(responseText: string): Array<typeof ScoredEdge.Type> {
const parsedArray = decodeScoredEdges(responseText);
if (O.isSome(parsedArray)) {
return Array.from(parsedArray.value);
}
const scored: Array<typeof ScoredEdge.Type> = [];
for (const line of responseText.split("\n")) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
const parsedLine = decodeScoredEdge(trimmed);
if (O.isSome(parsedLine)) {
scored.push(parsedLine.value);
}
2026-04-05 21:09:33 -05:00
}
2026-06-01 16:22:25 -05:00
return scored;
2026-04-05 21:09:33 -05:00
}
2026-06-01 16:22:25 -05:00
export function termToString(term: Term): string {
2026-04-05 21:09:33 -05:00
switch (term.type) {
case "IRI":
return term.iri;
case "LITERAL":
return term.value;
case "BLANK":
return `_:${term.id}`;
case "TRIPLE":
return `(${termToString(term.triple.s)} ${termToString(term.triple.p)} ${termToString(term.triple.o)})`;
}
}
2026-04-05 22:44:45 -05:00
2026-06-01 16:22:25 -05:00
export function stringToTerm(value: string): Term {
2026-04-05 22:44:45 -05:00
if (value.startsWith("http://") || value.startsWith("https://")) {
return { type: "IRI", iri: value };
}
if (value.startsWith("_:")) {
return { type: "BLANK", id: value.slice(2) };
}
return { type: "LITERAL", value };
}