Fork me on GitHub

2025年9月

GraphRAG 索引构建之知识提取(四)

我们昨天大概了解了基于 NLP 的知识提取流程,为了更好地学习相关的提取技术,我们简单介绍了 SpaCy 和 TextBlob 这两个 NLP 库的基本使用。有了 SpaCy 和 TextBlob 的基础,我们再回过头来看看 extract_graph_nlp 工作流中的三大名词短语提取器。

句法分析提取器(Syntactic)

首先是句法分析提取器,其核心实现位于 SyntacticNounPhraseExtractor 类:

def __init__(
  self,
  model_name: str,
  max_word_length: int,
  include_named_entities: bool,
  exclude_entity_tags: list[str],
  exclude_pos_tags: list[str],
  exclude_nouns: list[str],
  word_delimiter: str,
):
  # 根据是否需要命名实体识别来决定加载的组件
  if not include_named_entities:
    # 排除命名实体识别器,提升处理速度
    self.nlp = self.load_spacy_model(model_name, exclude=["lemmatizer", "ner"])
  else:
    # 保留命名实体识别器,只排除词形还原器
    self.nlp = self.load_spacy_model(model_name, exclude=["lemmatizer"])

从初始化函数可以看出该提取器有不少配置参数:

extract_graph_nlp:
  text_analyzer:
    extractor_type: syntactic_parser
    model_name: en_core_web_md   # SpaCy 模式
    max_word_length: 15          # 最大单词长度
    word_delimiter: " "          # 单词分隔符
    include_named_entities: true # 是否开启命名实体识别
    exclude_nouns:               # 排除停用词,大多是日常交流中使用频繁但本身不携带太多关键信息的词汇
      - stuff
      - thing
      - things
      - ...
    exclude_entity_tags:         # 排除指定类型的实体
      - DATE   # 日期
    exclude_pos_tags:            # 排除特定的词性标签
      - DET    # 限定词,如 a an the 等
      - PRON   # 代词,如 he she it 等
      - INTJ   # 感叹词,如 oh wow hey 等
      - X      # 其他

句法分析提取器根据 include_named_entities 参数加载 SpaCy 模型,默认模型是 en_core_web_md,为提高性能,加载模型时排除了 lemmatizer 模块(词形还原器),如果用户关闭了命名实体识别,还会排除 ner 模块(命名实体识别器)。

该提取器的核心实现如下:

def extract(self, text: str) -> list[str]:
  
  # SpaCy 文本分析
  doc = self.nlp(text)

  if self.include_named_entities:
    # 命名实体 + 名词块
    entities = [
      ent for ent in doc.ents
      if ent.label_ not in self.exclude_entity_tags
    ]
    spans = entities + list(doc.noun_chunks)

    # 根据规则过滤
    tagged_noun_phrases = [
      self._tag_noun_phrases(span, entities) for span in spans
    ]
  else:
    # 直接获取名词块,根据规则过滤
    tagged_noun_phrases = [
      self._tag_noun_phrases(chunk, []) for chunk in doc.noun_chunks
    ]

如果开启了命名实体识别,则将 SpaCy 提取的命名实体(ents)和名词块(noun_chunks)合并,然后通过一些规则进行过滤得到结果;如果未开启,则直接获取名词块,根据规则过滤。过滤规则比较细碎,比如根据 exclude_entity_tags 排除特定类型的实体,根据 exclude_pos_tags 排除特定词性,根据 exclude_nouns 排除停用词,排除空格和标点,根据 max_word_length 判断长度是否有效,等等。

上下文无关文法提取器(CFG)

第二个是上下文无关文法(CFG,Context-Free Grammar)提取器,其核心实现位于 CFGNounPhraseExtractor 类:

def __init__(
  self,
  model_name: str,
  max_word_length: int,
  include_named_entities: bool,
  exclude_entity_tags: list[str],
  exclude_pos_tags: list[str],
  exclude_nouns: list[str],
  word_delimiter: str,
  noun_phrase_grammars: dict[tuple, str],
  noun_phrase_tags: list[str],
):
  # 根据是否需要命名实体识别来决定加载的组件
  if not include_named_entities:
    # 排除命名实体识别器,提升处理速度
    self.nlp = self.load_spacy_model(model_name, exclude=["lemmatizer", "parser", "ner"])
  else:
    # 保留命名实体识别器,只排除词形还原器和句法分析器
    self.nlp = self.load_spacy_model(model_name, exclude=["lemmatizer", "parser"])

可以看出这个提取器和句法分析提取器非常像,同样是基于 SpaCy 实现的,而且配置参数也基本上一样。只不过 CFG 提取器在加载模型时排除了句法分析器(parser),因为它不依赖复杂的依存句法分析,只需要基本的词性标注功能即可。另外,它在初始化函数里多了两个参数:

extract_graph_nlp:
  text_analyzer:
    extractor_type: cfg
    # ... 同上
    noun_phrase_tags:            # 识别名词短语的词性标签
      - PROPN  # 专有名词,指特定的人、地方、组织等的名称,如 "London"(伦敦)、"Apple"(苹果公司)、"Alice"(爱丽丝)等
      - NOUNS  # 普通名词,如 "book"(书)、"city"(城市)、"idea"(想法)等
    noun_phrase_grammars:        # CFG 语法规则
      "PROPN,PROPN": "PROPN"
      "NOUN,NOUN": "NOUNS"
      "NOUNS,NOUN": "NOUNS"
      "ADJ,ADJ": "ADJ"
      "ADJ,NOUN": "NOUNS"

这两个参数就是 CFG 提取器的核心,CFG 其实是一种形式语法,由四元组 G = (V, Σ, R, S) 定义:

  • V: 非终结符集合 (如 NP, VP)
  • Σ: 终结符集合 (如具体单词)
  • R: 产生式规则集合 (如 NP → DT NN)
  • S: 起始符号

光看这个定义可能比较抽象,实际上就是先定义一个 CFG 规则集合,集合中包含多个键值对,键为词性标签对 (POS1, POS2),值为合并后的词性,比如上面的配置对应下面的语法规则:

"PROPN,PROPN": "PROPN", # 专有名词 + 专有名词 → 专有名词
"NOUN,NOUN": "NOUNS",   # 名词 + 名词 → 复合名词  
"NOUNS,NOUN": "NOUNS",  # 复合名词 + 名词 → 复合名词
"ADJ,ADJ": "ADJ",       # 形容词 + 形容词 → 形容词
"ADJ,NOUN": "NOUNS",    # 形容词 + 名词 → 复合名词

然后依次获取相邻两个词的词性标签对,在规则集合中查找匹配的规则并合并,通过反复应用语法规则,将简单词汇组合成更复杂的名词短语。我们看一个具体的例子,假设有文本 "big red car",处理过程如下:

  1. 初始标注: [("big", "ADJ"), ("red", "ADJ"), ("car", "NOUN")]
  2. 第一轮合并:

    • 检查 ("ADJ", "ADJ") → 找到规则 "ADJ,ADJ": "ADJ"
    • 合并: [("big red", "ADJ"), ("car", "NOUN")]
  3. 第二轮合并:

    • 检查 ("ADJ", "NOUN") → 找到规则 "ADJ,NOUN": "NOUNS"
    • 合并: [("big red car", "NOUNS")]
  4. 结果筛选:

    • 如果 "NOUNS" 在 noun_phrase_tags 中,则提取为名词短语

CFG 提取器的核心就是一个迭代式的合并算法:

def extract_cfg_matches(self, doc: Doc) -> list[tuple[str, str]]:
  # 1. 预处理:提取词汇和词性标注对,过滤无效词汇
  tagged_tokens = [
    (token.text, token.pos_) for token in doc
    if token.pos_ not in self.exclude_pos_tags  # 过滤指定词性
    and token.is_space is False                 # 过滤空格
    and token.text != "-"                       # 过滤连字符
  ]

  # 2. 迭代式合并:持续寻找匹配的语法模式
  merge = True
  while merge:
    merge = False
    for index in range(len(tagged_tokens) - 1):
      first, second = tagged_tokens[index], tagged_tokens[index + 1]
      key = first[1], second[1]  # 构建POS标签对作为查找键
      value = self.noun_phrase_grammars.get(key, None)  # 查找语法规则
      if value:
        # 找到匹配模式:移除原来的两个词汇,插入合并结果
        merge = True
        tagged_tokens.pop(index)     # 移除第一个词汇
        tagged_tokens.pop(index)     # 移除第二个词汇(索引已向前移动)
        match = f"{first[0]}{self.word_delimiter}{second[0]}"  # 合并文本
        pos = value  # 使用语法规则定义的新词性
        tagged_tokens.insert(index, (match, pos))  # 插入合并结果
        break
    
  # 3. 筛选结果:只返回符合目标词性的词汇
  return [t for t in tagged_tokens if t[1] in self.noun_phrase_tags]

通过自底向上的迭代合并,CFG 提取器从词汇级别逐步构建更复杂的语法结构,能有效识别复合名词短语,如 "Microsoft Corporation"、"machine learning algorithm" 等。由于少了依存句法分析,CFG 提取器一般比句法分析提取器更快,但需要为不同语言定制语法规则。另外有趣的是,CFG 的提取逻辑实际上参考了 TextBlob 的 FastNPExtractor 实现,感兴趣的可以看下 TextBlob 的源码。

正则表达式提取器(RegexEnglish)

这是基于 TextBlob 的最快速提取器,专门针对英语文本优化,它充分利用了前面介绍的 TextBlob 库的名词短语提取能力。核心实现位于 RegexENNounPhraseExtractor 类:

def __init__(
    self,
    exclude_nouns: list[str],
    max_word_length: int,
    word_delimiter: str,
):
    # 自动下载必需的语料库
    download_if_not_exists("brown")    # 布朗语料库
    download_if_not_exists("treebank") # 宾州树库
    download_if_not_exists("averaged_perceptron_tagger_eng") # 词性标注器

    # 下载分词器
    download_if_not_exists("punkt")     # 句子分割器
    download_if_not_exists("punkt_tab") # 句子分割器的表格版本

    # 预加载语料库,避免多线程竞争条件
    nltk.corpus.brown.ensure_loaded()
    nltk.corpus.treebank.ensure_loaded()

提取器在初始化时会自动下载和预加载所需的 NLTK 资源,另外能看到它的配置参数要简单的多:

extract_graph_nlp:
  text_analyzer:
    extractor_type: regex_english
    max_word_length: 15          # 最大单词长度
    word_delimiter: " "          # 单词分隔符
    exclude_nouns:               # 排除停用词,大多是日常交流中使用频繁但本身不携带太多关键信息的词汇
      - stuff
      - thing
      - things
      - ...

提取器的核心实现基于 TextBlob 的词性标注和名词短语提取能力:

def extract(self, text: str) -> list[str]:
  # 1. 创建TextBlob对象,自动执行分词和词性标注
  blob = TextBlob(text)
  
  # 2. 提取专有名词:识别NNP标签的词汇
  proper_nouns = [token[0].upper() for token in blob.tags if token[1] == "NNP"]
  
  # 3. 使用TextBlob内置的noun_phrases属性提取名词短语  
  tagged_noun_phrases = [
    self._tag_noun_phrases(chunk, proper_nouns)
    for chunk in blob.noun_phrases  # 
  ]
  
  # 4. 应用过滤规则,保留有效的名词短语
  filtered_noun_phrases = set()
  for tagged_np in tagged_noun_phrases:
    if (
      tagged_np["has_proper_nouns"]           # 包含专有名词
      or len(tagged_np["cleaned_tokens"]) > 1  # 多词短语
      or tagged_np["has_compound_words"]      # 复合词
    ) and tagged_np["has_valid_tokens"]:       # 词汇长度有效
      filtered_noun_phrases.add(tagged_np["cleaned_text"])
      
  return list(filtered_noun_phrases)

值得注意的是,这里的 noun_phrases 是通过 TextBlob 的 FastNPExtractor 提取的,正如上面所说,CFG 提取器就是参考它实现的,因此这个提取器虽然名字叫正则表达式提取器,实际上仍然是基于 CFG 实现的。

名词图谱构建

选定名词短语提取器后,下一步是构建名词图谱,这个过程由 build_noun_graph() 函数实现:

async def build_noun_graph(
  text_unit_df: pd.DataFrame,
  text_analyzer: BaseNounPhraseExtractor,
  normalize_edge_weights: bool,
  num_threads: int = 4,
  cache: PipelineCache | None = None,
) -> tuple[pd.DataFrame, pd.DataFrame]:

  # 提取节点
  nodes_df = await _extract_nodes(
    text_units, text_analyzer, num_threads=num_threads, cache=cache
  )
  # 提取边
  edges_df = _extract_edges(
    nodes_df, normalize_edge_weights=normalize_edge_weights
  )

  return (nodes_df, edges_df)

整个构建过程分为两个关键步骤:节点提取和边提取。

节点提取

节点提取的核心逻辑位于 _extract_nodes() 函数:

async def _extract_nodes(
  text_unit_df: pd.DataFrame,
  text_analyzer: BaseNounPhraseExtractor,
  num_threads: int = 4,
  cache: PipelineCache | None = None,
) -> pd.DataFrame:

  # 定义提取函数
  async def extract(row):
    text = row["text"]
    attrs = {"text": text, "analyzer": str(text_analyzer)}
    key = gen_sha512_hash(attrs, attrs.keys())
    
    # 缓存机制:先检查缓存
    result = await cache.get(key)
    if not result:
      # 缓存未命中,使用提取器提取
      result = text_analyzer.extract(text)
      await cache.set(key, result)
    return result

  # 并行处理所有文本单元
  text_unit_df["noun_phrases"] = await derive_from_rows(
    text_unit_df,
    extract,
    num_threads=num_threads,
    async_type=AsyncType.Threaded,
  )

  # 展开名词短语到单独行
  noun_node_df = text_unit_df.explode("noun_phrases")
  noun_node_df = noun_node_df.rename(
    columns={"noun_phrases": "title", "id": "text_unit_id"}
  )

  # 按名词短语标题分组,统计频率
  grouped_node_df = (
    noun_node_df.groupby("title").agg({"text_unit_id": list}).reset_index()
  )
  grouped_node_df["frequency"] = grouped_node_df["text_unit_id"].apply(len)

  return grouped_node_df[["title", "frequency", "text_unit_id"]]

整个过程比较简单,在代码中已有详细注释,核心点有几个:

  • 提取器调用:根据配置调用对应的提取器,从文本单元中提取出名词短语
  • 异步并发处理:使用 derive_from_rows 进行多线程并行处理
  • 缓存机制:通过 SHA512 哈希键实现缓存,避免重复计算
  • 频率统计:统计每个名词短语在多少个文本单元中出现

边提取

边提取的核心逻辑位于 _extract_edges() 函数:

def _extract_edges(
  nodes_df: pd.DataFrame,
  normalize_edge_weights: bool = True,
) -> pd.DataFrame:

  # 1. 展开节点到文本单元级别
  text_units_df = nodes_df.explode("text_unit_ids")
  text_units_df = text_units_df.rename(columns={"text_unit_ids": "text_unit_id"})

  # 2. 按文本单元分组,只保留包含2个或更多名词短语的文本单元
  text_units_df = (
    text_units_df.groupby("text_unit_id")
    .agg({"title": lambda x: list(x) if len(x) > 1 else np.nan})
    .reset_index()
  )
  text_units_df = text_units_df.dropna()

  # 3. 使用组合算法生成所有可能的边对
  from itertools import combinations
  titles = text_units_df["title"].tolist()
  all_edges = [list(combinations(t, 2)) for t in titles]

  # 4. 标准化边方向(确保 source ≤ target)
  edge_df[["source", "target"]] = edge_df.loc[:, "edges"].to_list()
  edge_df["min_source"] = edge_df[["source", "target"]].min(axis=1)
  edge_df["max_target"] = edge_df[["source", "target"]].max(axis=1)
  edge_df = edge_df.rename(
    columns={"min_source": "source", "max_target": "target"}
  )

  # 5. 按源节点和目标节点分组,计算权重
  grouped_edge_df = (
    edge_df.groupby(["source", "target"]).agg({"text_unit_id": list}).reset_index()
  )
  grouped_edge_df["weight"] = grouped_edge_df["text_unit_id"].apply(len)

  # 6. 可选的 PMI 权重归一化
  if normalize_edge_weights:
    grouped_edge_df = calculate_pmi_edge_weights(nodes_df, grouped_edge_df)

  return grouped_edge_df

边提取采用了基于 共现(Co-occurrence) 的关系提取策略,核心思想是:在同一个文本单元中出现的名词短语之间存在潜在关系。假设文本单元包含名词短语 ["Apple", "iPhone", "Technology"],算法会首先使用 combinations(t, 2) 生成所有可能的两两组合:

[
  ("Apple","iPhone"), 
  ("Apple","Technology"), 
  ("iPhone","Technology")
]

然后标准化边的方向,确保无向图中的边始终按统一方向存储,避免重复边(如 A-B 和 B-A 被视为同一条边);接着根据每对边出现的次数计算权重,原始权重=1;再根据各实体的全局频率调整权重,这里的使用的是 PMI 归一化。

PMI(Pointwise Mutual Information,点互信息) 是信息论中用于衡量两个事件(或特征)之间关联程度的指标,它描述了两个事件同时发生的概率与它们独立发生时概率乘积之间的偏离程度。使用 PMI 可以减少对低频实体的偏重,它的计算公式如下:

pmi.png

最终生成三条带权重的无向边。

图谱修剪

至此,我们终于完成了基于 NLP 的图谱构建,通过传统的自然语言技术,从文本单元中提取出节点和边。不过使用 NLP 提取的原始图谱通常包含大量的噪声节点和边,我们还需要对其进行进一步的修剪,这也就是 prune_graph 工作流要做的事:

def prune_graph(
  entities: pd.DataFrame,
  relationships: pd.DataFrame,
  pruning_config: PruneGraphConfig,
) -> tuple[pd.DataFrame, pd.DataFrame]:
  
  # 创建一个临时图
  graph = create_graph(relationships, edge_attr=["weight"], nodes=entities)

  # 根据配置对图进行修剪
  pruned = prune_graph_operation(
    graph,
    min_node_freq=pruning_config.min_node_freq,
    max_node_freq_std=pruning_config.max_node_freq_std,
    min_node_degree=pruning_config.min_node_degree,
    max_node_degree_std=pruning_config.max_node_degree_std,
    min_edge_weight_pct=pruning_config.min_edge_weight_pct,
    remove_ego_nodes=pruning_config.remove_ego_nodes,
    lcc_only=pruning_config.lcc_only,
  )

  # 将修剪后的图还原成 DataFrame 并返回
  pruned_nodes, pruned_edges = graph_to_dataframes(
    pruned, node_columns=["title"], edge_columns=["source", "target"]
  )

首先将 DataFrame 转换成 NetworkX 图,然后对图进行修剪,最后还原成 DataFrame 并返回。修剪的策略主要是通过 settings.yaml 文件进行配置:

prune_graph:
  min_node_freq: 2           # 最小节点频率阈值
  max_node_freq_std: 2.0     # 节点频率标准差上限  
  min_node_degree: 1         # 最小节点度数阈值
  max_node_degree_std: 2.0   # 节点度数标准差上限
  min_edge_weight_pct: 0.1   # 最小边权重百分位数
  remove_ego_nodes: false    # 是否移除 ego 节点
  lcc_only: true             # 是否只保留最大连通分量

这里涉及一些统计学和图形学中的基础概念,比如节点频率、节点度数、标准差、边的权重、百分位数、ego 节点、最大连通分量等,下面是我对这些参数做一个简单的总结:

  1. 频率修剪

    • 移除频率低于最小阈值的节点,如果节点频率(出现次数)低于 min_node_freq 则移除;
    • 移除频率高于标准差上限的节点,通过 np.std 计算标准差,通过 np.mean 计算平均值,如果节点频率高于 mean + max_node_freq_std * std 则移除;
  2. 度数修剪

    • 移除度数低于最小阈值的节点,如果节点度数(连接的边个数)低于 min_node_degree 则移除;
    • 移除度数高于标准差上限的节点,通过 np.std 计算标准差,通过 np.mean 计算平均值,如果节点度数高于 mean + max_node_degree_std * std 则移除;
  3. 权重修剪:移除权重低于百分位数阈值的边,通过 np.percentile 计算所有边权重相对于 min_edge_weight_pct 的百分位数,如果边权重低于该值则移除;
  4. 中心节点修剪:可选移除度数最高的节点,ego 节点通常是图中连接最密集的中心节点,可能会过度影响图的结构和分析结果,移除中心节点后,更容易发现图中真实的社区结构和聚类模式;
  5. 连通性修剪:可选只保留图中最大的连通子图,孤立的节点或较小的连通分量会被移除;

小结

通过今天的学习,我们掌握了以下要点:

  • 三种名词短语提取器:详细了解了 Syntactic、CFG 和 RegexEnglish 三种提取方法的原理、特点和适用场景,学会了如何根据需求选择合适的提取器;
  • 共现关系构建边:深入理解了基于文本单元内名词短语共现的关系提取算法,包括组合生成、权重计算和 PMI 归一化等关键技术;
  • 图谱修剪策略:掌握了多维度统计过滤的修剪方法,了解如何通过频率、度数、权重等指标清理噪声节点和边;

通过基于 NLP 的知识提取,我们已经从文本单元中构建出了包含实体和关系的初步知识图谱。相比于基于大模型的知识提取,传统 NLP 方法不仅更便宜,而且处理速度也有着显著的提升,不过这种方法的局限性也很明显:

  1. 语义理解有限:很多功能都是基于统计方法实现,缺乏深层语义理解;
  2. 关系类型单一:只能提取隐式共现关系,无法识别具体的关系类型;
  3. 上下文依赖:依赖文本单元的分块质量,为减少这方面的影响,分块大小不要设置的过大,一般在 50 到 100 即可;
  4. 噪声问题:容易引入无意义的共现关系;

因此在技术选型时也需要根据实际情况综合对比,如果你主要就是想做全局搜索,生成点摘要,那 NLP 方法就挺合适,另外一种比较好的做法是将 NLP 方法作为基准。

好了,关于 GraphRAG 知识提取的内容就学到这了。在下一篇文章中,我们将学习 GraphRAG 如何基于这些提取的实体和关系进行更深层的图谱分析,包括社区检测、层次化聚类和向量化索引,最终构建出完整的多层次知识图谱索引体系。


GraphRAG 索引构建之知识提取(三)

今天,我们将继续学习 GraphRAG 中关于知识提取的内容。上周的 extract_graph 工作流,完全基于大语言模型进行实体关系提取,质量高但速度较慢;为此 GraphRAG 还提供了另一种实现,基于传统的自然语言处理(NLP)技术,实现更快速、更低成本的实体关系提取。我们可以通过 Fast 索引方法 开启该功能:

$ graphrag index --root ./ragtest --method fast

我们重点学习 Fast 方法中的两个核心工作流:

  • 基于 NLP 的图谱提取(extract_graph_nlp - 使用 NLP 技术从文本中提取名词短语作为实体
  • 图谱修剪(prune_graph - 对提取结果进行统计过滤和清理

基于 NLP 的图谱提取

extract_graph_nlp 工作流的核心实现位于 index/workflows/extract_graph_nlp.py 文件,整个流程相当简洁:

async def run_workflow(
  config: GraphRagConfig,
  context: PipelineRunContext,
) -> WorkflowFunctionOutput:
  # 1. 加载文本单元
  text_units = await load_table_from_storage("text_units", context.output_storage)

  # 2. 执行 NLP 图谱提取
  entities, relationships = await extract_graph_nlp(
    text_units,
    extraction_config=config.extract_graph_nlp,
  )

  # 3. 保存结果
  await write_table_to_storage(entities, "entities", context.output_storage)
  await write_table_to_storage(relationships, "relationships", context.output_storage)

与基于大模型的 extract_graph 相比,这个工作流的实现要简单得多,核心逻辑都封装在 extract_graph_nlp() 函数中:

async def extract_graph_nlp(
  text_units: pd.DataFrame,
  extraction_config: ExtractGraphNLPConfig,
) -> tuple[pd.DataFrame, pd.DataFrame]:
  # 创建名词短语提取器
  text_analyzer = create_noun_phrase_extractor(text_analyzer_config)

  # 构建名词图谱
  extracted_nodes, extracted_edges = await build_noun_graph(
    text_units,
    text_analyzer=text_analyzer,
    normalize_edge_weights=extraction_config.normalize_edge_weights,
    num_threads=extraction_config.concurrent_requests,
  )

  # 添加下游工作流所需的字段
  extracted_nodes["type"] = "NOUN PHRASE"
  extracted_nodes["description"] = ""
  extracted_edges["description"] = ""

  return (extracted_nodes, extracted_edges)

整个工作流的执行流程如下:

extract-graph-nlp-flow.png

大致可分为三个步骤:首先创建名词短语提取器,然后构建名词图谱,最后为提取的节点和边添加必要的元数据字段。

名词短语提取器

GraphRAG 提供了三种不同的名词短语提取方法,每种都有其独特的优势和适用场景,通过工厂模式进行统一管理:

class NounPhraseExtractorFactory:
  @classmethod
  def get_np_extractor(cls, config: TextAnalyzerConfig) -> BaseNounPhraseExtractor:
    # 根据配置创建名词短语提取器
    match np_extractor_type:
      # 句法分析提取器
      case NounPhraseExtractorType.Syntactic:
        return SyntacticNounPhraseExtractor(...)
      # 上下文无关文法提取器
      case NounPhraseExtractorType.CFG:
        return CFGNounPhraseExtractor(...)
      # 正则表达式提取器
      case NounPhraseExtractorType.RegexEnglish:
        return RegexENNounPhraseExtractor(...)

我们可以在 settings.yaml 中配置使用哪种提取器:

extract_graph_nlp:
  text_analyzer:
    extractor_type: regex_english # [regex_english, syntactic_parser, cfg]

三种提取器的特点如下:

  • 句法分析提取器(Syntactic):基于 依存句法分析(Dependency Parsing)命名实体识别(NER) 的名词短语提取器,使用 SpaCy 实现。该提取器相比基于正则表达式的提取器往往能产生更准确的结果,但速度较慢。此外,通过使用 SpaCy 相应的模型,它可以用于不同的语言。
  • 上下文无关文法提取器(CFG):基于 上下文无关文法(CFG)命名实体识别(NER) 的名词短语提取器,同样使用 SpaCy 实现。该提取器往往比基于句法分析的提取器更快,但对于不同语言可能需要修改语法规则。
  • 正则表达式提取器(RegexEnglish):基于正则表达式的名词短语提取器,使用 TextBlob 实现。它是默认提取器,也是 LazyGraphRAG 首次基准测试中使用的提取器,但它只适用于英文。它比基于句法分析的提取器更快,但准确性可能更低。

下表是三种提取器的一个简单对比:

特性SyntacticCFGRegexEnglish
准确性最高中等较低
速度最慢中等最快
多语言支持需配置仅英语
资源占用中等
依赖复杂度SpaCy 模型SpaCy 基础TextBlob+NLTK
可定制性配置参数语法规则有限

建议根据实际的需求进行选择,比如:高质量需求选择 Syntactic 提取器,快速原型选择 RegexEnglish 提取器,平衡性能选择 CFG 提取器。下面我们将详细介绍这三种提取器的实现,但在深入具体源码之前,让我们先了解一下这些提取器所依赖的核心 NLP 库。

SpaCy 简单介绍

SpaCy 是一个专为生产环境设计的现代自然语言处理库,在 GraphRAG 的名词短语提取中扮演着重要角色。它提供了高效的文本处理管道,支持多种语言和复杂的 NLP 任务。

spacy.png

SpaCy 基于 Cython 实现,处理速度极快,支持 75+ 不同的语言,并内置了多种语言的预训练模型,下面是一些常用的 SpaCy 模型:

  • en_core_web_sm: 英语小型模型(12MB)- 基础功能
  • en_core_web_md: 英语中型模型(31MB)- 包含词向量
  • en_core_web_lg: 英语大型模型(382MB)- 高精度
  • zh_core_web_sm: 中文小型模型(46MB)- 支持中文处理

每一种模型都采用管道(Pipeline)架构,文本按顺序经过多个处理组件,每个组件可以选择性的启用或禁用:

spacy-pipeline.png

这些组件包括:

  • tok2vec: 将文本转换为向量表示
  • tagger: 词性标注器,为每个词(token)分配词性标签(Part-of-Speech Tag)
  • parser: 句法分析器,分析句子的句法结构,包括词语之间的依赖关系(Dependency Parsing)
  • attribute_ruler: 属性规则器,通过自定义规则修正或补充文本的属性(如词性、形态等)
  • lemmatizer: 词形还原器,将词语还原为其基本形式(词根)
  • ner: 命名实体识别器,识别文本中的命名实体(Named Entity)并分类

更多关于 SpaCy 模型、管道和组件的内容,可参考下面的文档:

让我们通过一个简单的示例来了解 SpaCy 的基本功能。首先,下载我们所需的模型:

$ python -m spacy download en_core_web_sm

通过 spacy.load 加载模型:

import spacy

nlp = spacy.load("en_core_web_sm")

然后准备一段简单的测试文本:

text = "Apple Inc, founded by Steve Jobs, develops innovative iPhone technology in California."
doc = nlp(text)

接下来就可以使用 SpaCy 的各个功能了,比如分词和词性标注:

for token in doc:
  print(f"{token.text:12} {token.pos_:8} {token.tag_:6} {token.lemma_}")

这里的几个属性解释如下:

  • text 为单词的原始文本;
  • pos_ 为单词的词性,比如名词(noun)、动词(verb)、形容词(adjective)、副词(adverb)等;
  • tag 为词性标签,用于表示具体词性的符号或缩写,不同的标注体系会定义不同的 TAG 符号,例如在英文的 Penn Treebank 标注体系中,"NN" 表示名词(noun, singular),"VB" 表示动词(verb, base form),"JJ" 表示形容词(adjective);而在中文的北大分词标注体系中,"n" 表示名词,"v" 表示动词,"a" 表示形容词等;
  • lemma 为词根或词元,指的是将词语的各种变形形式(如过去式、复数、比较级等)还原为其最基本的形式,比如将 "running" 还原为 "run","better" 还原为 "good" 等;

上面的代码输出如下:

Apple        PROPN    NNP    Apple
Inc          PROPN    NNP    Inc
,            PUNCT    ,      ,
founded      VERB     VBN    found
by           ADP      IN     by
Steve        PROPN    NNP    Steve
Jobs         PROPN    NNP    Jobs
,            PUNCT    ,      ,
develops     VERB     VBZ    develop
innovative   ADJ      JJ     innovative
iPhone       PROPN    NNP    iPhone
technology   NOUN     NN     technology
in           ADP      IN     in
California   PROPN    NNP    California
.            PUNCT    .      .

命名实体识别和名词块提取功能:

for ent in doc.ents:
  print(f"{ent.text:20} {ent.label_:10} {spacy.explain(ent.label_)}")

for chunk in doc.noun_chunks:
  print(f"{chunk.text:25} {chunk.root.text:10} {chunk.root.dep_}")

输出如下:

# 命名实体
Apple Inc            ORG        Companies, agencies, institutions, etc.
Steve Jobs           PERSON     People, including fictional
California           GPE        Countries, cities, states

# 名词块
Apple Inc                 Inc        nsubj
Steve Jobs                Jobs       pobj
innovative iPhone technology technology dobj
California                California pobj

命名实体(entities)和名词块(noun_chunks)是 SpaCy 中两个不同的概念,但很容易混淆。命名实体基于 NER 模型识别,而名词块基于句法分析识别,命名实体相对于名词块更具语义性,比如 "The big red car was expensive" 这句话,通过识别语法上的名词短语结构,可以抽取出 "The big red car" 名词块,但是可能抽不出命名实体。

TextBlob 简单介绍

TextBlob 是一个简单而强大的 Python 文本处理库,专为快速原型开发和教学设计。在 GraphRAG 的 RegexEnglish 提取器中,TextBlob 提供了高效的英语文本处理能力。

textblob.png

TextBlob 的核心特性如下:

  • 简洁的 API:提供直观的文本处理接口
  • 快速处理:基于 NLTK 和正则表达式的高效实现
  • 内置功能:集成了常用的 NLP 任务
  • 轻量级:依赖少,安装简单

TextBlob 提供了丰富的文本处理功能:

from textblob import TextBlob

# 创建 TextBlob 对象
text = "Apple Inc, founded by Steve Jobs, develops innovative iPhone technology in California."
blob = TextBlob(text)

# 1. 分词
print("分词结果:")
print(blob.words)
# 输出:['Apple', 'Inc', 'founded', 'by', 'Steve', 'Jobs', 'develops', 'innovative', 'iPhone', 'technology', 'in', 'California']

# 2. 句子分割
print("\n句子分割:")
for sentence in blob.sentences:
    print(f"- {sentence}")
# 输出:- Apple Inc, founded by Steve Jobs, develops innovative iPhone technology in California.

# 3. 词性标注
print("\n词性标注:")
for word, pos in blob.tags:
    print(f"{word:12} {pos}")
# 输出:
# Apple        NNP
# Inc          NNP  
# founded      VBN
# by           IN
# Steve        NNP
# ...

# 4. 名词短语
print("\n名词短语:")
for noun in blob.noun_phrases:
    print(f"- {noun}")
# 输出:
# - apple inc
# - steve jobs
# - innovative iphone technology
# - california

由于 TextBlob 基于 NLTK 实现,它依赖 NLTK 的一些资源,比如 punkt_tab 用于分割句子,averaged_perceptron_tagger_eng 用于词性标注,brown 用于名词短语提取(布朗语料库),可以通过 textblob.download_corpora 全量下载:

$ python -m textblob.download_corpora

可以看出,TextBlob 的功能和 SpaCy 是有部分重合的,TextBlob 的优势是轻量级、简单易用、快速上手,比较适合原型开发和教学,但是它准确性有限、多语言支持不足、扩展性和配置选项相对较少,在生产环境建议还是使用 SpaCy。因此在 GraphRAG 的代码里也可以看到,作者计划将 TextBlob 移除,使用 SpaCy 来重新实现 RegexEnglish 提取器。

未完待续

我们今天主要学习 GraphRAG 中基于 NLP 的知识提取流程,这是 Fast 索引方法的核心组件。我们了解到 Fast 索引方法中关于知识提取的有两个工作流:基于 NLP 的图谱提取图谱修剪。为了更好地学习相关的提取技术,我们先介绍了 SpaCy 和 TextBlob 这两个 NLP 库的使用,通过简单示例加深基本概念的理解。

明天我们将继续学习这部分内容,包括三种名词短语提取器的实现原理,通过共现关系构建边,以及多种不同的图谱修剪策略。