ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Graphiti实战:基于LLM与向量数据库的动态知识图谱构建指南

Graphiti实战:基于LLM与向量数据库的动态知识图谱构建指南

1. 项目概述:从海量文档到实时洞察的挑战

最近在做一个内部项目,需要把公司过去几年积累的几百份技术文档、会议纪要和产品手册“盘活”。老板的要求很直接:能不能做个系统,让新来的同事问个问题,比如“我们A产品的数据备份方案和B方案在成本上有啥区别?”,系统能立刻从这些文档里找到相关信息,并且把产品、方案、成本这些概念之间的关系清晰地展示出来,而不是扔给用户一堆需要自己再整理的搜索结果。这个需求,本质上就是构建一个能够实时查询和推理的知识图谱系统。

传统的知识图谱构建,往往是个“离线批处理”的活儿。你需要用NLP工具从文档里抽取出实体(比如产品名、技术名词)和关系(比如“包含”、“优于”、“依赖于”),然后存进图数据库。这个过程耗时很长,数据更新也不及时。而“实时”的要求,意味着我们需要一种更敏捷的方式:当用户提出一个新问题时,系统能动态地从最新的文档中抽取知识,并即时构建出一个针对该问题的、轻量级的图谱片段进行展示和推理。这就是我选择Graphiti这个框架进行实战探索的核心原因。

Graphiti 并不是一个图数据库,而是一个将大语言模型(LLM)的语义理解能力与图结构(Graph)的关联推理能力结合起来的开发框架。它的核心思路是“按需构图”。系统不会事先把所有可能的知识都抽取并存储成一张大图(那成本高且维护难),而是利用LLM理解用户查询的意图,动态地决定需要从文档中抽取哪些实体和关系,并即时组织成图谱进行回答。这对于处理海量、非结构化文档且需求多变的场景来说,非常具有吸引力。接下来,我将完整分享这次实战的笔记,包括设计思路、关键实现、踩过的坑以及一些性能调优的心得。

2. 核心架构与工具选型解析

2.1 为什么是 Graphiti + LLM + 向量数据库的组合?

面对“海量文档实时知识图谱”的需求,我评估了几个方案。传统方案是:NLP流水线(实体识别、关系抽取) + 图数据库(Neo4j, NebulaGraph)。这个方案的问题是,流水线需要大量标注数据来训练,或者依赖规则,泛化能力差;且构建的是“静态全图”,任何文档更新都需要重新跑一遍流程,无法实时。

Graphiti提出的动态构图理念更符合我们的场景。其架构核心是:

  1. LLM作为“图谱构建师”:利用LLM强大的零样本/少样本理解能力,将用户查询和文档片段转化为图谱查询指令或直接生成图谱结构。它替代了传统的训练好的NLP模型。
  2. 向量数据库作为“记忆库”:所有文档被切分成片段(chunks),编码成向量后存入向量数据库(如Chroma, Weaviate)。当用户提问时,先将问题本身也转化为向量,在向量库中进行相似性检索,快速找到最相关的文档片段。这解决了“从海量文档中快速定位相关信息”的问题。
  3. Graphiti作为“协调中枢”:Graphiti框架负责编排整个流程。它接收用户查询,调用LLM分析查询意图并生成针对向量检索结果的“信息抽取指令”,然后再调用LLM根据指令和检索到的文本,抽取出实体和关系,最后组织成图结构返回。

我最终的技术栈如下:

  • 框架:Graphiti(Python)
  • LLM服务:OpenAI GPT-4 API(用于复杂意图理解和信息抽取)。对于成本敏感的部分,也用到了 GPT-3.5-Turbo。
  • 向量数据库:ChromaDB(轻量级,易于集成,适合原型和中小规模数据)。
  • 开发语言:Python 3.10+。
  • 辅助工具:LangChain(用于文档加载、文本分割和部分链式调用编排),但Graphiti本身也提供了类似的模式。

注意:LLM API的选择至关重要。GPT-4在理解复杂指令和进行精确抽取方面显著优于3.5,但成本也高。我的策略是:在构图的关键步骤(如解析查询生成抽取指令)使用GPT-4以保证质量;在简单的文本摘要或初筛时使用GPT-3.5。

2.2 环境准备与初始化

首先,建立一个干净的Python环境并安装核心依赖。

# 创建并激活虚拟环境 python -m venv graphiti_env source graphiti_env/bin/activate # Linux/Mac # graphiti_env\Scripts\activate # Windows # 安装核心包 pip install graphiti-ai pip install openai pip install chromadb pip install langchain langchain-openai pip install pypdf # 用于读取PDF文档 pip install tiktoken # 用于计算Token,控制成本

接下来,进行关键的初始化配置,主要是设置LLM和向量数据库。

import os from graphiti import Graphiti from langchain_openai import OpenAIEmbeddings, ChatOpenAI from langchain_community.vectorstores import Chroma from langchain_text_splitters import RecursiveCharacterTextSplitter # 1. 设置OpenAI API密钥(请替换为你的密钥,或从环境变量读取) os.environ["OPENAI_API_KEY"] = "your-api-key-here" # 2. 初始化LLM客户端 # 用于对话和复杂推理的LLM llm_gpt4 = ChatOpenAI(model="gpt-4", temperature=0.1) # temperature调低,使输出更确定 llm_gpt35 = ChatOpenAI(model="gpt-3.5-turbo", temperature=0.1) # 用于生成文本嵌入(向量)的模型 embeddings = OpenAIEmbeddings(model="text-embedding-3-small") # 性价比高 # 3. 初始化Graphiti graphiti_client = Graphiti() # 4. 初始化文本分割器 # 这里选择递归字符分割,尝试保持段落和句子的完整性 text_splitter = RecursiveCharacterTextSplitter( chunk_size=1000, # 每个片段约1000字符 chunk_overlap=200, # 片段间重叠200字符,避免信息被割裂 length_function=len, separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""] )

实操心得chunk_size的设置是平衡检索精度和上下文完整性的关键。太小(如200)会导致信息碎片化,LLM缺乏足够上下文进行准确抽取;太大(如2000)则可能引入无关噪声,降低向量检索的准确性。1000-1500是一个常见的起步值,需要根据你的文档平均段落长度进行调整。chunk_overlap能有效缓解句子被腰斩的问题。

3. 知识库构建:文档处理与向量化

知识图谱的“知识”来源于文档,因此第一步是将非结构化的文档转化为结构化的、可检索的知识单元。

3.1 文档加载与预处理

我处理的文档包括PDF、Word和Markdown。使用LangChain的文档加载器可以统一处理。

from langchain_community.document_loaders import PyPDFLoader, UnstructuredWordDocumentLoader, TextLoader from typing import List def load_documents(directory_path: str) -> List[Document]: """加载指定目录下的所有支持格式的文档""" documents = [] for filename in os.listdir(directory_path): filepath = os.path.join(directory_path, filename) if filename.endswith('.pdf'): loader = PyPDFLoader(filepath) elif filename.endswith('.docx'): loader = UnstructuredWordDocumentLoader(filepath) elif filename.endswith('.md') or filename.endswith('.txt'): loader = TextLoader(filepath, encoding='utf-8') else: continue loaded_docs = loader.load() # 为每个文档片段添加源文件元数据,便于追溯 for doc in loaded_docs: doc.metadata["source"] = filename documents.extend(loaded_docs) return documents # 示例:加载`docs`文件夹下的所有文档 raw_documents = load_documents("./docs") print(f"共加载了 {len(raw_documents)} 个原始文档片段。")

3.2 文本分割与向量数据库持久化

加载后的文档需要被分割成更小的片段,然后转化为向量存入ChromaDB。

def create_vector_store(documents: List[Document], persist_directory: str = "./chroma_db"): """ 将文档分割、向量化并持久化到ChromaDB。 """ # 1. 分割文本 print("开始分割文本...") all_splits = text_splitter.split_documents(documents) print(f"分割后得到 {len(all_splits)} 个文本片段。") # 2. 创建并持久化向量存储 print("开始生成向量并存入数据库...") vectorstore = Chroma.from_documents( documents=all_splits, embedding=embeddings, persist_directory=persist_directory ) # 显式持久化 vectorstore.persist() print(f"向量数据库已创建并保存至 {persist_directory}") return vectorstore # 执行创建 vector_store = create_vector_store(raw_documents)

注意事项:向量数据库的持久化路径很重要。首次运行会创建,后续运行可以直接加载,无需重复向量化,节省成本和时间。

# 后续加载已有向量数据库 vector_store = Chroma(persist_directory="./chroma_db", embedding_function=embeddings)

3.3 检索策略优化:提升召回率

简单的向量相似性检索有时会漏掉关键信息。我采用了混合检索策略来提升召回率。

from langchain.retrievers import ContextualCompressionRetriever from langchain.retrievers.document_compressors import LLMChainExtractor def create_enhanced_retriever(vectorstore, llm_for_compression=llm_gpt35): """ 创建增强的检索器,结合向量检索和上下文压缩。 """ # 基础向量检索器,设置检索数量稍大 base_retriever = vector_store.as_retriever(search_kwargs={"k": 8}) # 使用LLM对检索结果进行压缩/重排,只保留与查询最相关的部分 # 这可以节省后续LLM处理的Token,并提升精度 compressor = LLMChainExtractor.from_llm(llm_for_compression) compression_retriever = ContextualCompressionRetriever( base_compressor=compressor, base_retriever=base_retriever ) return compression_retriever enhanced_retriever = create_enhanced_retriever(vector_store)

这个LLMChainExtractor会在向量检索返回片段后,再用一个小型LLM(我用GPT-3.5)快速扫描每个片段,提取出其中与查询直接相关的句子,过滤掉无关内容。实测下来,这能有效提高最终构图时输入LLM的上下文质量。

4. 动态图谱构建:Graphiti 核心实战

这是最核心的部分,即如何利用Graphiti和LLM,根据用户查询动态构建知识图谱。

4.1 定义图谱模式(Schema)

虽然我们是动态构图,但提前定义一个期望的图谱模式能极大引导LLM的输出格式,使其更结构化、更可控。我们定义实体类型和关系类型。

# 根据我们的技术文档领域,定义可能的实体和关系类型 GRAPH_SCHEMA = { "entity_types": [ "产品", "技术组件", "功能特性", "部署环境", "人员角色", "成本指标", "问题", "解决方案" ], "relationship_types": [ "包含", "依赖于", "优于", "劣于", "导致", "解决", "拥有", "属于", "相关于", "成本为" ] } # 将这个模式转化为给LLM的提示词部分 schema_prompt = f""" 你是一个知识图谱构建专家。请从给定的文本中提取信息,构建一个知识图谱。 图谱中的节点(实体)类型应限于:{', '.join(GRAPH_SCHEMA['entity_types'])}。 图谱中的边(关系)类型应限于:{', '.join(GRAPH_SCHEMA['relationship_types'])}。 请以JSON格式输出,包含`entities`和`relationships`两个列表。 每个实体应包含:`id`(唯一标识,如‘产品_A’)、`name`(显示名称)、`type`(实体类型)。 每个关系应包含:`source_id`(源实体ID)、`target_id`(目标实体ID)、`type`(关系类型)、`description`(可选,关系描述)。 """

4.2 实现动态构图管道

现在,我们将检索、LLM调用和结果解析串联起来。

import json from langchain_core.prompts import ChatPromptTemplate def build_knowledge_graph(query: str, retriever, top_k: int = 5) -> dict: """ 核心函数:根据用户查询动态构建知识图谱。 """ # 1. 检索相关文档片段 print(f"检索与查询‘{query}’相关的文档...") relevant_docs = retriever.invoke(query) context_text = "\n\n---\n\n".join([doc.page_content for doc in relevant_docs]) if not context_text: return {"entities": [], "relationships": [], "context": "未找到相关信息。"} # 2. 构建LLM提示词 prompt_template = ChatPromptTemplate.from_messages([ ("system", "你是一个精准的信息抽取助手。" + schema_prompt), ("human", """ 用户查询:{query} 请基于以下相关文本内容,抽取与查询意图紧密相关的实体和关系,构建一个聚焦的知识图谱。 注意:图谱应直接服务于回答该查询,避免抽取无关的宽泛知识。 相关文本: {context} 请输出纯净的JSON。 """) ]) # 3. 调用LLM(使用GPT-4以保证抽取质量) chain = prompt_template | llm_gpt4 response = chain.invoke({"query": query, "context": context_text}) # 4. 解析LLM的JSON输出 try: # LLM输出可能是带Markdown代码块的JSON,需要清理 content = response.content if '```json' in content: content = content.split('```json')[1].split('```')[0].strip() elif '```' in content: content = content.split('```')[1].split('```')[0].strip() graph_data = json.loads(content) # 添加检索到的源文档信息作为图谱元数据 graph_data["source_documents"] = [doc.metadata.get("source", "unknown") for doc in relevant_docs] return graph_data except json.JSONDecodeError as e: print(f"LLM返回的JSON解析失败: {e}") print(f"原始返回内容: {response.content}") # 返回一个包含错误信息的空结构 return {"entities": [], "relationships": [], "error": "图谱生成失败,LLM返回格式异常。"} # 示例查询 query_example = “我们产品A的数据备份方案和产品B的容灾方案在成本上有何差异?” result_graph = build_knowledge_graph(query_example, enhanced_retriever) print(json.dumps(result_graph, indent=2, ensure_ascii=False))

一个成功的输出可能如下所示:

{ "entities": [ {"id": "产品_A", "name": "产品A", "type": "产品"}, {"id": "备份方案_X", "name": "基于快照的增量备份方案", "type": "解决方案"}, {"id": "成本_月度1000", "name": "每月1000元", "type": "成本指标"}, {"id": "产品_B", "name": "产品B", "type": "产品"}, {"id": "容灾方案_Y", "name": "跨地域热备容灾方案", "type": "解决方案"}, {"id": "成本_月度5000", "name": "每月5000元", "type": "成本指标"} ], "relationships": [ {"source_id": "产品_A", "target_id": "备份方案_X", "type": "拥有", "description": "采用"}, {"source_id": "备份方案_X", "target_id": "成本_月度1000", "type": "成本为", "description": null}, {"source_id": "产品_B", "target_id": "容灾方案_Y", "type": "拥有", "description": "采用"}, {"source_id": "容灾方案_Y", "target_id": "成本_月度5000", "type": "成本为", "description": null}, {"source_id": "备份方案_X", "target_id": "容灾方案_Y", "type": "劣于", "description": "在容灾级别和成本上"} ], "source_documents": ["产品A白皮书.pdf", "产品B技术架构.docx", "成本核算指南.md"] }

4.3 图谱可视化与交互

生成JSON数据后,我们可以用网络图库进行可视化。这里使用networkxpyvis

import networkx as nx from pyvis.network import Network def visualize_graph(graph_data: dict, output_html_path: str = "knowledge_graph.html"): """ 将图谱数据可视化为交互式HTML网页。 """ G = nx.DiGraph() # 创建有向图 # 添加节点 for entity in graph_data.get("entities", []): G.add_node( entity["id"], label=entity["name"], title=f"类型: {entity['type']}", group=entity["type"] # 按类型分组,便于可视化区分 ) # 添加边 for rel in graph_data.get("relationships", []): G.add_edge( rel["source_id"], rel["target_id"], title=rel["type"] + (f": {rel['description']}" if rel.get("description") else ""), label=rel["type"] ) # 使用Pyvis生成交互式网络 net = Network(height="750px", width="100%", directed=True, notebook=False) net.from_nx(G) # 可以调整一些物理布局参数,让图更美观 net.set_options(""" var options = { "physics": { "forceAtlas2Based": { "gravitationalConstant": -50, "centralGravity": 0.01, "springLength": 100, "springConstant": 0.08 }, "minVelocity": 0.75, "solver": "forceAtlas2Based" } } """) net.save_graph(output_html_path) print(f"知识图谱已可视化保存至: {output_html_path}") # 在Jupyter Notebook中可以直接显示 # return net.show(f"{output_html_path}") # 可视化上面生成的图谱 visualize_graph(result_graph)

生成的HTML文件可以在浏览器中打开,你可以拖动节点,放大缩小,清晰地看到“产品A-拥有->备份方案X-成本为->月度1000元”以及“劣于”关系连接到产品B的容灾方案。这种可视化对于呈现复杂关系非常直观。

5. 高级技巧与性能优化

5.1 缓存与异步处理

频繁调用LLM API成本高、速度慢。对于相对稳定的文档库,可以对“查询-检索结果-构图”进行缓存。

import hashlib import pickle from functools import lru_cache def get_query_hash(query: str, top_k: int) -> str: """生成查询的哈希键,用于缓存""" return hashlib.md5(f"{query}_{top_k}".encode()).hexdigest() @lru_cache(maxsize=100) def cached_build_graph(query_hash: str, context_text: str) -> dict: """ 缓存构图结果。注意:context_text是检索到的文本,如果文档库更新,缓存需要失效。 这里为简化,假设文档库短期内不变。生产环境需用Redis等外部缓存并设置过期。 """ # 这里模拟一个缓存查找,实际应连接缓存数据库 cache_file = f"./cache/{query_hash}.pkl" if os.path.exists(cache_file): with open(cache_file, 'rb') as f: return pickle.load(f) # 如果没有缓存,则调用真正的构图函数(这里需要原函数支持,略作修改) # 实际应用中,应将构图逻辑封装,此处仅为示意 return None # 修改后的构图函数,加入缓存逻辑 def build_knowledge_graph_with_cache(query: str, retriever, top_k: int = 5, use_cache=True) -> dict: query_hash = get_query_hash(query, top_k) if use_cache: cached_result = cached_build_graph(query_hash, "") # 需要更精细的缓存键设计 if cached_result: print("命中缓存!") return cached_result # ... (原有的检索和构图逻辑) ... result = build_knowledge_graph(query, retriever, top_k) if use_cache: # 将结果存入缓存 cache_file = f"./cache/{query_hash}.pkl" os.makedirs(os.path.dirname(cache_file), exist_ok=True) with open(cache_file, 'wb') as f: pickle.dump(result, f) return result

对于大量并发的查询,可以考虑使用异步IO来并行处理检索和多个LLM调用(如果一次查询需要多步LLM推理)。

5.2 提示词工程迭代

LLM的表现极度依赖提示词。我通过多次实验,总结了几个有效的提示词技巧:

  1. 角色扮演:让LLM扮演“领域专家”(如“资深技术架构师”),其抽取的准确度会比通用指令更高。
  2. 少样本示例(Few-Shot):在提示词中提供1-2个完美的输入输出示例,能显著规范LLM的输出格式和质量。
  3. 分步指令:对于复杂查询,可以要求LLM先“列出查询中涉及的核心概念”,再“从文本中找出与这些概念相关的陈述”,最后“将陈述转化为图谱三元组”。这比一步到位成功率更高。
  4. 后处理校验:LLM可能生成重复实体或矛盾关系。可以写一个简单的后处理脚本,合并相同ID的实体,或根据规则(如“优于”和“劣于”不应同时存在于相同两个实体间)进行冲突检测和清理。

5.3 成本控制与监控

LLM API调用是主要成本。必须进行监控和优化。

import tiktoken def count_tokens(text: str, model: str = "gpt-4") -> int: """计算文本的Token数量""" encoder = tiktoken.encoding_for_model(model) return len(encoder.encode(text)) # 在构图函数中,记录Token消耗 def build_knowledge_graph_with_cost_tracking(query: str, retriever): # ... 检索 ... input_context = context_text[:5000] # 可以截断过长的上下文 input_prompt = f"{schema_prompt}\n\n查询:{query}\n上下文:{input_context}" input_tokens = count_tokens(input_prompt, "gpt-4") # 调用LLM... output_tokens = count_tokens(response.content, "gpt-4") total_cost = (input_tokens * 0.03 + output_tokens * 0.06) / 1000 # GPT-4粗略定价 print(f"本次构图消耗: 输入{input_tokens} tokens, 输出{output_tokens} tokens, 估算成本${total_cost:.4f}") # 可以将消耗记录到日志或数据库 # log_cost(query, input_tokens, output_tokens, total_cost) return result_graph

优化策略:

  • 上下文截断:只将最相关的文档片段传给LLM。
  • 使用更便宜的模型:在非关键步骤(如初步检索结果摘要)使用GPT-3.5。
  • 设置预算和告警:在应用层面设置每日/每月Token消耗上限。

6. 常见问题与排查实录

在实际部署和测试中,我遇到了不少问题,这里记录下最典型的几个及其解决方案。

6.1 LLM输出格式不稳定

问题:LLM有时不返回JSON,而是返回一段文字描述,或者JSON格式错误(如缺少引号)。

解决方案

  1. 强化系统提示词:在系统指令中明确强调“请输出纯净的JSON,不要包含任何额外的解释或Markdown标记”。
  2. 使用LangChain的Output ParsersPydanticOutputParserJsonOutputParser可以强制LLM输出指定格式,并在解析失败时进行重试或错误处理。
  3. 后处理清洗:如上文代码所示,尝试从返回内容中提取被Markdown代码块包裹的JSON。
  4. 降级模型:如果GPT-4仍然不稳定,可以尝试使用专门针对JSON格式进行微调的模型,或者在提示词中提供更详细的JSON Schema。

6.2 检索结果不相关导致构图偏差

问题:向量检索返回的文档片段与用户查询的意图表面相似但实际不相关,导致LLM基于错误信息构图。

解决方案

  1. 优化检索器:如前所述,使用ContextualCompressionRetriever进行重排和过滤。
  2. 混合检索(Hybrid Search):结合关键词检索(如BM25)和向量检索。ChromaDB支持同时进行。关键词检索能保证精确匹配,向量检索保证语义匹配,两者取并集或加权得分。
    # ChromaDB 支持传入 `search_type` 参数 retriever = vector_store.as_retriever( search_type="similarity", # 或 "mmr" (最大边际相关性) 进行多样性检索 search_kwargs={"k": 6, “score_threshold”: 0.5} # 可以设置相似度阈值 )
  3. 查询扩展(Query Expansion):在检索前,先用LLM对原始查询进行改写或扩展,生成多个同义或相关的查询语句,分别检索后再合并结果。这能提高召回率。

6.3 图谱规模失控或过于稀疏

问题:对于开放式查询,LLM可能抽取过多无关实体,导致图谱庞大且混乱;或者抽取的实体和关系过少,图谱没有价值。

解决方案

  1. 在提示词中约束范围:明确要求“图谱应直接服务于回答该查询,避免抽取无关的宽泛知识”。可以要求LLM先判断查询意图,再决定抽取范围。
  2. 设置抽取上限:在提示词中要求“最多抽取5个核心实体和7条关键关系”。
  3. 后处理剪枝:构图后,计算图中节点的度(连接数),过滤掉孤立节点或连接数极少的节点。或者,只保留与查询中明确提到的核心实体有路径连接的子图。

6.4 处理歧义与冲突信息

问题:不同文档可能对同一事实描述有冲突(如A文档说方案X成本1000,B文档说成本1200)。LLM可能随机选择一个或生成矛盾关系。

解决方案

  1. 在提示词中要求标注来源:让LLM在抽取每个事实时,注明其来源于哪个文档片段(通过元数据)。在后端,我们可以呈现“根据文档A,成本为1000;根据文档B,成本为1200”,将冲突暴露给用户判断。
  2. 置信度评分:可以设计简单规则,如出现频率高、来源权威性高的信息置信度高。在图谱可视化中,用边的粗细或颜色表示置信度。
  3. 人工反馈循环:允许用户对图谱中的关系进行“确认”或“纠错”,将这些反馈记录下來,用于优化后续的提示词或作为新的训练数据。

这次Graphiti实战让我深刻体会到,将LLM的动态理解能力与图谱的结构化表达能力结合,是解锁非结构化数据价值的一把利器。它不像传统方法那样追求“大而全”的完美图谱,而是追求“小而准”的即时洞察,非常贴合快速变化的知识库和探索式问答场景。最大的挑战和乐趣都来自于与LLM的“沟通艺术”——如何通过精妙的提示词,让它成为一个可靠的知识工程师。

返回列表