为了账号安全,请及时绑定邮箱和手机立即绑定

从非结构化文本中提取和可视化知识图谱:以德国历史为例的本地大语言模型应用

看看萨克森人,他们的邻居……

动机和情境

就像往常一样,过程就是目的。所以在进入代码部分之前,我先详细说明一下为什么我写了这篇文章。如果你只对结果和代码感兴趣,可以直接跳到“代码/执行细节”部分(以及 GitHub 上的笔记本)。但是你会错过重要的上下文信息……这正好引出了关于图的主题:

为什么用图?

我之前就已经在图表方面工作过了,是在更灵活地展示组织现实,而不只是纯粹的层级结构

在玩了一番RAG(检索增强生成)后,这种兴趣又重新燃起。当纯统计词嵌入不足以满足某些应用场景的需求时,将图作为RAG的基础就变得有趣了,并且有助于避免LLM(大型语言模型)生成幻觉。

图为RAG提供了更安全的环境,因为它们提供了更好的上下文理解,图封装了文本中的语义,并且通过(a)潜在的语义关系可视化和(b)再利用作为图嵌入的输入(而不是单纯的词嵌入)来促进理解的一种很好的方式。

然而,完整的RAG是第二个步骤。本文仅讨论从非结构化文本中创建图。图的嵌入和语义驱动的RAG用法超出本文范围,将在以后的文章中讨论。

为什么需要本地的LLM?

我从未在当地运行过LLM——但明显看到了兴趣所在和需求。

  • 隐私和保密性,例如处理不能传递给不受控制的第三方的公司信息。在此示例代码中,不需要达到这种保密级别。但它作为一个概念证明,展示了适用于从保密公司文件中提取知识的解决方案,因为这些信息不会离开你的电脑。
  • 使用和支持开源模型,以促进信息自主和不依赖于“赢家通吃”的公司。如果你对为什么这很重要感兴趣,我推荐阅读Daniel Jeffries的文章(并考虑订阅他的substack频道)。
  • 软硬件融合演进,使运行高质量的LLM成为可能(相对而言),在可负担的家庭设备上运行。

选择最终落在了将Ollama与Macbook M1 Pro上的Mixtral 8x7B混合专家模型相结合。Ollama承诺提供一种简单的方式在本地运行大型语言模型,果然没有让我失望——真的非常方便。

我听说Mixtral的表现非常好,比如和chatGPT 3.5差不多。网上的计算结果和反馈表明,该模型可能需要一台配备64GB内存的Mac来运行。M3处理器推出后,旧款M1硬件的价格大幅下降,这让我只需牺牲约25%到30%的性能(与新M3基准相比),就能以不到一半的价格买到它——后来我发现,即使任务非常计算密集,性能仍然足够满足我的需求。

顺便说一下,计算强度的任务也会大幅增加使用任何第三方API的成本——所以即使我还没有做过具体的计算,我也假设在硬件上的投资最终也是划算的。当然,这也假设你会长期使用这些硬件。不过,图提取并不是本地代理唯一能做的事情。我还看到本地代理在日常任务中也有很大帮助。具体这可以如何实现,我已经尝试过类似“职业教练代理”的情况——只是当时的代码还依赖于OpenAI API。

下一步是:

如上所述,知识提取和使用本地LLM都为超出本文范围的更多实验可能性提供了可能(——但关于这些可能性的进一步文章可能会在后续文章中发表)。

  • 对于提取的图的使用来说,主要用途是作为基于语义的RAG改进的基础
  • 运行LLM的其他用途有(1)用自有数据微调开源模型的可能性,帮助LLM提供更适合特定应用场景的答案,和(2)本地运行代理框架的可能。
德国播客文字稿的使用案例详情:该用例的历史

首先,我选择了一个用例,我尝试从我目前最喜欢的播客“德国人的历史”中提取知识图谱,这是 Dirk Hoffmann-Becking 主持的播客:对于任何历史爱好者来说,这是一份真心推荐。该播客的非结构化文本来自 https://open.spotify.com/episode/4rM24u1KZic2qIAs3i7nnJ?si=1d27f93c43bb4633

德国历史播客(在Spotify上)

我已经从出色的关联网站上抓取了播客的文字稿,每个时期有一个大的文本资料库(例如“Ottonians”、“Salians”、“Hohenstaufen”等)。不过,正如下面将解释的,这个例子仅限于单集的文字稿。

历史文献清楚地展示了“仅基于嵌入”的RAG的不足之处,这激发了对更语义导向的知识图谱驱动的RAG的兴趣,以查询相关文本(见上文“下一步”)。

证明如下:我已经基于对话的语料库创建了一个基于GPT的模型。但后来我查询文本时,发现结果非常参差不齐。

时间顺序和关系无疑是非常重要的概念,在历史文献中——但是,词嵌入难以表达这些概念。

一个很好的例子:尽管听起来可能很奇怪,但在语料库所涵盖的时期,教皇对皇帝实施的绝罚是一种强大的政治工具,经常被使用(……任何有自尊心的皇帝都不愿意从未被教皇绝罚过……)。当然了,教皇P1绝罚皇帝E1和绝罚皇帝E2是不一样的。特别是如果皇帝E2恰好是皇帝E1的玄孙,而教皇P1早就在几十年前就翘辫子了。

词嵌入确实很好地捕捉了“教皇绝罚皇帝”这样的关系……但它们很快就开始推测相关的名字(例如:如果教皇P1绝罚了皇帝E1,为什么他不会绝罚皇帝E2呢?)。这正是由于这些嵌入无法明确捕捉所嵌入词语的时间或关系属性。

建立这条链接实际上指的是建立一个图。 在知识图谱的表示中,只会存在从教皇P1到皇帝E1的“边”(即连接两个节点的线),而不会到E2,因为他们的生活时间间隔太长,无法共现。

这就为什么我想试试基于知识图谱的RAG。

作为第一步来说,这意味着能够创建并展示这个图表,因为图表可视化有助于理解。

GitHub上的具体示例中,代码使用了第96集“萨克森与东扩:遇见邻国”的对话文本

顺便说一句,给你“无用知识百科”增加一个有趣的冷知识:在这集中,撒克逊人(Saxons)遇到了哈拉尔德·蓝牙(Harald Bluetooth),蓝牙技术就是以他命名的(如果我没记错的话,这个名字是因为它是由诺基亚和爱立信共同发明而选择的……而且https://en.wikipedia.org/wiki/Harald_Bluetooth,哈拉尔德·蓝牙国王恰好是第一个成功统一瑞典人和挪威人(或者说至少是他们的先祖)的人):-)

黑客松

我的本意就是这样……缺少的就是机会。这个机会以杜塞尔多夫Python用户组PyDDF举办的Python黑客松的形式到来。该活动由Marc-André Lemburg和Charly Clark(他还维护着openpyxl包)组织、主持和推动。如果你对这个小组感兴趣,可以访问他们的网站或查看他们的YouTube频道。

在黑客马拉松周末前,我做了一些研究,无意中发现了一篇在 Medium 上的文章,这篇文章有望让我达成目标的 80% 到 90%。

所以黑客马拉松的目标是理解并修改这篇文章里的代码,以便从“德国人的历史”播客的转录文本中提取包含语义信息的知识图谱,作为未来基于图谱的RAG聊天的输入内容。

这篇文章不仅引发了这一切,还对它进行了修改.

我找到的那篇有启发性的文章提供了一个很好的基础,展示了如何实现最初的意图:从未结构化的文本中提取知识,并生成知识图谱,然后进行可视化。

对这段代码示例所做的主要变更和修改包括:等

  • 转变为单一的全能笔记本电脑
  • 现在使用更强大的Mixtral模型
  • 从代码库中移除了一些看起来没有使用的函数定义
  • 根据历史用例相应调整了SYS_PROMPT

这一点让我学到很多关于提示的教训:SYS_PROMPT 是真正的提示,相比之下,USER_PROMPT 更像是(类似于RAG)SYS_PROMPT 执行任务时所需的情境信息,而不是一个提示。

这个SYS_PROMPT需要根据使用场景的改变进行仔细修订:这篇文章聚焦于印度的医疗体系,这与中世纪德国历史这个领域差异很大。初次运行的结果令人失望……直到我仔细检查了SYS_PROMPT中的每一条指令,例如,明确排除了将人物识别为实体的概念提取部分。这在处理历史文本时产生了一些限制。在将SYS_PROMPT调整到历史领域,特别是关注人物作为主体或实体后,结果有了很大的改进。

SYS_PROMPT 也是一个了解基于 LLM 的处理与传统编程差异的好起点:虽然使用的 SYS_PROMPT 指令非常明确,但它们并不能每次都生成正确的 JSON 输出格式。需要手动检查输出的质量,具体来说是检查尝试从 LLM 提示调用到结果列表加载 JSON 字符串时出错的块的数量。偶尔跳过一个块不成问题,但如果从文本块转换为 JSON 格式的成功率太低,可能需要优化文本输入或修改和改进 SYS_PROMPT。

更换大语言模型可能显得有些大材小用了。需要测试一下是否使用一个更小、更精简的模型会更高效。根据“为什么鸡要过马路”(答案:因为它们能!)的逻辑,我根据上面提到的硬件选择了性能最佳的模型,而这恰好是Mixtral。

代码/执行细节

准备工作和导入库

导入一些常用的库,也就是完成这项工作所需的包。稍后再导入用于建立和可视化图形的相关包。

UUID包用于为每个块生成一个唯一的ID,这对于后续的自我关联(即,在同一个块中共同出现的概念之间建立边)非常重要。

Ollama 被设置为客户端。稍后会定义 Ollama 将要调用的具体模型(比如 Mixtral)。

# ## 初始化
import pandas as pd  
import numpy as np  
import os  
from langchain_community.document_loaders import TextLoader  
from langchain.text_splitter import RecursiveCharacterTextSplitter  
from pathlib import Path  
import random  

# 辅助函数所需的包  
import uuid  

# 用于定义提示的包  
import sys  
sys.path.append("..")  
import json  

# 设置 Ollama 作为 LLM  
from langchain_community.llms import Ollama  
import ollama  
from ollama import Client  
client = Client(host='http://localhost:11434')

图形提示功能

这段代码中最重要的功能是从输入给该功能的文本片段中提取所谓的三元组(节点-边-节点)。这些三元组以 JSON 文件的格式返回,并代表文本片段中的语义信息。在该示例的历史背景下(如结果文件所示),这通常简化为“演员 A 对演员 B 做了什么”。其中,演员 A 是三元组中的节点1,演员 B 是三元组中的节点2,而“做了什么”描述了两者之间的关系,从而成为三元组中的 Edge。

在这种情况下,Mixtral 被定义为 Ollama 使用的模型。当然没问题:模型必须先下载才行。更多详情请参阅链接:https://medium.com/llamaindex-blog/running-mixtral-8x7-locally-with-llamaindex-e6cebeabe0ab

如前所述:这里定义的SYS_PROMPT对于实现主要目标非常重要:它强制Mixtral从文本片段中提取语义关系,并以特定的JSON格式返回(正如之前提到的,这种情况并不总能成功)。我觉得非常幸运的是,文章的作者Rahul Nayak知道他在做什么。我自己可能想不出这样的方法。但是,如上所述,还是需要调整一下提示:在关于印度卫生系统的文章(这是灵感文章的背景)中,有些内容比历史播客的文字记录更加重要。

你知道吗?USER_PROMPT 就是要处理的文本块。

正如代码所示,在处理实际文本时,我发现两个问题:块ID(Chunk ID)并不总是正确地包含在输出的JSON中。因此,我采用了相当非主流的“如果看起来很傻但有效,那它就不是傻”的解决方案。

我发现从测试运行中,Mixtral 会插入不同长度的转义序列,并在前面加上一些额外信息,然后才开始包含 JSON 反馈的列表。在解决这两个问题之后,在返回结果列表之前,这些问题都得到了解决。

最后,无论 JSON 字符串是否正确,我都会把它打印出来,以便观察处理进度。

    #################################  
    # 使用的LLM定义  
    #################################  
    ##########################################################################  
    def graphPrompt(input: str, metadata={}, model="mixtral:latest"):  
        if model == None:  
            model = "mixtral:latest"  

        chunk_id = metadata.get('chunk_id', None)  

        # model_info = client.show(model_name=model)  
        # print(chalk.blue(model_info))  

        SYS_PROMPT = ("您是一个网络图制作者,任务是从给定的上下文中提取术语及其关系,任务如下:您将提供一个上下文段落(用``包围),您的任务是提取在给定上下文中提到的术语的本体论。这些术语应代表上下文中的关键概念。 \n"  
            "思路1:在每句话中,考虑其中提到的关键术语。\n"  
                "\t术语可能包括人(主体)、地点、组织、日期、持续时间、\n"  
                "\t条件、概念、物体、实体等。\n"  
                "\t术语应尽可能细化\n\n"  
            "思路2:考虑这些术语与其他术语之间的一对一关系。\n"  
                "\t在同一句子或段落中提到的术语通常彼此相关。\n"  
                "\t术语可以与其他许多术语相关\n\n"  
            "思路3:找出每对相关术语之间的关系。\n\n"  
            "以列表形式输出JSON。列表中的每个元素包含一对术语及其关系,如下所示。请勿更改此提示中定义的chunk_id值: \n"  
            "[\n"  
            "   {\n"  
            '       "chunk_id": "CHUNK_ID_GOES_HERE",\n'  
            '       "node_1": "从提取的本体论中提取的概念",\n'  
            '       "node_2": "从提取的本体论中提取的相关概念",\n'  
            '       "edge": "node_1和node_2之间的关系,用一两句描述"\n'   
            "   }, {...}\n"  
            "]"  
        )  
        SYS_PROMPT = SYS_PROMPT.replace('CHUNK_ID_GOES_HERE', chunk_id)  

        USER_PROMPT = f"context: ```{input}``` \n\n output: "  

        response = client.generate(model="mixtral:latest", system=SYS_PROMPT, prompt=USER_PROMPT)  

        aux1 = response['response']  
        # 从 start_index 开始切片,提取 JSON 部分并解决意外的转义问题
        start_index = aux1.find('[')  
        json_string = aux1[start_index:]  
        json_string = json_string.replace('\\\\\_', '_')  
        json_string = json_string.replace('\\\\_', '_')  
        json_string = json_string.replace('\\\_', '_')  
        json_string = json_string.replace('\\_', '_')  
        json_string = json_string.replace('\_', '_')  
        json_string.lstrip() # 去除可能的前导空格  
    #####################################################  
        print("json-string:\n" + json_string)  
    #####################################################           
        try:  
            result = json.loads(json_string)  
            result = [dict(item) for item in result]  
        except:  
            print("\n\nERROR ### 这是错误响应: ", response, "\n\n")  
            result = None  
        print("§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§")  

        return result

其他帮助函数

  • documents2Dataframe: 从输入文档的分段创建数据帧;在这里,每个块都通过其 UUID(唯一标识符)进行唯一区分,以确保在处理过程中清晰区分每个块——这对于进一步处理非常重要。顺便说一句,我想仅使用数据帧的索引就足够了……但俗话说:“不要在运行的系统上做改动!”
  • df2Graph: 这是一个包装函数,用于更重要的 graphPrompt() 函数;此函数将 graphPrompt() 函数应用于通过前者 documents2Dataframe() 函数创建的数据帧中的每一行。
  • graph2DF: 所谓的反向函数。这个函数将 Mistral 从文本块中提取出的语义信息列表中的 JSON 三元组转换成数据帧。
  • contextual_proximity: 上面链接的启发性文章很好地解释了此函数的作用。这是一个自连接查询,用于识别给定块中概念的共现次数。假设共现次数越多,这些相关概念(因此语义意义)的相关性也就越大。
    # ## 函数定义
    def documents2Dataframe(documents) -> pd.DataFrame:  
        rows = []  
        for chunk in documents:  
            row = {  
                "text": chunk.page_content,  
                **chunk.metadata,  
                "chunk_id": uuid.uuid4().hex,  
            }  
            rows = rows + [row]  

        df = pd.DataFrame(rows)  
        return df  

    def df2Graph(dataframe: pd.DataFrame, model=None) -> list:  
        # dataframe.reset_index(inplace=True)  
        results = dataframe.apply(  
            lambda row: graphPrompt(row.text, {"chunk_id": row.chunk_id}, model), axis=1  
        )  
        # 无效的 JSON 结果会被转换为 NaN  
        results = results.dropna()  
        results = results.reset_index(drop=True)  

        ## 将列表的列表扁平化为单个实体列表。  
        concept_list = np.concatenate(results).ravel().tolist()  
        return concept_list  

    def graph2Df(nodes_list) -> pd.DataFrame:  
        ## 删除所有空值实体  
        graph_dataframe = pd.DataFrame(nodes_list).replace(" ", np.nan)  
        graph_dataframe = graph_dataframe.dropna(subset=["node_1", "node_2"])  
        graph_dataframe["node_1"] = graph_dataframe["node_1"].apply(lambda x: x.lower())  
        graph_dataframe["node_2"] = graph_dataframe["node_2"].apply(lambda x: x.lower())  
        return graph_dataframe  

    def contextual_proximity(df: pd.DataFrame) -> pd.DataFrame:  
        ## 将数据帧转换为节点列表  
        dfg_long = pd.melt(  
            df, id_vars=["chunk_id"], value_vars=["node_1", "node_2"], value_name="node"  
        )  
        dfg_long.drop(columns=["variable"], inplace=True)  
        # 将出现在相同文本块中的术语连接起来  
        dfg_wide = pd.merge(dfg_long, dfg_long, on="chunk_id", suffixes=("_1", "_2"))  
        # 删除自环  
        self_loops_drop = dfg_wide[dfg_wide["node_1"] == dfg_wide["node_2"]].index  
        dfg2 = dfg_wide.drop(index=self_loops_drop).reset_index(drop=True)  
        ## 按节点分组并计算边的数量。  
        dfg2 = (  
            dfg2.groupby(["node_1", "node_2"])  
            .agg({"chunk_id": [",".join, "count"]})  
            .reset_index()  
        )  
        dfg2.columns = ["node_1", "node_2", "chunk_id", "count"]  
        dfg2.replace("", np.nan, inplace=True)  
        dfg2.dropna(subset=["node_1", "node_2"], inplace=True)  
        # 删除计数为 1 的所有边  
        dfg2 = dfg2[dfg2["count"] != 1]  
        dfg2["edge"] = "上下文邻近"  
        return dfg2

输入输出变量

这一部分定义了子目录和精确的文件名,输入从其中获取,结果写入其中。该单独的“仅可视化”笔记本遵循相同的约定,可以直接读取此代码生成的结果。

显然,这部分如果应用到其他场景中,需要改成实际的数据源。

    # ## 变量声明  
    ## 输入数据目录  
    ##########################################################  
    input_file_name = "Saxony_Eastern_Expansion_EP_96.txt"  
    ##########################################################  
    data_dir = "HotG_Data/" + input_file_name  
    inputdirectory = Path(f"./{data_dir}")  

    ## 输出CSV文件将存放于此  
    outputdirectory = Path(f"./data_output")  

    output_graph_file_name = f"输出图形文件名_{input_file_name[:-4]}.csv"  
    output_graph_file_with_path = outputdirectory/output_graph_file_name  

    output_chunks_file_name = f"输出片段文件名_{input_file_name[:-4]}.csv"  
    output_chunks_file_with_path = outputdirectory/output_chunks_file_name  

    output_context_prox_file_name = f"输出上下文邻近文件名_{input_file_name[:-4]}.csv"  
    output_context_prox_file_with_path = outputdirectory/output_context_prox_file_name

剩下的代码就跟那篇文章里提到的一样。

加载并切分文档

    # ## 加载文档  

    #loader = TextLoader("./HotG_Data/Hanse.txt")  
    loader = TextLoader(inputdirectory)  
    Document = loader.load()  
    # 移除不必要的换行符  
    Document[0].page_content = Document[0].page_content.replace("\n", " ")  

    splitter = RecursiveCharacterTextSplitter(  
        chunk_size=1000,  
        chunk_overlap=100,  
        length_function=len,  
        is_separator_regex=False,  
    )  

    pages = splitter.split_documents(Document)  
    print("块数 = ", len(pages))  
    print(pages[5].page_content)

创建一个数据框来处理这些块

    ## 创建所有块的数据框  
    df = documents2Dataframe(pages)  
    print(df.shape)  
    df.head()

提取核心代码:这才是代码的核心部分!

df2Graph会被调用在整个包含文本片段的数据框中;如前所述,df2Graph会将graphPrompt()函数应用于数据框中的每个文本片段。而这个graphPrompt()函数则根据SYS_PROMPT中的指令,从文本片段中实际提取知识。

这两个数据框分别保存了包含分块文本的信息和包含检索到三元组的图形信息,这样就不用为了简单可视化而重新生成这些信息。

    # ## 提取概念  
    ## 要使用LLM再生图,请将此值设为True  
    ## 如果需要重新生成耗时的知识抽取过程,请将此值设为True  
    ##################  
    regenerate = False  # 如果需要重新生成耗时的知识抽取过程,请将此值设为True  
    ##################  
    if regenerate:  
    #########################################################      
        concepts_list = df2Graph(df, model='mixtral:latest')  
    #########################################################  
        dfg1 = graph2Df(concepts_list)  

        if not os.path.exists(outputdirectory):  # 检查输出目录是否存在  
            os.makedirs(outputdirectory)  

        dfg1.to_csv(output_graph_file_with_path, sep=";", index=False)  
        df.to_csv(output_chunks_file_with_path, sep=";", index=False)  
    else:  
        dfg1 = pd.read_csv(output_graph_file_with_path, sep=";")  

    ## 将空字符串替换为NaN值  
    dfg1.replace("", np.nan, inplace=True)  
    dfg1.dropna(subset=["node_1", "node_2", 'edge'], inplace=True)  
    ## 将关系权重增加到4。  
    ## 在稍后计算上下文接近度时,我们将权重设置为1。  
    dfg1['count'] = 4   
    ## 输出数据框的形状  
    print(dfg1.shape)  
    ## 输出数据框的前几行  
    dfg1.head()

计算上下文接近度

如上所述:这段代码统计给定文本中概念共同出现的次数。假设共现次数越多,这两个概念之间的关系及语义含义就越强。

注意,也将上下文邻近性数据帧保存为 CSV 文件格式到指定的输出目录下。

    # ## 计算上下文邻近度  
    # dfg2 = contextual_proximity(dfg1)  
    dfg2.to_csv(output_context_prox_file_with_path, sep=";", index=False)  
    dfg2.tail()#  

    # ### 合并两个DataFrame  
    dfg = pd.concat([dfg1, dfg2], axis=0)  
    dfg = (  
        dfg.groupby(["node_1", "node_2"])  
        .agg({"chunk_id": ",".join, "edge": ','.join, 'count': 'sum'})  
        .reset_index()  
    )
图可视部分(注释)

这段代码与那篇文章相比没有变化。再说一遍:我很高兴拉胡尔·奈亚克显然知道自己在做什么,而且做得很好!

这里的改动可以带来有趣的新变化。例如,我假设社区生成可能还有更适合的其他算法(代码采用的是Girvan-Newman算法),可能更适用于实际应用。因此这里仍有很大的实验余地。

初始化 NetworkX 图对象:

     ## 计算NetworkX图的
    nodes = pd.concat([dfg['node_1'], dfg['node_2']], axis=0).unique()  
    nodes.shape  

    import networkx as nx  
    G = nx.Graph()  

    ## 添加节点到图中  
    for node in nodes:  
        G.add_node(  
            str(node)  
        )  
    ## 添加边到图中  
    for index, row in dfg.iterrows():  
        G.add_edge(  
            str(row["node_1"]),  
            str(row["node_2"]),  
            title=row["edge"],  
            weight=row['count']/4  
        )

社区划分

    # 计算社区以便给节点着色
    communities_generator = nx.community.girvan_newman(G)  
    第一层社区 = next(communities_generator)  
    下一层社区 = next(communities_generator)  
    communities = sorted(map(sorted, 下一层社区))  
    print(f"社区数量:{len(communities)}")  
    print(communities)

准备数据以添加颜色代码信息到图表并增强图表的颜色信息

根据之前计算出的每个节点的社区归属情况,为图中的节点应用颜色。

    # ### 为社区颜色创建数据框
    import seaborn as sns  # 导入seaborn库
    palette = "hls"  
    ## 将这些颜色添加到各个社区,并创建一个新的数据框  
    def colors2Community(communities) -> pd.DataFrame:  
        ## 定义颜色调色板  
        p = sns.color_palette(palette, len(communities)).as_hex()  
        random.shuffle(p)  
        rows = []  
        group = 0  
        for community in communities:  
            color = p.pop()  
            group += 1  
            for node in community:  
                rows += [{"node": node, "color": color, "group": group}]  
        df_colors = pd.DataFrame(rows)  
        return df_colors  

    colors = colors2Community(communities)  # 调用colors2Community函数
    colors  # 显示结果

    # ### 将颜色添加到图
    for index, row in colors.iterrows():  # 遍历颜色数据框的每一行
        G.nodes[row['node']]['group'] = row['group']  # 设置节点的组
        G.nodes[row['node']]['color'] = row['color']  # 设置节点的颜色
        G.nodes[row['node']]['size'] = G.degree[row['node']]  # 设置节点的大小

创建或初始化 pyviz 网络对象,并显示生成的网络图。

导入 Network 从 pyvis.network  
net = Network(  
    notebook=True,  
    # bgcolor="#1a1a1a",  
    cdn_resources="remote",  
    height="800px",  
    width="100%",  
    select_menu=True,  
    # font_color="#cccccc",  
    filter_menu=False,  
)  
net.from_nx(G)  
net.force_atlas_2based(central_gravity=0.015, gravity=-31)  

net.show_buttons(filter_=['physics'])  
net.show("knowledge_graph.html")  # 生成一个名为 'knowledge_graph.html' 的文件

最后,你应该得到这样的结果。

知识图谱,第九十六集的。

这张图是互动的:你可以放大缩小,拖动节点到不同位置等。对于预期目的来说,这是最直观的知识发现和探索的理想入口!有没有之前未曾注意到的关系?有没有意外联系?有没有之前我认为很重要但可能被忽略的东西?等等。

在你被这张图本身所吸引之前,请注意一下上面代码块中的代码:

net.show_buttons(filter_='物理')

我花了一段时间才注意到:但这在图表下面增加了一个交互控制区,让你调整图表显示的物理效果。你只需向下滚动就能看到它。这为探索提供了更多的可能性。为了确保你知道要寻找什么,这里

按钮控件来控制图表物理行为

到目前为止,代码如下所示:感兴趣的朋友们可以点击下面的链接,这里再提供一下相应的GitHub仓库链接。

GitHub - syrom/LocalKnowledgeGraphExtraction (本地知识图谱提取): 从普通、非结构化文本中提取知识图谱并进行可视化展示……GitHub 页面
尾声:
Ollama 错误

整个领域仍然很新——而且新软件通常还处于实验阶段。这同样适用于例如Ollama这样的软件。我最初尝试过夜运行上述代码,处理涵盖整个历史时期(例如萨利安王朝、霍恩施陶芬王朝等)的转录,一次最多处理40集。但这行不通,因为Ollama会在某个时刻停止回应代码对Mixtral的调用。

这个错误似乎与内存溢出或内存泄漏有关,因为它在经过一定数量的代数周期后才会出现。每一代处理一段文本并将生成的JSON格式。

这个bug已在GitHub上被识别并标记等,并且在今天发布的Ollama更新(2024年03月29日)中部分修复了。

更新之后,第一次可以使用下面的代码处理大量文本中的每个块,具体来说,将文本分成100个块,每个块大小为1000个字符,并有100个字符的重叠部分。

不幸的是,当分块大小大于120时,我仍然不可避免地遇到了LLM调用停滞的问题:代码执行会直接停止,不再返回任何结果。尽管内核仍然处于活动状态,这已经足够处理大约3个播客剧集的文本。但正如先前提到的,GitHub示例仅使用单集文本以确保其确实有效。

这个问题显然与所使用的新工具有关——随着后续的更新,这些问题可能完全消失,也可能仍然存在。

性能

如果你以为本地生成很容易,那就再想想看。

知识提取过程在本地机器(如MacBook M1 Pro)上表现很慢。这说明了背后处理的复杂程度。我记录了处理每个块生成JSON字符串所需的时间,大约30秒至1分钟之间,平均大约40秒。因此,大约100个块(每个块约1000字符,总共约10万个字符)需要超过一个小时的处理时间来生成知识图谱。另外,最好别断开电源。平时省电的MacBook一旦运行起脚本,就开始大量耗电。

因此,代码还将结果保存为多种形式的CSV文件。这样一来,一旦提取完成,知识图谱可以更快地重建,只需加载提取过程中生成的结果文件即可。或者,输出可以用作第二步中的RAG-input。

,如前所述:有一台专门的笔记本电脑,用于从GitHub上的保存文件中重新构建知识图谱,跳过耗时且费力的提取步骤。

……如果你喜欢这篇文章或觉得它有用,记得留下鼓励的赞同 :-)

根据要求,不提供额外解释,以下是直接翻译:
……如果你喜欢这篇文章或觉得它有用,记得留下鼓励的赞同 :-)

保留原有情感符号,以保持原文语气。

这个故事刊登在“生成式人工智能出版物”(Generative AI Publication)。

关注我们并点击访问 SubstackLinkedInZeniteq,以了解最新的 AI 动态。让我们一起塑造 AI 的未来吧!

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消