跳到主要内容

图网络子系统:关系检索与主题建模

30 秒导读: 前几章里 txtai 是"把文本变向量、按相似度排序"的检索引擎。本章讲它多长出来的一只手——图网络:索引时不仅把每条记录变成一个向量,还顺手把"谁和谁相似"连成边,于是整个嵌入库变成一张图。之后你既能像走地图一样在记录间遍历(A 和 C 之间隔着谁?),也能对整张图跑 PageRank(网页排名算法,按"被重要节点引用"给节点打分)、社区发现和主题建模。

本章是 txtai 系列的第 6 章。它假设你已经知道嵌入库怎么把文本变向量、怎么排序检索(见 01-embeddings-database04-search-and-fusion)。这里只讲"图这一面":边从哪来、图查询怎么跑、PageRank / 社区 / 主题拿来干嘛。向量化、ANN、SQL 的细节不重复,分别见 02 / 03 / 05


1. 这是什么(零基础也能懂)

一句话定义: 图子系统让 txtai 在"向量检索"之外,额外维护一张节点=记录、边=相似关系的网络,并在这张网络上支持遍历查询、图算法和主题聚类。

它解决什么问题。 普通向量检索只回答一种问题:"跟这句话最像的 N 条是谁?"——一次打分、一个排序、结束。但很多真实需求是关系型的:

  • 这两篇文档之间,靠哪些中间文档串起来?(路径)
  • 整个语料里,哪些记录是"枢纽"、删了会让知识断裂?(中心性 / PageRank)
  • 这堆记录能自动分成几个主题簇,每簇讲什么?(社区 / 主题建模)

这些问题"排序一个 top-N"答不了,得把记录之间的连接显式建出来。图子系统就是干这个的。

一句话直觉。 把向量索引想成"每本书的内容摘要卡",图子系统则是在卡片之间拉线:两张卡内容够像就连一根线,线的粗细 = 相似度。连完之后,这摞卡片就从"一叠卡"变成了"一张地图",可以顺着线走、可以找枢纽、可以圈出聚集区。

用起来什么样(最小示例):

# 示意,非源码:开启 graph,txtai 索引时自动建边
from txtai import Embeddings

embeddings = Embeddings(
path="sentence-transformers/all-MiniLM-L6-v2",
content=True,
functions=[{"name": "graph", "function": "graph.attribute"}],
graph={"approximate": True, "topics": {}}, # 开启图 + 主题建模
)
embeddings.index(rows) # 建向量索引的同时,自动建图

# 图查询:走 openCypher(图数据库查询语言),similar() 先做向量检索再进图
g = embeddings.search("""
MATCH P=({id: "start"})-[*1..3]->({id: "end"})
RETURN P
LIMIT 5
""", graph=True)

print(g.pagerank()) # 每个节点的重要性打分
print(g.showpath("a", "b")) # 两点间最短路径

关键点:开发者不手动连边。你照常 index(),只要配了 graph,txtai 就在建向量索引之后自动把相似度关系连成图。


2. 顶层全景(它大概怎么转)

图子系统住在 txtai/graph/ 下,对外由 Embeddings 编排类驱动。它由五个部件组成:

部件干什么在哪个文件
Graph(基类)定义全部图操作(建边、遍历、算法、主题),是抽象接口 + 通用逻辑graph/base.py:11
NetworkX默认后端,把图存在内存里(NetworkX 库)graph/networkx.py:28
RDBMS把图落到关系数据库(SQL 表),支持超大图 / 持久化graph/rdbms.py:22
Query把 openCypher 图查询解析、执行,处理 similar() 混合子句graph/query.py:19
Topics用社区发现做主题建模,给每个簇起名graph/topics.py:9
GraphFactory按 config 里的 backend 选后端graph/factory.py:11

两条主线怎么走。 一是建图(索引时),二是用图(查询时):

┌─────────────── 建图(index 时)───────────────┐

文本记录 ──向量化/索引──▶ 向量索引就绪

│ ① graph.insert():每条记录 = 一个节点

[ 一堆孤立节点 ]
│ ② graph.index():用"相似度搜索"当探针
│ 每个节点找自己的近邻 → 连成边

[ 节点 + 相似度边的图 ] ──③ 主题建模 → 主题簇

┌─────────────── 用图(search 时)──────────────┐

图查询字符串 ──▶ isquery? ──是──▶ graphsearch():
(MATCH...RETURN) 先 index scan 解析 similar()
再交给 graph.batchsearch() 走图


路径 / 子图 / PageRank / 社区

怎么读这张图:上半"建图"发生在 index() 里,是全自动的;下半"用图"发生在 search() 里,靠识别出这是一条图查询后分流过去。两半的接缝是同一个相似度搜索函数——建边靠它,查询里的 similar() 也靠它。


3. 核心机制一:索引时,相似度怎么变成边

这是整个子系统最核心的一步:边不是人连的,是"跑一遍相似度检索"推断出来的。

3.1 三步走:插入节点 → 推断边 → 建主题

Graph.index() 是建图总入口,只有三步(graph/base.py:468 index):

# 真实源码摘录 graph/base.py:478-486
self.resolverelations(ids) # ① 先落地"手动指定"的关系边
self.inferedges(self.scan(), search) # ② 再用 search 函数为每个节点推断相似度边
if "topics" in self.config:
self.addtopics(similarity) # ③ 最后做主题建模

在这之前,节点已经由 Graph.insert() 逐条塞进来了(graph/base.py:395):每条记录取出 text/object 字段当节点数据,id 和要复制的属性挂上去,先攒成 nodes 一次性 addnodes。此刻图里只有孤立节点、还没有边

3.2 边的来源之一:手动关系(resolverelations)

如果记录里带了 relationships 字段(你显式说"这条连那条"),insert 会先把它们暂存到 self.relations(addrelations,graph/base.py:570)。等到 index() 里,resolverelations(graph/base.py:590)把这些字符串 id 解析成图内部 id,建成边,默认权重 1.0,然后清空暂存。这类边是"人给的事实",不经过相似度。

3.3 边的来源之二:相似度推断(inferedges + addbatch)

这才是主戏。思路:让每个节点拿自己的文本去检索库,检索回来的近邻,就是它该连的边;相似度分数就是边权重。

inferedges(graph/base.py:630)分批扫节点,addbatch(graph/base.py:670)真正建边。核心在这几行(graph/base.py:683-690):

# 真实源码摘录 graph/base.py:683-692
for x, result in enumerate(search([data for _, data in batch], limit)):
x, _ = batch[x]
for y, score in result:
if str(x) != str(y) and score > minscore: # 排除自环 + 低分
edges.append((x, y, {"weight": score})) # 分数即权重
self.addedges(edges)

三个 config 旋钮控制这步(graph/base.py:641-642):

参数默认作用
limit15每个节点最多连几条边(取 top-N 近邻)
minscore0.1低于此相似度的边直接丢,避免噪声连接
batchsize256一批多少节点一起检索,吞吐优化
approximateTrue见下,近似建图的关键取巧

取巧点:approximate walk(近似建图)。 全量建图要为每个节点都做一次检索,量大时很贵。approximate=True 时,inferedges 会跳过"已经有边的节点"(graph/base.py:659):

# 真实源码摘录 graph/base.py:659
if not approximate or not self.hasedge(node):
batch.append((node, data))

直觉:因为边是无向的,A 检索到 B 时已经建立了 A–B 边,那么轮到 B 时它已经"有边"了,就不必再为 B 单独跑一次检索。这把检索次数近似砍半,牺牲一点点完整性换速度——所以叫 approximate。

search 从哪来? 注意 index()search 参数不是图自己的搜索,而是嵌入库传进来的向量检索函数。在 Embeddings.index() 里是这么调的(embeddings/base.py:153):

# 真实源码摘录 embeddings/base.py:152-153
if self.graph:
self.graph.index(Search(self, indexonly=True), Ids(self), self.batchsimilarity)

Search(self, indexonly=True) 是一个"只走索引、返回 (id, score)"的检索器(见 04-search-and-fusion)。图把它当探针,一批批查近邻来建边。

3.4 时序约束:图必须在 scoring 之后建(呼应 01 章)

既然建边要调向量/稀疏检索,那检索器必须先就绪。这解释了 Embeddings.index() 里一处刻意的顺序(embeddings/base.py:142-153):

# 真实源码摘录 embeddings/base.py:142-153
# Index scoring, if necessary
# This must occur before graph index in order to be available to the graph
if self.issparse():
self.scoring.index()
...
if self.graph:
self.graph.index(...) # 图放在最后

注释写得很直白:scoring 索引必须先于图索引,否则图建边时探针检索不到东西。这是 01 章提到的"graph.index 必须排在 scoring 之后"这一时序约束的落点。upsert 路径同理(embeddings/base.py:191-201)。

3.5 增量:upsert 与 infertopics

新增记录走 Graph.upsert()(graph/base.py:488)。它和 index 的差别在于只给新节点建边(scan(attribute="data") 只扫还没处理的节点),并且主题不重建而是继承:infertopics(graph/base.py:742)让每个新节点"随大流"——看自己的邻居里哪个主题/类别出现得最多,就归到那个主题:

# 真实源码摘录 graph/base.py:757-758
topic = Counter(self.attribute(x, "topic") for x in ids).most_common(1)[0][0] if ids else None

删除走 Graph.delete()(graph/base.py:444):不光删节点,还要把它从所属主题的 id 列表里摘掉,主题空了就连主题一起删。


4. 核心机制二:图查询(open/close graph walk)

建好图之后怎么查?txtai 支持 openCypher(图数据库通用查询语言,MATCH ... RETURN ... 风格),底层用 GrandCypher 库执行。

4.1 先分流:这条查询是不是图查询

Embeddings 的搜索入口会先问一句"这是图查询吗"。判定很简单(graph/query.py:65 isquery):

# 真实源码摘录 graph/query.py:77
return all(query and query.strip().startswith("MATCH ") and "RETURN " in query for query in queries)

MATCH 开头、含 RETURN 就算图查询。命中后,搜索层把它交给 graphsearch(embeddings/search/base.py:75):

# 真实源码摘录 embeddings/search/base.py:74-76
# Graph search
if self.graph and self.graph.isquery(queries):
return self.graphsearch(queries, limit, weights, index)

4.2 混合的精华:similar() 子句 = 先向量检索,再进图

纯 openCypher 只能按图结构和属性匹配。txtai 的独门混合是允许在图查询里写 similar('查询文本')——先做一次向量检索,把命中的节点当作图遍历的起点/过滤条件。这靠"解析 → 索引扫描 → 回填 → 执行"四步接起来。

先解析。 Query.parse()(graph/query.py:79)用正则把每个 similar(...) 抠出来,替换成占位符 __SIMILAR__0__SIMILAR__1……并记下参数:

# 真实源码摘录 graph/query.py:104-106
for x, match in enumerate(re.finditer(r"similar\((.+?)\)", query, ...)):
query = query.replace(match.group(0), f"{Query.SIMILAR}{x}")

再做索引扫描回填。 这正是 graphsearch 的关键——图查询要先做一遍普通的 index scan,再交给图(embeddings/search/base.py:299):

# 真实源码摘录 embeddings/search/base.py:313-327
queries = [self.graph.parse(query) for query in queries] # ① 解析出 similar 占位符
limit = max(limit, self.limit(queries))
scan = Scan(self.search, limit, weights, index)(queries, None) # ② 对每个 similar() 跑向量检索
for x, query in enumerate(queries):
query["results"] = [r for y, r in scan if x == y] # ③ 把检索结果挂回查询
return self.graph.batchsearch(queries, limit, self.indexids) # ④ 带着结果进图执行

最后把占位符换成真结果。 Query.build()(graph/query.py:122)把 __SIMILAR__0 替换成"节点的 match_0 属性 > 0"这样的普通 openCypher 条件,并用检索命中的 id 先把图 filter 成子图(graph/query.py:160),再喂给 GrandCypher 执行(graph/query.py:63)。

一句话概括这条链路:"文本相似"这件向量的事,被翻译成"节点带某个匹配属性"这件图能懂的事,于是语义检索和图遍历在同一条查询里合流。 呼应 04-search-and-fusion 的融合思想,只是这里融合的对象是图结构。

4.3 open vs close:返回行还是返回子图

search 有个 graph 开关(graph/networkx.py:123):

  • graph=False(close / 关闭式):把 openCypher 的列式结果拍平成一行行 dict 返回,像普通查询结果。
  • graph=True(open / 开放式):把命中的所有节点收集起来,filter 成一个子图对象返回(graph/networkx.py:128-142),你可以继续在这个子图上跑 pagerank() / showpath() / 遍历。

后者是"图 walk"的精髓——查询不是终点,而是切出一块子图接着分析。filter(graph/base.py:515)会连同边、主题、类别一起复制到子图里,并保留每个节点的检索分数。


5. 核心机制三:遍历与图算法(路径 / 中心性 / PageRank)

图建好、子图切好之后,Graph 提供三类分析原语。它们在基类里是抽象方法,NetworkX 后端直接委托给 networkx 库实现:

方法回答什么问题实现
showpath(a, b)a 到 b 的最短路径经过谁graph/base.py:255networkx.py:113
centrality()谁连的边最多(度中心性)graph/base.py:235networkx.py:105
pagerank()谁最"重要"(被重要节点连得多)graph/base.py:245networkx.py:109

PageRank 用带权边。 networkx.py:109 里传了 weight="weight",也就是说相似度分数(建边时存的边权)直接参与排名——相似度高的连接对"重要性"贡献更大,结果按分降序返回。

最短路径的巧思:近重复要跳过。 showpath 用相似度换算距离时有个特判(networkx.py:230 distance):

# 真实源码摘录 graph/networkx.py:243-245
# Distance is 1 - score. Skip minimal distances as they are near duplicates.
distance = max(1.0 - attrs["weight"], 0.0)
return distance if distance >= 0.15 else 1.00

距离 = 1 - 相似度,但如果两点近乎重复(距离 < 0.15),反而把距离拉大到 1.0。直觉:路径分析想找"有意义的跳转",而近重复节点(几乎同一条内容)之间的跳转没信息量,所以故意惩罚它,不让最短路径老是从重复内容里穿过去。


6. 核心机制四:主题建模(社区发现 + 起名)

主题建模回答"这堆记录自动分成几个簇、每簇讲什么"。它建在社区发现(把连接紧密的节点圈成一个社区)之上。

6.1 三步:发现社区 → 排序 → 起名

addtopics(graph/base.py:694)委托给 Topics 类(graph/topics.py:9),流程是(graph/topics.py:30 __call__):

# 真实源码摘录 graph/topics.py:41-54
communities = graph.communities(self.config) # ① 社区发现
communities = sorted(communities, key=len, reverse=True) # ② 按簇大小排序
centrality = graph.centrality()
topics = [self.score(graph, x, community, centrality) for x, community in enumerate(communities)]
return self.merge(topics) # ③ 合并重名主题

社区发现挑算法。 NetworkX.communities(graph/networkx.py:153)支持三种,默认 Louvain:

算法config algorithm出处
Louvain(默认,分层模块度)缺省networkx.py:209 louvain
贪心模块度greedynetworkx.py:157
标签传播lpanetworkx.py:159

给主题起名。 Topics.score(graph/topics.py:56)对每个社区临时建一个 BM25 打分索引,取簇内最有代表性的几个词(默认 terms=4)拼成主题名,例如 machine_learning_model_training。没有文本的社区就退化成 topic_0 这类名字,并按中心性排序节点(graph/topics.py:87-92)。

合并同名。 merge(graph/topics.py:136)用词集合 frozenset(terms) 当 key,把凑巧起了同样名字的社区并成一个主题,最后按规模从大到小输出 {主题名: [节点 id]}

6.2 主题落到节点属性

addtopics 最后把结果写回每个节点(graph/base.py:718-724):给节点挂 topic(所属主题)、topicrank(在主题内的排名)、category(更高层类别,若配了 categories + 相似度函数会给每个主题贴一个高层标签)。这样查询时就能按主题过滤、按类别聚合。


7. 后端:内存图 vs 数据库图

GraphFactory.create(graph/factory.py:16)按 config 的 backend 选实现,默认 networkx:

后端存哪适合关键取舍
NetworkX进程内存(networkx 图对象)中小图、单机、快图随进程走,靠 save/load 序列化持久化
RDBMS关系数据库的 nodes/edges超大图、需持久化/共享通过 grand 库暴露成"NetworkX 兼容"接口,SQL 细节见 05

关键设计:RDBMS 继承 NetworkX。 RDBMS(NetworkX)(graph/rdbms.py:22)——它不重写图算法,只把底层存储换成 SQL 表,再用 InMemoryCachedBackend 包一层内存缓存,让上层遍历/PageRank/主题这些逻辑原封不动复用(graph/rdbms.py:113)。也就是说"图的算法"和"图存哪"彻底解耦:换后端不影响任何图查询语义。自定义后端可通过 Resolver 按类名加载(graph/factory.py:45)。


8. 边界与局限

  • 图查询需要 GrandCypher,图后端需要 networkx。 二者都是可选依赖,缺了会明确报错让你装 graph extra(graph/query.py:32graph/networkx.py:36)。
  • 建图开销随规模上升。 每个节点(近似模式下约半数节点)都要跑一次检索来建边;超大语料建图慢,approximate/limit/minscore 是主要的省钱旋钮。
  • approximate 会漏边。 近似建图跳过已连边的节点,换来速度但图不是"完全"的;要精确关系就关掉 approximate
  • 主题名是词袋拼出来的,不是语义标题。 Topics 用 BM25 top 词拼名字,可读性有限;想要人话标题得靠 categories 贴高层标签或外部再加工。
  • openCypher 支持有限。 走的是 GrandCypher,并非完整图数据库,复杂 Cypher 特性未必支持;本章不展开其语法边界。
  • 向量化 / ANN / SQL 的实现细节本章刻意不重复,分别见 02 / 03 / 05

9. 代码地图(导航索引)

主题文件路径符号名
图基类 / 全部图操作graph/base.py:11Graph
插入节点(text/object → 节点)graph/base.py:395Graph.insert
建图三步(关系→推断→主题)graph/base.py:468Graph.index
增量建图graph/base.py:488Graph.upsert
相似度推断边(分批 + 近似跳过)graph/base.py:630Graph.inferedges
建边核心(自环/低分过滤,分数即权重)graph/base.py:670Graph.addbatch
手动关系边落地graph/base.py:570 / :590addrelations / resolverelations
主题建模(社区→起名→写属性)graph/base.py:694Graph.addtopics
增量继承主题(随邻居多数)graph/base.py:742Graph.infertopics
删除(连带清主题)graph/base.py:444Graph.delete
切子图(带边/主题/分数)graph/base.py:515Graph.filter
遍历/图算法graph/base.py:255 / :235 / :245showpath / centrality / pagerank
NetworkX 后端(算法委托 networkx)graph/networkx.py:28NetworkX
最短路径距离(惩罚近重复)graph/networkx.py:230NetworkX.distance
社区发现(louvain/greedy/lpa)graph/networkx.py:153NetworkX.communities
RDBMS 后端(继承 NetworkX,换存储)graph/rdbms.py:22 / :82RDBMS / RDBMS.connect
openCypher 查询执行graph/query.py:19 / :35Query / Query.__call__
图查询判定graph/query.py:65Query.isquery
similar() 解析成占位符graph/query.py:79 / :122Query.parse / Query.build
主题建模实现(社区→打分→合并)graph/topics.py:9 / :30Topics / Topics.__call__
后端选择graph/factory.py:11GraphFactory.create
图查询分流入口embeddings/search/base.py:75Search.__call__
图查询:先 index scan 再进图embeddings/search/base.py:299Search.graphsearch
建图时序约束(scoring 先于 graph)embeddings/base.py:142-153Embeddings.index