❝本文从一个直觉出发——"物以类聚,人以群分",逐步讲解知识图谱中社区检测的核心原理,并结合一个生产级 Go 项目的完整实现,带你理解从论文到代码的全过程。❞
打开你的微信,看看你的好友列表。你会发现一个有趣的现象:你的大学同学之间互相认识的概率很高,你的同事之间也是如此,但你的大学同学和你的同事之间大多互不相识。
「这就是社区(Community)的直觉定义:一组内部连接紧密、与外部连接稀疏的节点集合。」
放到知识图谱的语境里:
比如,在一份关于微服务架构的文档中,"Kubernetes""Docker""Helm""容器调度"这些实体大概率会被聚成一个社区,而"MySQL""Redis""数据分片"会聚成另一个。
传统的 RAG(检索增强生成)流程是这样的:用户提问 → 向量检索找到相关文本块 → 拼成 Prompt 喂给大模型。
这个流程在回答具体问题时很好用,但遇到宏观问题就力不从心了:
❝"这份文档集主要讨论了哪些技术领域?它们之间有什么关系?" ❞
向量检索本质上是"点对点"的——它找到的是与问题最相似的片段,但无法给出全局视角。
「社区检测正是为了弥补这个缺口。」 微软在 2024 年发表的 GraphRAG 论文提出了一个优雅的方案:
这就像给一本书自动生成了"章节大纲"——每个社区就是一个主题章节,摘要就是章节概述。
社区检测的本质是一个「优化问题」:如何划分节点,使得社区内部的边尽可能多,社区之间的边尽可能少?
衡量这个"好坏"的指标叫「模块度(Modularity)」,它的核心思想是:
❝社区内部的实际边数 vs 随机情况下的期望边数❞
如果一个社区内部的连接远超随机期望,说明这些节点确实"有理由"聚在一起。
Leiden 算法(2019 年提出,是 Louvain 算法的改进版)的核心策略非常朴素:「贪心迭代」。
想象你在一个大型会议上,每个人一开始都是独自一桌。算法做的事情是:
用数学语言描述,节点 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) 是"排斥力"——社交网络的随机期望。只有吸引力超过排斥力时,换桌才有意义。
一层社区往往不够。就像一个公司可以按"小组 → 部门 → 事业群"层层聚合一样,知识图谱中的社区也需要多层结构。
GraphRAG 的做法是:
这样就得到了一棵"社区树":叶子是具体实体,底层社区是细粒度主题,顶层社区是宏观领域。
下面以实际业务实践代码,看看上述理论是怎么落地的。
首先是社区的领域模型:
// 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
}
几个设计要点:
节点数 × 内部边密度,兼顾了规模和紧密度对应的图谱配置:
type GraphConfig struct {
Enabled bool // 是否开启知识图谱
EntityTypes []string // 支持的实体类型
RelationTypes []string // 支持的关系类型
CommunityEnabled bool // 是否开启社区检测
MaxCommunityLevel int // 最大社区层级数(默认 3)
}
社区检测的第一步是构建高效的图数据结构。我们使用稀疏邻接矩阵来表示:
// 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}
}
这是整个社区检测的心脏——简化版 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
}
逐段解读:
❝「与原版 Leiden 的差异」:完整的 Leiden 算法在贪心移动后还有一个 refinement(精炼)阶段,用于避免陷入局部最优。我们的简化版省略了这一步——在 RAG 场景下,社区检测主要服务于摘要生成,不需要追求算法理论上的最优解,"足够好"就行。 ❞
基础社区检测完成后,需要向上聚合。关键在于「超图(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
}
三个终止条件确保算法不会无限递归:
社区检测给了我们一组紧密关联的实体,但这还不够——我们需要用自然语言描述"这群实体在讨论什么"。这就是 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 过长的问题,又天然实现了信息的逐层抽象。
不是所有社区都同样重要。我们用一个简单但有效的公式来排名:
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
}
「为什么是 "节点数 × 密度" 而不是单独的密度或节点数?」
社区检测完成后,如何在搜索中发挥作用?我们实现了两种利用方式:
当用户提出宏观问题时,直接使用社区摘要回答:
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 策略。
在我们的混合检索(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)
}
我们实现的是简化版 Leiden——省略了 refinement 阶段。这是一个有意的取舍:
在 RAG 场景下,社区的边界不需要那么精确。一个社区即使包含少量"误分"的实体,LLM 在生成摘要时也能自动忽略不相关的部分。「算法简洁性带来的可维护性,比理论最优解更重要。」
当前实现在每次文档处理后都会「全量重建社区」(先删后建)。这个设计在中小规模知识库下完全够用,但在大规模场景下可能成为瓶颈。
可能的优化方向:
分辨率 γ = 1.0 是 Leiden 的经典默认值,分层时每层减半。这个设置在大多数场景下表现良好,但本质上是一个需要根据数据特点调优的超参。如果发现社区太大或太小,调整 resolution 即可。
社区检测是 GraphRAG 体系中承上启下的关键一环:
核心实现并不复杂——一个贪心迭代的 Leiden 算法(~80 行核心逻辑),加上 LLM 摘要生成,就能把一张知识图谱变成一份结构化的"主题地图"。
「最后一个直觉」:社区检测做的事情,其实就是帮知识图谱"找到它自己的目录"。文档的目录是人工写的,而知识图谱的目录是算法自动发现的——这正是 GraphRAG 最令人兴奋的地方。