首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >当知识图谱学会"抱团":GraphRAG 社区检测的原理与 Go 实战

当知识图谱学会"抱团":GraphRAG 社区检测的原理与 Go 实战

作者头像
tunsuy
发布2026-04-09 11:30:03
发布2026-04-09 11:30:03
800
举报

❝本文从一个直觉出发——"物以类聚,人以群分",逐步讲解知识图谱中社区检测的核心原理,并结合一个生产级 Go 项目的完整实现,带你理解从论文到代码的全过程。❞


一、从朋友圈说起:什么是社区?

打开你的微信,看看你的好友列表。你会发现一个有趣的现象:你的大学同学之间互相认识的概率很高,你的同事之间也是如此,但你的大学同学和你的同事之间大多互不相识。

「这就是社区(Community)的直觉定义:一组内部连接紧密、与外部连接稀疏的节点集合。」

放到知识图谱的语境里:

  • 「节点」 = 实体(人物、技术、组织、概念……)
  • 「边」 = 实体间的关系(uses、depends_on、manages……)
  • 「社区」 = 一群关系紧密的实体,它们往往在讨论同一个主题

比如,在一份关于微服务架构的文档中,"Kubernetes""Docker""Helm""容器调度"这些实体大概率会被聚成一个社区,而"MySQL""Redis""数据分片"会聚成另一个。

二、为什么 RAG 需要社区?

传统的 RAG(检索增强生成)流程是这样的:用户提问 → 向量检索找到相关文本块 → 拼成 Prompt 喂给大模型。

这个流程在回答具体问题时很好用,但遇到宏观问题就力不从心了:

❝"这份文档集主要讨论了哪些技术领域?它们之间有什么关系?" ❞

向量检索本质上是"点对点"的——它找到的是与问题最相似的片段,但无法给出全局视角。

「社区检测正是为了弥补这个缺口。」 微软在 2024 年发表的 GraphRAG 论文提出了一个优雅的方案:

  1. 从文档中抽取实体和关系,构建知识图谱
  2. 对图谱进行社区检测,把紧密相关的实体聚成社区
  3. 用 LLM 为每个社区生成摘要
  4. 回答宏观问题时,用社区摘要代替原始文本块

这就像给一本书自动生成了"章节大纲"——每个社区就是一个主题章节,摘要就是章节概述。

三、Leiden 算法:社区检测的核心引擎

3.1 从模块度说起

社区检测的本质是一个「优化问题」:如何划分节点,使得社区内部的边尽可能多,社区之间的边尽可能少?

衡量这个"好坏"的指标叫「模块度(Modularity)」,它的核心思想是:

❝社区内部的实际边数 vs 随机情况下的期望边数❞

如果一个社区内部的连接远超随机期望,说明这些节点确实"有理由"聚在一起。

3.2 Leiden 算法的策略

Leiden 算法(2019 年提出,是 Louvain 算法的改进版)的核心策略非常朴素:「贪心迭代」

想象你在一个大型会议上,每个人一开始都是独自一桌。算法做的事情是:

  1. 「初始化」:每个人(节点)自成一桌(社区)
  2. 「贪心移动」:逐一考察每个人,看他移到邻桌后"桌内话题的一致性"(模块度)是否提升,如果是,就让他换桌
  3. 「反复迭代」:不断重复步骤 2,直到没人想换桌为止

用数学语言描述,节点 i 从当前社区移到社区 c 的模块度增益为:

代码语言:javascript
复制
Δ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 的做法是:

  1. 「Level 0」:在原始图上检测基础社区(分辨率 γ = 1.0)
  2. 「Level 1」:把 Level 0 的社区当作节点,构建"超图",以更低的分辨率(γ/2)再次检测
  3. 「Level 2+」:继续递归,分辨率逐层减半(γ/4, γ/8, ...),社区越来越大
  4. 「终止条件」:只剩一个社区,或无法进一步合并

这样就得到了一棵"社区树":叶子是具体实体,底层社区是细粒度主题,顶层社区是宏观领域。

四、Go 实战:从零实现社区检测

下面以实际业务实践代码,看看上述理论是怎么落地的。

4.1 数据模型设计

首先是社区的领域模型:

代码语言:javascript
复制
// 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 最精妙的设计之一

对应的图谱配置:

代码语言:javascript
复制
type GraphConfig struct {
    Enabled           bool     // 是否开启知识图谱
    EntityTypes       []string // 支持的实体类型
    RelationTypes     []string // 支持的关系类型
    CommunityEnabled  bool     // 是否开启社区检测
    MaxCommunityLevel int      // 最大社区层级数(默认 3)
}

4.2 邻接图:稀疏表示

社区检测的第一步是构建高效的图数据结构。我们使用稀疏邻接矩阵来表示:

代码语言:javascript
复制
// adjacencyGraph 邻接图,用于社区检测
type adjacencyGraph struct {
    nodes       []string          // 节点名称列表(排序后)
    nodeIndex   map[string]int    // 名称 → 索引(O(1) 查找)
    adjWeight   []map[int]float64 // 稀疏邻接权重矩阵
    totalWeight float64           // 总边权重(模块度公式中的 m)
}

为什么用 []map[int]float64 而不是二维数组?因为知识图谱通常是「稀疏图」——节点很多但每个节点的邻居相对较少。稀疏表示既省内存,遍历邻居时也更高效。

构建邻接图的过程很直接——从 Neo4j 拉取所有边,构建双向加权邻接关系:

代码语言:javascript
复制
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 行:

代码语言:javascript
复制
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
}

逐段解读:

  1. 「初始化阶段」:每个节点独占一个社区(社区数 = 节点数)
  2. 「贪心迭代」:对每个节点,计算移到每个邻居社区的增益,选择增益最大的(如果大于 0 就移动)
  3. 「收敛判定」:一整轮下来没有任何节点移动,说明已达到局部最优
  4. 「ID 重映射」:让社区编号连续,方便后续处理

「与原版 Leiden 的差异」:完整的 Leiden 算法在贪心移动后还有一个 refinement(精炼)阶段,用于避免陷入局部最优。我们的简化版省略了这一步——在 RAG 场景下,社区检测主要服务于摘要生成,不需要追求算法理论上的最优解,"足够好"就行。 ❞

4.4 分层递归:从社区到超社区

基础社区检测完成后,需要向上聚合。关键在于「超图(Super Graph)构建」——把社区当节点,社区间的跨边权重求和作为新的边权:

代码语言:javascript
复制
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 的主循环中,递归地检测和合并:

代码语言:javascript
复制
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
}

三个终止条件确保算法不会无限递归:

  1. 只剩一个社区
  2. 超图节点不足
  3. 新一层社区数没有减少(说明分辨率已经无法继续合并了)

4.5 LLM 摘要生成:给社区赋予"灵魂"

社区检测给了我们一组紧密关联的实体,但这还不够——我们需要用自然语言描述"这群实体在讨论什么"。这就是 LLM 摘要的作用。

「基础社区(Level 0)的摘要生成」

我们会把社区内的实体信息(名称、类型、描述)和内部关系(类型、描述)拼装成 Prompt:

代码语言:javascript
复制
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 输出格式:

代码语言:javascript
复制
TITLE: <最多 10 词的标题>
SUMMARY: <2-3 句话的摘要>

传给 LLM 的实体信息长这样:

代码语言:javascript
复制
Entities:
- Kubernetes [technology]: 容器编排平台,用于自动化部署和管理容器化应用
- Docker [technology]: 容器化引擎,提供应用级别的隔离
- Helm [technology]: Kubernetes 包管理器

Relationships:
Kubernetes -[uses]-> Docker (Kubernetes 使用 Docker 作为默认容器运行时)
Helm -[manages]-> Kubernetes (Helm 管理 Kubernetes 上的应用部署)

LLM 会返回类似:

代码语言:javascript
复制
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+)的摘要」则更有意思——它不直接看实体,而是用子社区的摘要来生成更高层的概述:

代码语言:javascript
复制
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 社区重要性排名

不是所有社区都同样重要。我们用一个简单但有效的公式来排名:

代码语言:javascript
复制
rank = 节点数 × 内部边密度

内部边密度的计算:

代码语言:javascript
复制
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)

当用户提出宏观问题时,直接使用社区摘要回答:

代码语言:javascript
复制
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)流程中,社区通过图谱局部搜索间接发挥作用:

代码语言:javascript
复制
用户提问
  ├── Milvus 混合检索(Dense + BM25 Sparse,RRF 融合)
  │     → 向量+关键词匹配的文本块
  ├── 图谱局部检索(Graph Local Search)
  │     → 从相关实体出发,多跳遍历关联的文本块
  └── RRF 二次融合
        → 最终排序结果

其中 RRF(Reciprocal Rank Fusion)是一个经典的多路结果融合算法:

代码语言:javascript
复制
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 中串联起来,看看一个文档从摄入到社区检测的完整生命周期:

代码语言:javascript
复制
文档上传
  │
  ▼
阶段1: 文档解析 ─── 支持 PDF、Markdown、纯文本等
  │
  ▼
阶段2: 智能分块 ─── 语义分块 / 递归分块 / Markdown 结构分块
  │
  ▼
阶段3: 向量化 ─── Embedding + 原文存入 Milvus(支持 BM25 全文检索)
  │
  ▼
阶段4: 实体抽取 ─── LLM 从文本中抽取三元组(主语-谓语-宾语)
  │                 构建知识图谱存入 Neo4j
  ▼
阶段4.5: 社区检测 ─── 本文的主角
  │  ├── 从 Neo4j 拉取全图边
  │  ├── 构建邻接图
  │  ├── Leiden 算法分层检测
  │  ├── LLM 为每个社区生成摘要
  │  └── 存回 Neo4j
  ▼
阶段5: 完成 ─── 更新文档状态和统计信息

对应的 Pipeline 代码中,社区检测的触发条件清晰明确:

代码语言:javascript
复制
// 图谱功能开启 && 社区检测开启 && 检测器已注入
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 最令人兴奋的地方。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-03-24,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 有文化的技术人 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、从朋友圈说起:什么是社区?
  • 二、为什么 RAG 需要社区?
  • 三、Leiden 算法:社区检测的核心引擎
    • 3.1 从模块度说起
    • 3.2 Leiden 算法的策略
    • 3.3 分层检测:从小圈子到大主题
  • 四、Go 实战:从零实现社区检测
    • 4.1 数据模型设计
    • 4.2 邻接图:稀疏表示
    • 4.3 Leiden 核心算法实现
    • 4.4 分层递归:从社区到超社区
    • 4.5 LLM 摘要生成:给社区赋予"灵魂"
    • 4.6 社区重要性排名
  • 五、社区在搜索中的使用
    • 5.1 全局搜索(Global Search)
    • 5.2 混合搜索中的角色
  • 六、完整流程串联
  • 七、设计取舍与思考
    • 7.1 简化 vs 完整 Leiden
    • 7.2 全量重建 vs 增量更新
    • 7.3 分辨率参数的选择
  • 八、总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档