图算法与接口

main
林名赫 4 months ago
parent 222135cdc3
commit a0180d4b81

@ -2,7 +2,7 @@
This module contains all graph-related routes for the LightRAG API.
"""
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, List
import traceback
from fastapi import APIRouter, Depends, Query, HTTPException
from pydantic import BaseModel
@ -170,4 +170,49 @@ def create_graph_routes(rag, api_key: Optional[str] = None):
status_code=500, detail=f"Error updating relation: {str(e)}"
)
@router.get("/graphs/note", dependencies=[Depends(combined_auth)])
async def get_knowledge_graph_note(
label: str = Query(..., description="Label to get knowledge graph for"),
max_depth: int = Query(3, description="Maximum depth of graph", ge=1),
max_nodes: int = Query(1000, description="Maximum nodes to return", ge=1),
note_ids: List[str] = Query(
...,
description="List of note IDs to get knowledge graph for",
example=["file1.txt", "file2.docx", "file3.pdf"]
),
):
"""
Retrieve a connected subgraph of nodes where the label includes the specified label.
When reducing the number of nodes, the prioritization criteria are as follows:
1. Hops(path) to the staring node take precedence
2. Followed by the degree of the nodes
Args:
label (str): Label of the starting node
max_depth (int, optional): Maximum depth of the subgraph,Defaults to 3
max_nodes: Maxiumu nodes to return
note_ids: List of note ids to filter nodes by
Returns:
Dict[str, List[str]]: Knowledge graph for label
"""
try:
return await rag.get_knowledge_graph_note(
node_label=label,
max_depth=max_depth,
max_nodes=max_nodes,
note_ids=note_ids,
)
except Exception as e:
logger.error(f"Error getting knowledge graph for label '{label}': {str(e)}")
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error getting knowledge graph: {str(e)}"
)
return router

@ -1,7 +1,7 @@
import os
import re
from dataclasses import dataclass
from typing import final
from typing import final, Optional
import configparser
@ -1214,6 +1214,232 @@ class Neo4JStorage(BaseGraphStorage):
)
return result
async def get_knowledge_graph_note(
self,
node_label: str,
max_depth: int = 3,
max_nodes: int = None,
note_ids: Optional[list[str]] = None,
) -> KnowledgeGraph:
"""
Retrieve a connected subgraph of nodes where the label includes the specified `node_label`.
Args:
node_label: Label of the starting node, * means all nodes
max_depth: Maximum depth of the subgraph, Defaults to 3
max_nodes: Maxiumu nodes to return by BFS, Defaults to 1000
note_ids: List of note ids to filter nodes by
Returns:
KnowledgeGraph object containing nodes and edges, with an is_truncated flag
indicating whether the graph was truncated due to max_nodes limit
"""
# Get max_nodes from global_config if not provided
if max_nodes is None:
max_nodes = self.global_config.get("max_graph_nodes", 1000)
else:
# Limit max_nodes to not exceed global_config max_graph_nodes
max_nodes = min(max_nodes, self.global_config.get("max_graph_nodes", 1000))
workspace_label = self._get_workspace_label()
result = KnowledgeGraph()
seen_nodes = set()
seen_edges = set()
async with self._driver.session(
database=self._DATABASE, default_access_mode="READ"
) as session:
try:
if node_label == "*":
# First check total node count to determine if graph is truncated
count_query = (
f"""
MATCH (n:`{workspace_label}`)
WHERE $note_ids IS NULL OR n.file_path IN $note_ids
RETURN count(n) as total
"""
)
count_result = None
try:
count_result = await session.run(count_query,{"note_ids": note_ids})
count_record = await count_result.single()
if count_record and count_record["total"] > max_nodes:
result.is_truncated = True
logger.info(
f"Graph truncated: {count_record['total']} nodes found, limited to {max_nodes}"
)
finally:
if count_result:
await count_result.consume()
# Run main query to get nodes with highest degree
main_query = f"""
MATCH (n:`{workspace_label}`)
WHERE $note_ids IS NULL OR n.file_path IN $note_ids
OPTIONAL MATCH (n)-[r]-()
WITH n, COALESCE(count(r), 0) AS degree
ORDER BY degree DESC
LIMIT $max_nodes
WITH collect({{node: n}}) AS filtered_nodes
UNWIND filtered_nodes AS node_info
WITH collect(node_info.node) AS kept_nodes, filtered_nodes
OPTIONAL MATCH (a)-[r]-(b)
WHERE a IN kept_nodes AND b IN kept_nodes
RETURN filtered_nodes AS node_info,
collect(DISTINCT r) AS relationships
"""
result_set = None
try:
result_set = await session.run(
main_query,
{"max_nodes": max_nodes,
"note_ids": note_ids},
)
record = await result_set.single()
finally:
if result_set:
await result_set.consume()
else:
# return await self._robust_fallback(node_label, max_depth, max_nodes)
# First try without limit to check if we need to truncate
full_query = f"""
MATCH (start:`{workspace_label}`)
WHERE start.entity_id = $entity_id
WITH start
CALL apoc.path.subgraphAll(start, {{
relationshipFilter: '',
labelFilter: '{workspace_label}',
minLevel: 0,
maxLevel: $max_depth,
bfs: true
}})
YIELD nodes, relationships
WITH nodes, relationships, size(nodes) AS total_nodes
UNWIND nodes AS node
WITH collect({{node: node}}) AS node_info, relationships, total_nodes
RETURN node_info, relationships, total_nodes
"""
# Try to get full result
full_result = None
try:
full_result = await session.run(
full_query,
{
"entity_id": node_label,
"max_depth": max_depth,
},
)
full_record = await full_result.single()
# If no record found, return empty KnowledgeGraph
if not full_record:
logger.debug(f"No nodes found for entity_id: {node_label}")
return result
# If record found, check node count
total_nodes = full_record["total_nodes"]
if total_nodes <= max_nodes:
# If node count is within limit, use full result directly
logger.debug(
f"Using full result with {total_nodes} nodes (no truncation needed)"
)
record = full_record
else:
# If node count exceeds limit, set truncated flag and run limited query
result.is_truncated = True
logger.info(
f"Graph truncated: {total_nodes} nodes found, breadth-first search limited to {max_nodes}"
)
# Run limited query
limited_query = f"""
MATCH (start:`{workspace_label}`)
WHERE start.entity_id = $entity_id
WITH start
CALL apoc.path.subgraphAll(start, {{
relationshipFilter: '',
labelFilter: '{workspace_label}',
minLevel: 0,
maxLevel: $max_depth,
limit: $max_nodes,
bfs: true
}})
YIELD nodes, relationships
UNWIND nodes AS node
WITH collect({{node: node}}) AS node_info, relationships
RETURN node_info, relationships
"""
result_set = None
try:
result_set = await session.run(
limited_query,
{
"entity_id": node_label,
"max_depth": max_depth,
"max_nodes": max_nodes,
},
)
record = await result_set.single()
finally:
if result_set:
await result_set.consume()
finally:
if full_result:
await full_result.consume()
if record:
# Handle nodes (compatible with multi-label cases)
for node_info in record["node_info"]:
node = node_info["node"]
node_id = node.id
if node_id not in seen_nodes:
result.nodes.append(
KnowledgeGraphNode(
id=f"{node_id}",
labels=[node.get("entity_id")],
properties=dict(node),
)
)
seen_nodes.add(node_id)
# Handle relationships (including direction information)
for rel in record["relationships"]:
edge_id = rel.id
if edge_id not in seen_edges:
start = rel.start_node
end = rel.end_node
result.edges.append(
KnowledgeGraphEdge(
id=f"{edge_id}",
type=rel.type,
source=f"{start.id}",
target=f"{end.id}",
properties=dict(rel),
)
)
seen_edges.add(edge_id)
logger.info(
f"Subgraph query successful | Node count: {len(result.nodes)} | Edge count: {len(result.edges)}"
)
except neo4jExceptions.ClientError as e:
logger.warning(f"APOC plugin error: {str(e)}")
if node_label != "*":
logger.warning(
"Neo4j: falling back to basic Cypher recursive search..."
)
return await self._robust_fallback(node_label, max_depth, max_nodes)
else:
logger.warning(
"Neo4j: APOC plugin error with wildcard query, returning empty result"
)
return result
async def _robust_fallback(
self, node_label: str, max_depth: int, max_nodes: int

@ -564,6 +564,35 @@ class LightRAG:
return await self.chunk_entity_relation_graph.get_knowledge_graph(
node_label, max_depth, max_nodes
)
async def get_knowledge_graph_note(
self,
node_label: str,
max_depth: int = 3,
max_nodes: int = None,
note_ids: Optional[List[str]] = None, # Add note_ids parameter
) -> KnowledgeGraph:
"""Get knowledge graph for a given label
Args:
node_label (str): Label to get knowledge graph for
max_depth (int): Maximum depth of graph
max_nodes (int, optional): Maximum number of nodes to return. Defaults to self.max_graph_nodes.
note_ids (List[str], optional): List of note IDs to include in the graph. Defaults to None.
Returns:
KnowledgeGraph: Knowledge graph containing nodes and edges
"""
# Use self.max_graph_nodes as default if max_nodes is None
if max_nodes is None:
max_nodes = self.max_graph_nodes
else:
# Limit max_nodes to not exceed self.max_graph_nodes
max_nodes = min(max_nodes, self.max_graph_nodes)
return await self.chunk_entity_relation_graph.get_knowledge_graph_note(
node_label, max_depth, max_nodes, note_ids
)
def _get_storage_class(self, storage_name: str) -> Callable[..., Any]:
import_path = STORAGES[storage_name]

@ -0,0 +1,217 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>知识图谱可视化(接口调用修复版)</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/extension/graph.min.js"></script>
<style>
.container { width: 1200px; margin: 20px auto; }
#param-form {
margin-bottom: 20px;
padding: 15px;
border: 1px solid #eee;
border-radius: 4px;
}
.form-group {
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 10px;
}
.form-group label {
width: 120px;
font-weight: bold;
}
.form-group input {
flex: 1;
padding: 6px 8px;
border: 1px solid #ddd;
border-radius: 3px;
}
.form-group input[type="number"] {
width: 100px;
}
#note-ids-input {
display: flex;
gap: 5px;
align-items: center;
}
#note-ids-input input {
flex: 1;
}
button {
padding: 8px 16px;
background: #4285f4;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #3367d6;
}
#graph-container {
width: 100%;
height: 700px;
border: 1px solid #eee;
}
#node-info {
margin-top: 10px;
padding: 10px;
border: 1px solid #eee;
border-radius: 4px;
}
.required-mark {
color: red;
}
.error-message {
color: #dc3545;
}
.loading-message {
color: #6c757d;
}
</style>
</head>
<body>
<div class="container">
<form id="param-form">
<div class="form-group">
<label>标签<label class="required-mark">*</label></label>
<input type="text" id="label" placeholder="输入标签(支持通配符*" required value="*">
</div>
<div class="form-group">
<label>最大深度:</label>
<input type="number" id="max_depth" value="3" min="1">
</div>
<div class="form-group">
<label>最大节点数:</label>
<input type="number" id="max_nodes" value="100" min="1">
</div>
<div class="form-group">
<label>文档ID<label class="required-mark">*</label></label>
<input type="text" id="note_ids" placeholder="多个用逗号分隔(如:马克思主义原理.txt" required value="马克思主义原理.txt">
</div>
<button type="submit">加载知识图谱</button>
</form>
<div id="graph-container"></div>
<div id="node-info">请点击按钮调用接口</div>
</div>
<script>
document.getElementById('param-form').addEventListener('submit', function(e) {
e.preventDefault();
const nodeInfo = document.getElementById('node-info');
nodeInfo.className = 'loading-message';
nodeInfo.innerHTML = '正在加载...';
// 1. 收集参数(不手动编码)
const params = {
label: document.getElementById('label').value.trim(),
max_depth: document.getElementById('max_depth').value.trim() || 3,
max_nodes: document.getElementById('max_nodes').value.trim() || 100,
note_ids: document.getElementById('note_ids').value.trim().split(',').map(id => id.trim()).filter(Boolean)
};
if (!params.label || params.note_ids.length === 0) {
nodeInfo.className = 'error-message';
nodeInfo.innerHTML = '请填写必填参数';
return;
}
// 2. 构建URL关键不手动编码让URLSearchParams自动处理
const baseUrl = 'http://localhost:9621/graphs/note';
const urlParams = new URLSearchParams();
// 直接传原始值URLSearchParams会自动编码一次正确格式
urlParams.append('label', params.label);
urlParams.append('max_depth', params.max_depth);
urlParams.append('max_nodes', params.max_nodes);
params.note_ids.forEach(id => urlParams.append('note_ids', id));
const fullUrl = `${baseUrl}?${urlParams.toString()}`;
console.log('调用接口:', fullUrl); // 此时日志中的note_ids应为正确的一次编码格式
// 3. 发起请求
fetch(fullUrl, {
method: 'GET',
mode: 'cors',
headers: { 'Accept': 'application/json' }
})
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then(kgData => {
if (!kgData.nodes || !kgData.edges) throw new Error('数据格式错误');
// 4. 处理节点连接数
const nodeLinkCount = {};
kgData.nodes.forEach(node => nodeLinkCount[node.id] = 0);
kgData.edges.forEach(edge => {
nodeLinkCount[edge.source]++;
nodeLinkCount[edge.target]++;
});
// 5. 转换为ECharts格式
const echartsNodes = kgData.nodes.map(node => ({
id: node.id,
name: node.labels[0],
category: node.properties.entity_type,
properties: node.properties,
symbolSize: 20 + (nodeLinkCount[node.id] * 3)
}));
const echartsLinks = kgData.edges.map(edge => ({
source: edge.source,
target: edge.target,
properties: edge.properties
}));
const categories = Array.from(new Set(echartsNodes.map(n => n.category))).map(type => ({ name: type }));
// 6. 渲染图表
const chart = echarts.init(document.getElementById('graph-container'));
chart.setOption({
tooltip: { formatter: '{b}' },
legend: { data: categories.map(c => c.name), bottom: 10 },
series: [{
type: 'graph',
layout: 'force',
roam: true,
draggable: true,
force: { repulsion: 300, edgeLength: 100, gravity: 0.1, iterations: 50 },
label: { show: true, fontSize: 12 },
categories: categories,
data: echartsNodes,
links: echartsLinks,
emphasis: { focus: 'adjacency', lineStyle: { width: 5 } }
}]
});
// 7. 点击事件
chart.on('click', (params) => {
// 保持原有点击逻辑不变
if (params.dataType === 'node') {
const p = params.data.properties;
nodeInfo.innerHTML = `<strong>${params.name}</strong> | 类型:${p.entity_type} | 连接数:${nodeLinkCount[params.data.id]}<br>描述:${p.description?.slice(0, 100)}...`;
} else if (params.dataType === 'edge') {
const p = params.data.properties;
const sourceNode = echartsNodes.find(n => n.id === params.data.source);
const targetNode = echartsNodes.find(n => n.id === params.data.target);
nodeInfo.innerHTML = `<strong>边信息</strong> | ${sourceNode?.name} → ${targetNode?.name}<br>描述:${p.description}`;
}
});
window.addEventListener('resize', () => chart.resize());
nodeInfo.className = '';
nodeInfo.innerHTML = '加载完成 | 点击节点/边查看详情';
})
.catch(error => {
console.error('错误:', error);
nodeInfo.className = 'error-message';
nodeInfo.innerHTML = `加载失败:${error.message}`;
});
});
</script>
</body>
</html>

@ -108,7 +108,7 @@
z-index: 9999; /* 确保启动页在最上层 */
}
.custom-splash-bg {
background-image: url('C:/Users/17850/Desktop/灵简/backg.jpg');
background-image: url('backg.jpg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
@ -130,7 +130,7 @@
<!-- 启动页 -->
<div class="splash-screen fixed inset-0 custom-splash-bg flex flex-col items-center justify-center transition-opacity duration-500" id="splashScreen">
<img src="C:\Users\17850\Desktop\灵简\tubiao.png" alt="灵简图标" class="w-64 h-64 rounded-lg mb-6">
<img src="tubiao.png" alt="灵简图标" class="w-64 h-64 rounded-lg mb-6">
<p class="text-3xl font-semibold text-dark">灵简 - 灵性之“简”</p>
</div>
@ -140,7 +140,7 @@
/* 登录界面背景 */
#login-screen {
/* 替换为你的背景图片路径 */
background-image: url('C:/Users/17850/Desktop/灵简/backg.jpg');
background-image: url('backg.jpg');
background-size: cover; /* 图片覆盖整个屏幕 */
background-position: center; /* 图片居中 */
background-repeat: no-repeat; /* 不重复 */
@ -161,7 +161,7 @@
<!-- 品牌标识 -->
<div class="text-center mb-8">
<div class="flex items-center justify-center gap-2 mb-3">
<img src="C:\Users\17850\Desktop\灵简\tubiao.png" alt="灵简图标" class="w-10 h-10 rounded-lg">
<img src="tubiao.png" alt="灵简图标" class="w-10 h-10 rounded-lg">
<span class="text-2xl font-bold text-dark">灵简</span>
</div>
<h1 class="text-xl font-semibold text-dark">欢迎回来</h1>
@ -361,7 +361,7 @@
<!-- 标题栏模拟Windows窗口标题栏 -->
<header class="bg-sidebar text-white px-4 py-2 flex items-center justify-between select-none">
<div class="flex items-center gap-3">
<img src="C:\Users\17850\Desktop\灵简\tubiao.png" alt="灵简图标" class="dashboard-logo w-6 h-6"> <!-- 替换绝对路径为相对路径 -->
<img src="tubiao.png" alt="灵简图标" class="dashboard-logo w-6 h-6"> <!-- 替换绝对路径为相对路径 -->
<span>灵简 - 灵性之“简”</span>
</div>
</header>
@ -387,7 +387,7 @@
<!-- 用户信息区域 -->
<div class="p-4 border-t border-gray-700 flex items-center justify-between">
<div class="flex items-center gap-3">
<img src="C:\Users\17850\Desktop\灵简\tubiao.png" alt="用户头像" class="w-8 h-8 rounded-full"> <!-- 替换绝对路径 -->
<img src="tubiao.png" alt="用户头像" class="w-8 h-8 rounded-full"> <!-- 替换绝对路径 -->
<div>
<div class="text-sm font-medium" id="user-name">昵称</div>
<div class="text-xs text-text-muted" id="user-grade-major">年级 | 专业</div>
@ -510,7 +510,7 @@
<!-- 系统消息示例 -->
<div class="max-w-[80%]">
<div class="flex items-center gap-2 mb-1">
<img src="C:\Users\17850\Desktop\灵简\tubiao.png" alt="AI" class="w-6 h-6 rounded-full">
<img src="tubiao.png" alt="AI" class="w-6 h-6 rounded-full">
<span class="text-sm font-medium text-gray-700">灵简助手</span>
</div>
<div class="bg-gray-100 p-3 rounded-lg rounded-tl-none">
@ -871,7 +871,7 @@
// DOM元素
let conversationHistory = [];
// 基础地址只保留 域名/IP + 端口
const API_BASE_URL = 'http://192.168.208.16:9621';
const API_BASE_URL = 'http://192.168.240.16:9621';
// 调用接口时,再拼接具体路径(如上传接口)
const uploadUrl = `${API_BASE_URL}/documents/upload`;
const splashScreen = document.getElementById('splashScreen');

Loading…
Cancel
Save