当知识图谱学会“抱团“:GraphRAG 社区检测的原理与 Go 实战
❝本文从一个直觉出发——"物以类聚,人以群分",逐步讲解知识图谱中社区检测的核心原理,并结合一个生产级 Go 项目的完整实现,带你理解从论文到代码的全过程。
❞
一、从朋友圈说起:什么是社区?
打开你的微信,看看你的好友列表。你会发现一个有趣的现象:你的大学同学之间互相认识的概率很高,你的同事之间也是如此,但你的大学同学和你的同事之间大多互不相识。
「这就是社区(Community)的直觉定义:一组内部连接紧密、与外部连接稀疏的节点集合。」
放到知识图谱的语境里:
「节点」= 实体(人物、技术、组织、概念……)
「边」= 实体间的关系(uses、depends_on、manages……)
「社区」= 一群关系紧密的实体,它们往往在讨论同一个主题
比如,在一份关于微服务架构的文档中,"Kubernetes""Docker""Helm""容器调度"这些实体大概率会被聚成一个社区,而"MySQL""Redis""数据分片"会聚成另一个。
二、为什么 RAG 需要社区?
传统的 RAG(检索增强生成)流程是这样的:用户提问 → 向量检索找到相关文本块 → 拼成 Prompt 喂给大模型。
这个流程在回答具体问题时很好用,但遇到宏观问题就力不从心了:
❝"这份文档集主要讨论了哪些技术领域?它们之间有什么关系?"
❞
向量检索本质上是"点对点"的——它找到的是与问题最相似的片段,但无法给出全局视角。
「社区检测正是为了弥补这个缺口。」微软在 2024 年发表的 GraphRAG 论文提出了一个优雅的方案:
从文档中抽取实体和关系,构建知识图谱
对图谱进行社区检测,把紧密相关的实体聚成社区
用 LLM 为每个社区生成摘要
回答宏观问题时,用社区摘要代替原始文本块
这就像给一本书自动生成了"章节大纲"——每个社区就是一个主题章节,摘要就是章节概述。
三、Leiden 算法:社区检测的核心引擎
3.1 从模块度说起
社区检测的本质是一个「优化问题」:如何划分节点,使得社区内部的边尽可能多,社区之间的边尽可能少?
衡量这个"好坏"的指标叫「模块度(Modularity)」,它的核心思想是:
❝社区内部的实际边数 vs 随机情况下的期望边数
❞
如果一个社区内部的连接远超随机期望,说明这些节点确实"有理由"聚在一起。
3.2 Leiden 算法的策略
Leiden 算法(2019 年提出,是 Louvain 算法的改进版)的核心策略非常朴素:「贪心迭代」。
想象你在一个大型会议上,每个人一开始都是独自一桌。算法做的事情是:
「初始化」:每个人(节点)自成一桌(社区)
「贪心移动」:逐一考察每个人,看他移到邻桌后"桌内话题的一致性"(模块度)是否提升,如果是,就让他换桌
「反复迭代」:不断重复步骤 2,直到没人想换桌为止
用数学语言描述,节点 i 从当前社区移到社区 c 的模块度增益为:
ΔQ = w(i,c) - γ · k_i · s_c / (2m)其中:
w(i,c):节点 i 到社区 c 内所有节点的边权之和("我跟那桌人有多熟")k_i:节点 i 的度("我总共认识多少人")s_c:社区 c 所有节点的总度("那桌人总共认识多少人")m:全图的总边权("所有人的关系总数")γ:分辨率参数(控制社区大小的旋钮,越大社区越小)
「关键直觉」:w(i,c)是"吸引力"——我跟那桌人的直接关系;γ · k_i · s_c / (2m)是"排斥力"——社交网络的随机期望。只有吸引力超过排斥力时,换桌才有意义。
3.3 分层检测:从小圈子到大主题
一层社区往往不够。就像一个公司可以按"小组 → 部门 → 事业群"层层聚合一样,知识图谱中的社区也需要多层结构。
GraphRAG 的做法是:
「Level 0」:在原始图上检测基础社区(分辨率 γ = 1.0)
「Level 1」:把 Level 0 的社区当作节点,构建"超图",以更低的分辨率(γ/2)再次检测
「Level 2+」:继续递归,分辨率逐层减半(γ/4, γ/8, ...),社区越来越大
「终止条件」:只剩一个社区,或无法进一步合并
这样就得到了一棵"社区树":叶子是具体实体,底层社区是细粒度主题,顶层社区是宏观领域。
四、Go 实战:从零实现社区检测
下面以实际业务实践代码,看看上述理论是怎么落地的。
4.1 数据模型设计
首先是社区的领域模型:
// Community 知识图谱社区,借鉴 GraphRAG 的社区检测和分层摘要 type Community struct { ID string // 唯一标识 CollectionID string // 所属知识库 Level int // 层级:0=基础层,1+=聚合层 Title string // LLM 生成的标题 Summary string // LLM 生成的摘要 EntityIDs []string// 包含的实体列表 ParentID string // 上层社区 ID ChildIDs []string// 子社区 ID 列表 Rank float64 // 重要性 = 节点数 × 内部边密度 CreatedAt time.Time UpdatedAt time.Time }几个设计要点:
「Level」和「ParentID/ChildIDs」支撑了分层树形结构
「Rank」用于搜索时排序,定义为
节点数 × 内部边密度,兼顾了规模和紧密度「Title + Summary」是社区的"灵魂",由 LLM 生成,这也是 GraphRAG 最精妙的设计之一
对应的图谱配置:
type GraphConfig struct { Enabled bool // 是否开启知识图谱 EntityTypes []string // 支持的实体类型 RelationTypes []string // 支持的关系类型 CommunityEnabled bool // 是否开启社区检测 MaxCommunityLevel int // 最大社区层级数(默认 3) }4.2 邻接图:稀疏表示
社区检测的第一步是构建高效的图数据结构。我们使用稀疏邻接矩阵来表示:
// adjacencyGraph 邻接图,用于社区检测 type adjacencyGraph struct { nodes []string // 节点名称列表(排序后) nodeIndex map[string]int // 名称 → 索引(O(1) 查找) adjWeight []map[int]float64 // 稀疏邻接权重矩阵 totalWeight float64 // 总边权重(模块度公式中的 m) }为什么用[]map[int]float64而不是二维数组?因为知识图谱通常是「稀疏图」——节点很多但每个节点的邻居相对较少。稀疏表示既省内存,遍历邻居时也更高效。
构建邻接图的过程很直接——从 Neo4j 拉取所有边,构建双向加权邻接关系:
func newAdjacencyGraph(edges []repository.EntityEdge) *adjacencyGraph { // 1. 收集所有节点并排序(确保结果确定性) nodeSet := make(map[string]struct{}) for _, e := range edges { nodeSet[e.SourceName] = struct{}{} nodeSet[e.TargetName] = struct{}{} } nodes := make([]string, 0, len(nodeSet)) for n := range nodeSet { nodes = append(nodes, n) } sort.Strings(nodes) // 2. 构建稀疏邻接矩阵(无向图,双向存储) adjWeight := make([]map[int]float64, len(nodes)) totalWeight := 0.0 for _, e := range edges { si, ti := nodeIndex[e.SourceName], nodeIndex[e.TargetName] w := e.Weight adjWeight[si][ti] += w adjWeight[ti][si] += w // 无向图 totalWeight += w } return &adjacencyGraph{nodes: nodes, adjWeight: adjWeight, totalWeight: totalWeight} }4.3 Leiden 核心算法实现
这是整个社区检测的心脏——简化版 Leiden 算法。核心逻辑不到 80 行:
func (d *CommunityDetector) leidenDetect(graph *adjacencyGraph, resolution float64) map[int]int { n := len(graph.nodes) community := make(map[int]int, n) // 初始化:每个节点自成一个社区 for i := 0; i < n; i++ { community[i] = i } // 迭代优化模块度(最多 50 轮) improved := true for iter := 0; iter < 50 && improved; iter++ { improved = false for i := 0; i < n; i++ { bestCommunity := community[i] bestGain := 0.0 // 统计节点 i 到各邻居社区的边权之和 neighborCommunities := make(map[int]float64) for j, w := range graph.adjWeight[i] { neighborCommunities[community[j]] += w } ki := graph.nodeDegree(i) currentComm := community[i] for c, wic := range neighborCommunities { if c == currentComm { continue } // 计算社区 c 的总度 sc := 0.0 for node, comm := range community { if comm == c { sc += graph.nodeDegree(node) } } // 模块度增益 ΔQ = w(i,c) - γ·k_i·s_c/(2m) gain := wic - resolution*ki*sc/(2*graph.totalWeight) if gain > bestGain { bestGain = gain bestCommunity = c } } if bestCommunity != currentComm { community[i] = bestCommunity improved = true } } } // 重新编号,使社区 ID 从 0 连续 remap := make(map[int]int) nextID := 0 for i := 0; i < n; i++ { if _, ok := remap[community[i]]; !ok { remap[community[i]] = nextID nextID++ } } // ... return result }逐段解读:
「初始化阶段」:每个节点独占一个社区(社区数 = 节点数)
「贪心迭代」:对每个节点,计算移到每个邻居社区的增益,选择增益最大的(如果大于 0 就移动)
「收敛判定」:一整轮下来没有任何节点移动,说明已达到局部最优
「ID 重映射」:让社区编号连续,方便后续处理
❝「与原版 Leiden 的差异」:完整的 Leiden 算法在贪心移动后还有一个 refinement(精炼)阶段,用于避免陷入局部最优。我们的简化版省略了这一步——在 RAG 场景下,社区检测主要服务于摘要生成,不需要追求算法理论上的最优解,"足够好"就行。
❞
4.4 分层递归:从社区到超社区
基础社区检测完成后,需要向上聚合。关键在于「超图(Super Graph)构建」——把社区当节点,社区间的跨边权重求和作为新的边权:
func (d *CommunityDetector) buildSuperGraph( communities []*domain.Community, originalGraph *adjacencyGraph, ) *adjacencyGraph { // 1. 建立 实体名 → 社区索引 的映射 entityToCommunity := make(map[string]int) for i, c := range communities { for _, eName := range c.EntityIDs { entityToCommunity[eName] = i } } // 2. 遍历原图所有边,累加跨社区边权 edgeMap := make(map[[2]int]float64) for si, adj := range originalGraph.adjWeight { srcComm := entityToCommunity[originalGraph.nodes[si]] for ti, w := range adj { tgtComm := entityToCommunity[originalGraph.nodes[ti]] if srcComm != tgtComm { key := [2]int{min(srcComm, tgtComm), max(srcComm, tgtComm)} edgeMap[key] += w } } } // 3. 构建新的邻接图 var edges []repository.EntityEdge for key, w := range edgeMap { edges = append(edges, repository.EntityEdge{ SourceName: fmt.Sprintf("community_%d", key[0]), TargetName: fmt.Sprintf("community_%d", key[1]), Weight: w, }) } return newAdjacencyGraph(edges) }然后在DetectAndSummarize的主循环中,递归地检测和合并:
prevCommunities := level0Communities for level := 1; level < d.maxLevel; level++ { iflen(prevCommunities) <= 1 { break// 只剩一个社区,无需继续 } superGraph := d.buildSuperGraph(prevCommunities, graph) iflen(superGraph.nodes) <= 1 { break } // 关键:分辨率逐层减半 → 社区越来越大 resolution := d.resolution / math.Pow(2, float64(level)) superCommunities := d.leidenDetect(superGraph, resolution) levelCommunities, err := d.buildHierarchicalCommunities( ctx, collectionID, level, superCommunities, prevCommunities, superGraph, ) iflen(levelCommunities) >= len(prevCommunities) { break// 无法进一步聚合 } prevCommunities = levelCommunities }三个终止条件确保算法不会无限递归:
只剩一个社区
超图节点不足
新一层社区数没有减少(说明分辨率已经无法继续合并了)
4.5 LLM 摘要生成:给社区赋予"灵魂"
社区检测给了我们一组紧密关联的实体,但这还不够——我们需要用自然语言描述"这群实体在讨论什么"。这就是 LLM 摘要的作用。
「基础社区(Level 0)的摘要生成」:
我们会把社区内的实体信息(名称、类型、描述)和内部关系(类型、描述)拼装成 Prompt:
func (d *CommunityDetector) generateCommunitySummary( ctx context.Context, entityDescriptions []string, relations []string, ) (string, string, error) { entitiesStr := strings.Join(entityDescriptions, "\n") iflen(relations) > 30 { relations = relations[:30] // 防止 prompt 过长 } relationsStr := strings.Join(relations, "\n") userPrompt := fmt.Sprintf("Entities:\n%s\n\nRelationships:\n%s", entitiesStr, relationsStr) response, err := d.llmClient.ChatCompletion( ctx, communitySummarySystemPrompt, userPrompt, ) // ... }System Prompt 告诉 LLM 输出格式:
TITLE: <最多 10 词的标题> SUMMARY: <2-3 句话的摘要>传给 LLM 的实体信息长这样:
Entities: - Kubernetes [technology]: 容器编排平台,用于自动化部署和管理容器化应用 - Docker [technology]: 容器化引擎,提供应用级别的隔离 - Helm [technology]: Kubernetes 包管理器 Relationships: Kubernetes -[uses]-> Docker (Kubernetes 使用 Docker 作为默认容器运行时) Helm -[manages]-> Kubernetes (Helm 管理 Kubernetes 上的应用部署)LLM 会返回类似:
TITLE: Container Orchestration Technology Stack SUMMARY: This community centers on container orchestration technologies. Kubernetes serves as the core platform, using Docker as its container runtime, while Helm provides package management capabilities for deploying applications on Kubernetes.「上层社区(Level 1+)的摘要」则更有意思——它不直接看实体,而是用子社区的摘要来生成更高层的概述:
func (d *CommunityDetector) generateHierarchicalSummary( ctx context.Context, childSummaries []string, ) (string, string, error) { userPrompt := fmt.Sprintf("Sub-community summaries:\n%s", strings.Join(childSummaries, "\n")) response, err := d.llmClient.ChatCompletion( ctx, hierarchicalSummarySystemPrompt, userPrompt, ) // ... }这种"摘要的摘要"策略非常巧妙——既避免了上层社区 Prompt 过长的问题,又天然实现了信息的逐层抽象。
4.6 社区重要性排名
不是所有社区都同样重要。我们用一个简单但有效的公式来排名:
rank = 节点数 × 内部边密度内部边密度的计算:
func (d *CommunityDetector) internalDensity(entityNames []string, graph *adjacencyGraph) float64 { n := len(entityNames) if n < 2 { return0 } internalEdges := 0.0 for _, name := range entityNames { idx := graph.nodeIndex[name] for neighborIdx, w := range graph.adjWeight[idx] { if neighborName在社区内 { internalEdges += w } } } internalEdges /= 2 // 无向图每条边计数两次 maxEdges := float64(n*(n-1)) / 2 // 完全图的边数 return internalEdges / maxEdges }「为什么是 "节点数 × 密度" 而不是单独的密度或节点数?」
纯密度:两个节点互连密度就是 1.0,但这样的社区没太大意义
纯节点数:100 个松散连接的节点不如 10 个紧密连接的有价值
两者相乘:兼顾了"规模"和"凝聚力"
五、社区在搜索中的使用
社区检测完成后,如何在搜索中发挥作用?我们实现了两种利用方式:
5.1 全局搜索(Global Search)
当用户提出宏观问题时,直接使用社区摘要回答:
func (s *SearchService) graphGlobalSearch( ctx context.Context, req *domain.SearchRequest, ) (*domain.SearchResponse, error) { // 获取 Level 0 社区,按 Rank 降序排列 communities, err := s.repos.Graph.ListCommunities( ctx, req.CollectionID, 0, ) return &domain.SearchResponse{Communities: communities}, nil }上层应用可以把 Top-K 社区的摘要拼接到 Prompt 中,让 LLM 基于这些"章节概述"来回答全局性问题。这就是 GraphRAG 论文中的 Global Search 策略。
5.2 混合搜索中的角色
在我们的混合检索(Hybrid Search)流程中,社区通过图谱局部搜索间接发挥作用:
用户提问 ├── Milvus 混合检索(Dense + BM25 Sparse,RRF 融合) │ → 向量+关键词匹配的文本块 ├── 图谱局部检索(Graph Local Search) │ → 从相关实体出发,多跳遍历关联的文本块 └── RRF 二次融合 → 最终排序结果其中 RRF(Reciprocal Rank Fusion)是一个经典的多路结果融合算法:
func (r *RRFReranker) Fuse(resultSets ...[]*domain.ChunkResult) []*domain.ChunkResult { rrfScores := make(map[string]float32) for _, results := range resultSets { for rank, result := range results { // 核心公式:1 / (k + rank),k=60 rrfScores[result.Chunk.ID] += 1.0 / (r.k + float32(rank+1)) } } // 按 RRF 分数降序排列 // ... }RRF 的优雅之处在于它「不需要对齐不同来源的分数」——无论向量检索的分数是 0~1 还是图谱检索的分数是任意值,RRF 只关心排名,天然免疫分数尺度不一致的问题。
六、完整流程串联
最后,让我们把所有模块在 Pipeline 中串联起来,看看一个文档从摄入到社区检测的完整生命周期:
文档上传 │ ▼ 阶段1: 文档解析 ─── 支持 PDF、Markdown、纯文本等 │ ▼ 阶段2: 智能分块 ─── 语义分块 / 递归分块 / Markdown 结构分块 │ ▼ 阶段3: 向量化 ─── Embedding + 原文存入 Milvus(支持 BM25 全文检索) │ ▼ 阶段4: 实体抽取 ─── LLM 从文本中抽取三元组(主语-谓语-宾语) │ 构建知识图谱存入 Neo4j ▼ 阶段4.5: 社区检测 ─── 本文的主角 │ ├── 从 Neo4j 拉取全图边 │ ├── 构建邻接图 │ ├── Leiden 算法分层检测 │ ├── LLM 为每个社区生成摘要 │ └── 存回 Neo4j ▼ 阶段5: 完成 ─── 更新文档状态和统计信息对应的 Pipeline 代码中,社区检测的触发条件清晰明确:
// 图谱功能开启 && 社区检测开启 && 检测器已注入 if collection.GraphConfig.CommunityEnabled && p.communityDetector != nil { communities, err := p.communityDetector.DetectAndSummarize(ctx, collection.ID) }七、设计取舍与思考
7.1 简化 vs 完整 Leiden
我们实现的是简化版 Leiden——省略了 refinement 阶段。这是一个有意的取舍:
「完整 Leiden」在每轮贪心移动后,还会对社区内部进行精炼,确保每个子集都是"连通的"
「我们的简化版」只做贪心移动,可能产生非连通的社区
在 RAG 场景下,社区的边界不需要那么精确。一个社区即使包含少量"误分"的实体,LLM 在生成摘要时也能自动忽略不相关的部分。「算法简洁性带来的可维护性,比理论最优解更重要。」
7.2 全量重建 vs 增量更新
当前实现在每次文档处理后都会「全量重建社区」(先删后建)。这个设计在中小规模知识库下完全够用,但在大规模场景下可能成为瓶颈。
可能的优化方向:
「增量更新」:只对受影响的子图重新检测
「异步执行」:社区检测放到后台任务,不阻塞文档处理流程
「变更追踪」:记录图的变更量,只有超过阈值才触发重建
7.3 分辨率参数的选择
分辨率 γ = 1.0 是 Leiden 的经典默认值,分层时每层减半。这个设置在大多数场景下表现良好,但本质上是一个需要根据数据特点调优的超参。如果发现社区太大或太小,调整resolution即可。
八、总结
社区检测是 GraphRAG 体系中承上启下的关键一环:
「承上」:它接收实体抽取和图谱构建的结果,将零散的实体关系聚合为有意义的主题群落
「启下」:它产出的社区摘要,直接服务于全局搜索和宏观问答
核心实现并不复杂——一个贪心迭代的 Leiden 算法(~80 行核心逻辑),加上 LLM 摘要生成,就能把一张知识图谱变成一份结构化的"主题地图"。
「最后一个直觉」:社区检测做的事情,其实就是帮知识图谱"找到它自己的目录"。文档的目录是人工写的,而知识图谱的目录是算法自动发现的——这正是 GraphRAG 最令人兴奋的地方。
