|
1 | 1 | import 'package:isar/isar.dart'; |
2 | 2 | import 'package:isar_agent_memory/isar_agent_memory.dart'; |
| 3 | +import 'llm_adapter.dart'; |
3 | 4 |
|
4 | 5 | /// Extension for HiRAG (Hierarchical RAG) capabilities. |
5 | 6 | /// |
6 | 7 | /// This extension adds methods to manage hierarchical layers of knowledge. |
7 | 8 | extension HierarchicalMemoryGraph on MemoryGraph { |
8 | | - |
9 | 9 | /// Relation type for summary edges (Child -> Summary). |
10 | 10 | static const String relationSummaryOf = 'summary_of'; |
11 | 11 |
|
12 | 12 | /// Relation type for part-of edges (Part -> Whole). |
13 | 13 | static const String relationPartOf = 'part_of'; |
14 | 14 |
|
| 15 | + /// Automatically summarizes a given layer by grouping nodes and using an LLM. |
| 16 | + /// |
| 17 | + /// [layerIndex]: The layer to summarize. |
| 18 | + /// [llmAdapter]: The adapter to the Large Language Model for summary generation. |
| 19 | + /// [promptTemplate]: A function to format the content into a prompt for the LLM. |
| 20 | + /// If null, a default template is used. |
| 21 | + /// |
| 22 | + /// Returns the ID of the newly created summary node. |
| 23 | + Future<int> autoSummarizeLayer({ |
| 24 | + required int layerIndex, |
| 25 | + required LLMAdapter llmAdapter, |
| 26 | + String Function(String content)? promptTemplate, |
| 27 | + }) async { |
| 28 | + // 1. Get all nodes in the target layer |
| 29 | + final nodes = await getNodesByLayer(layerIndex); |
| 30 | + if (nodes.isEmpty) { |
| 31 | + throw Exception('No nodes found in layer $layerIndex to summarize.'); |
| 32 | + } |
| 33 | + |
| 34 | + // 2. Combine content for the LLM prompt |
| 35 | + final contentToSummarize = nodes.map((n) => n.content).join('\n---\n'); |
| 36 | + final prompt = promptTemplate != null |
| 37 | + ? promptTemplate(contentToSummarize) |
| 38 | + : 'Summarize the following content into a coherent paragraph: \n\n$contentToSummarize'; |
| 39 | + |
| 40 | + // 3. Call LLM to generate summary |
| 41 | + final summaryContent = await llmAdapter.generate(prompt); |
| 42 | + |
| 43 | + // 4. Create the summary node in the next layer |
| 44 | + final childNodeIds = nodes.map((n) => n.id).toList(); |
| 45 | + final summaryNodeId = await createSummaryNode( |
| 46 | + summaryContent: summaryContent, |
| 47 | + childNodeIds: childNodeIds, |
| 48 | + layer: layerIndex + 1, |
| 49 | + ); |
| 50 | + |
| 51 | + // 5. Create 'part_of' relationships from the summary to its parts |
| 52 | + for (final childId in childNodeIds) { |
| 53 | + await storeEdge(MemoryEdge( |
| 54 | + fromNodeId: summaryNodeId, |
| 55 | + toNodeId: childId, |
| 56 | + relation: relationPartOf, |
| 57 | + )); |
| 58 | + } |
| 59 | + |
| 60 | + return summaryNodeId; |
| 61 | + } |
| 62 | + |
15 | 63 | /// Creates a summary node for a list of [childNodeIds]. |
16 | 64 | /// |
17 | 65 | /// [summaryContent]: The summarized text. |
@@ -56,4 +104,57 @@ extension HierarchicalMemoryGraph on MemoryGraph { |
56 | 104 | Future<List<MemoryNode>> getNodesByLayer(int layer) async { |
57 | 105 | return await isar.memoryNodes.filter().layerEqualTo(layer).findAll(); |
58 | 106 | } |
| 107 | + |
| 108 | + /// Performs a multi-hop search, enriching results with hierarchical context. |
| 109 | + /// |
| 110 | + /// [queryEmbedding]: The embedding of the search query. |
| 111 | + /// [maxHops]: The maximum number of upward traversals (default is 2). |
| 112 | + /// [topK]: The number of initial results to fetch from the base layer. |
| 113 | + /// |
| 114 | + /// Returns a list of enriched results, where each result includes the base node |
| 115 | + /// and a list of parent (summary) nodes. |
| 116 | + Future<List<({MemoryNode node, List<MemoryNode> context})>> multiHopSearch({ |
| 117 | + required List<double> queryEmbedding, |
| 118 | + int maxHops = 2, |
| 119 | + int topK = 5, |
| 120 | + }) async { |
| 121 | + // 1. Semantic search on the base layer (layer 0) |
| 122 | + final initialResults = |
| 123 | + await semanticSearch(queryEmbedding, topK: topK, layer: 0); |
| 124 | + |
| 125 | + final enrichedResults = |
| 126 | + <({MemoryNode node, List<MemoryNode> context})>[]; |
| 127 | + |
| 128 | + // 2. Traverse upwards for each result |
| 129 | + for (final result in initialResults) { |
| 130 | + final baseNode = result.node; |
| 131 | + final context = <MemoryNode>[]; |
| 132 | + var currentNode = baseNode; |
| 133 | + var hops = 0; |
| 134 | + |
| 135 | + while (hops < maxHops) { |
| 136 | + // Find edges where the current node is the 'from' node and relation is 'summary_of' |
| 137 | + final edges = await isar.memoryEdges |
| 138 | + .filter() |
| 139 | + .fromNodeIdEqualTo(currentNode.id) |
| 140 | + .relationEqualTo(relationSummaryOf) |
| 141 | + .findAll(); |
| 142 | + |
| 143 | + if (edges.isEmpty) break; |
| 144 | + |
| 145 | + // Follow the first summary edge to the parent |
| 146 | + final parentNodeId = edges.first.toNodeId; |
| 147 | + final parentNode = await getNode(parentNodeId); |
| 148 | + |
| 149 | + if (parentNode == null) break; |
| 150 | + |
| 151 | + context.add(parentNode); |
| 152 | + currentNode = parentNode; |
| 153 | + hops++; |
| 154 | + } |
| 155 | + enrichedResults.add((node: baseNode, context: context)); |
| 156 | + } |
| 157 | + |
| 158 | + return enrichedResults; |
| 159 | + } |
59 | 160 | } |
0 commit comments