1. 项目概述:从零构建文本知识图谱的本地化实践
最近在整理个人知识库时,我一直在寻找一种比传统关键词搜索和向量检索更深入、更能揭示内容内在关联的方法。相信很多从事内容分析、研究或者单纯想深度消化一本电子书的朋友都有过类似的困扰:我们读了很多材料,但知识点是孤立的,难以形成体系化的认知。这正是知识图谱可以大显身手的地方。这个项目,qianniuspace/llm_notebooks中的一个子模块,提供了一个非常接地气的解决方案:利用本地运行的大语言模型(LLM),将任意文本语料库(如PDF、TXT文档)自动转化为可视化的知识图谱(Knowledge Graph)。
简单来说,它就像给你的文档内容做了一次“CT扫描”,不仅列出所有重要的“器官”(概念),还清晰地展示出它们之间是如何通过“血管”(关系)连接在一起的。与依赖云端API(如GPT-4)的方案不同,本项目核心是“完全本地化”和“经济性”,使用 Ollama 部署 Mistral 7B 这类开源模型,使得数据处理过程零成本、隐私安全。它特别适合处理内部文档、敏感资料,或者单纯想体验大模型能力而不想付费的研究者、开发者。接下来,我将详细拆解这个项目的设计思路、每一步的具体操作、我踩过的坑以及如何让它更好地为你工作。
2. 核心思路与架构设计解析
2.1 为什么是“概念”而非“实体”?
传统的信息提取(如NER,命名实体识别)专注于识别文本中的具体实体,如人名“马斯克”、地点“上海”、组织“OpenAI”。这个项目的第一个关键设计抉择是:提取“概念”而非“实体”。这是一个至关重要的认知提升。
- 概念 vs. 实体:实体是具体的、客观存在的对象。而概念可以是更抽象、更复合的语义单元。例如,在句子“电动汽车的快速发展得益于电池能量密度的提升和充电网络的完善”中:
- 实体提取可能得到:“电动汽车”、“电池”、“充电网络”。关系较难直接定义。
- 概念提取则可能得到:“电动汽车的快速发展”、“电池能量密度提升”、“充电网络完善”。这些概念本身就蕴含了状态、事件或属性,它们之间的因果关系(“得益于”)更容易被模型识别和建立。
我的实操心得:在初期测试中,使用实体构建的图谱往往节点稀疏,关系多为简单的“位于”、“属于”,图谱显得干瘪。而提取概念后,图谱立刻变得“有血有肉”,节点之间能形成“导致”、“促进”、“对比”、“需要”等丰富的语义关系,对于理解文本的深层逻辑和论证结构帮助巨大。这相当于从“识别词汇”升级到了“理解意群”。
2.2 核心流程:从文本块到关系网络
项目的核心流程可以概括为下图所示的几个步骤,我将其拆解并补充了每个环节的设计考量:
原始文本 -> 文本分块 -> LLM提取概念与关系 -> 基于共现补充关系 -> 关系融合与权重计算 -> 构建图谱 -> 可视化文本分块:这是所有文档处理的第一步。这里没有采用简单的固定长度分割,而是需要考虑语义的完整性。一个段落或一个章节的结束通常是自然的分割点。分块的大小需要权衡:块太大,LLM提取的关系可能过于宏观和混杂;块太小,上下文信息不足,可能无法识别有效关系。通常,我会将块大小设置在200-500个token之间,并尽量保证块在句子的完整边界处切断。
LLM提取(核心步骤):对每一个文本块,我们向本地运行的Mistral 7B模型发送一个精心设计的提示词(Prompt),要求它完成一项结构化输出任务。这个Prompt是关键中的关键。
# 一个简化版的Prompt示例 system_prompt = """ 你是一个知识提取专家。请从以下文本片段中提取核心概念以及概念之间的语义关系。 请严格按照JSON格式输出,包含两个字段:“concepts”和“relations”。 “concepts”是一个字符串列表。 “relations”是一个列表,其中每个元素是一个字典,包含“source”(源概念)、“target”(目标概念)和“relation”(关系描述,如“导致”、“包含”、“对比”等)。 只输出JSON,不要有任何额外解释。 """ user_prompt = f"文本片段:{text_chunk}"LLM会根据这个指令,分析文本块,输出结构化的JSON数据。这一步的质量直接决定了图谱的“原材料”好坏。
关系融合与权重计算:这是项目的巧妙之处。它识别了两种关系来源:
- 显式关系(W1):由LLM直接提取出的语义关系,如“A导致B”。
- 隐式关系(W2):在同一文本块中共同出现的概念,被认为通过上下文存在某种关联。这种共现关系为图谱增加了密度和冗余路径。 如果同一对概念(如“碳中和”和“可再生能源”)在多个块中被LLM提取出关系(可能是“推动”、“依赖”),或者多次共现,那么它们之间的连接权重就会累加。最终,一对概念之间只保留一条边,其权重是W1和W2的综合,关系描述则是所有提取到关系的汇总。这种设计极大地增强了关系的鲁棒性,避免了因单次LLM提取的偶然误差而丢失重要连接。
图谱构建与增强分析:使用NetworkX库将节点(概念)和带权重的边(关系)构建成图数据结构。之后,可以运行图算法进行深入分析:
- 节点度中心性:连接数多的节点,往往是文本的核心主题。
- 社区发现:通过算法将图中联系紧密的节点聚类,这些社区可能对应文本中的不同子主题或叙事线索。 这些分析结果可以反馈到可视化中,例如用节点大小表示重要性,用颜色区分社区。
2.3 技术选型背后的逻辑
- 模型:Mistral 7B OpenOrca:选择它而非更大的模型(如Llama2 13B/70B),首要考虑是“本地部署的可行性”。7B参数模型在消费级GPU(甚至只有CPU)上可以运行,内存占用相对可控。OpenOrca版本经过了大量的指令微调,在遵循复杂提示词和生成结构化输出(JSON)方面表现优异,这正是本项目最需要的核心能力。
- 部署工具:Ollama:它极大地简化了本地大模型的下载、管理和服务化过程。一条命令
ollama run mistral:7b-openorca就完成了从拉取模型到启动API服务的所有步骤。它提供的API接口(通常为http://localhost:11434)与OpenAI API格式兼容,使得我们可以用requests库轻松调用,避免了复杂的模型加载和环境配置。 - 图谱处理:NetworkX + Pandas:NetworkX是Python图论分析的事实标准,API成熟,社区算法丰富。Pandas则用于处理提取出的结构化数据(概念列表、关系列表),进行清洗、去重、分组和权重计算,非常顺手。这是一个轻量级、快速迭代的方案。
- 可视化:Pyvis:Pyvis能生成交互式的、基于Web的图。这意味着你可以缩放、拖拽节点、查看边的标签,体验远胜于静态的matplotlib绘图。生成的HTML文件可以单独打开,方便分享和展示。
注意事项:这个技术栈是“原型友好型”的。如果知识图谱规模变得非常大(数万节点),NetworkX在内存和计算效率上可能会遇到瓶颈,届时需要考虑Neo4j、NebulaGraph等专业的图数据库。但对于大多数文档分析场景,当前栈完全够用。
3. 环境搭建与详细实操步骤
3.1 基础环境准备
假设你已经在电脑上安装了Python(建议3.9+)和pip。首先,为项目创建一个独立的虚拟环境,这是一个好习惯,能避免包依赖冲突。
# 创建并激活虚拟环境(以conda为例,venv同理) conda create -n kg_demo python=3.10 conda activate kg_demo3.2 安装Ollama并拉取模型
安装Ollama:访问 Ollama官网 ,根据你的操作系统(Windows/macOS/Linux)下载并安装。安装过程非常简单,几乎是一键完成。
拉取并运行模型:打开终端(或命令行),执行以下命令。这会下载约4GB的模型文件。
ollama run mistral:7b-openorca首次运行会先下载模型,下载完成后会自动进入一个交互式聊天界面。你可以按
Ctrl+D退出聊天,但Ollama服务会在后台继续运行,监听本地的11434端口,等待我们的程序调用。踩坑记录:确保你的网络环境能顺畅访问GitHub等资源,因为模型是从Ollama的服务器拉取的。如果下载慢,可以考虑配置镜像源。另外,运行模型会占用较多内存(约8-10GB),请确保你的电脑有足够可用内存。
3.3 安装Python依赖库
在项目目录下,创建一个requirements.txt文件,内容如下:
requests>=2.28.0 # 用于调用Ollama API pandas>=1.5.0 # 数据处理与分析 networkx>=3.0 # 图构建与分析 pyvis>=0.3.0 # 交互式图谱可视化 pypdf2>=3.0.0 # 用于读取PDF文档(如果处理PDF) tqdm>=4.65.0 # 显示进度条,处理长文档时很实用然后在终端中安装:
pip install -r requirements.txt3.4 核心代码实现与解析
我们围绕核心的extract_graph.ipynb笔记本,将其关键步骤拆解为可执行的Python脚本模块。以下是核心函数的实现与注释。
3.4.1 文本加载与分块
import PyPDF2 from typing import List import re def load_and_chunk_pdf(pdf_path: str, chunk_size: int = 300, overlap: int = 50) -> List[str]: """ 加载PDF文件并将其分割成有重叠的文本块。 参数: pdf_path: PDF文件路径。 chunk_size: 每个块的大致token数(中文字可粗略按字计数)。 overlap: 块之间的重叠token数,防止在句子中间切断重要上下文。 返回: 文本块列表。 """ text = "" with open(pdf_path, 'rb') as file: reader = PyPDF2.PdfReader(file) for page in reader.pages: text += page.extract_text() + "\n" # 简单的按句号、问号、感叹号分割,更复杂可用nltk或spacy sentences = re.split(r'(?<=[。!?])', text) chunks = [] current_chunk = [] current_length = 0 for sent in sentences: sent_len = len(sent) if current_length + sent_len > chunk_size and current_chunk: # 保存当前块 chunks.append(''.join(current_chunk)) # 保留重叠部分,构建新块 overlap_sents = current_chunk[-int(overlap/20):] if len(current_chunk) > 1 else current_chunk # 简单估算 current_chunk = overlap_sents + [sent] current_length = sum(len(s) for s in current_chunk) else: current_chunk.append(sent) current_length += sent_len if current_chunk: chunks.append(''.join(current_chunk)) return chunks3.4.2 调用本地LLM提取概念与关系
这是整个流程的引擎。我们需要设计一个稳定的Prompt,并处理API调用。
import requests import json import time OLLAMA_API_URL = "http://localhost:11434/api/generate" def extract_concepts_and_relations(text_chunk: str, model_name: str = "mistral:7b-openorca") -> dict: """ 调用本地Ollama服务的Mistral模型,从文本块中提取概念和关系。 参数: text_chunk: 输入文本块。 model_name: Ollama中已拉取的模型名称。 返回: 包含'concepts'和'relations'的字典,若失败返回None。 """ prompt = f""" 你是一个知识图谱构建助手。请仔细分析以下文本,提取其中出现的核心概念(Concept)以及概念之间的语义关系(Relation)。 **核心概念**:指的是文本中讨论的主要观点、主题、事件、属性或复合语义单元。它们通常是名词性短语。 **语义关系**:描述概念之间如何关联,例如:导致、促进、包含、属于、对比、需要、影响、是的一部分、依赖于、等等。 **输出要求**: 1. 请以纯JSON格式输出,且只输出JSON,不要有任何额外的解释、标记或文字。 2. JSON必须包含且仅包含两个字段: - "concepts": 一个字符串列表,列出所有提取出的核心概念。 - "relations": 一个字典列表,每个字典代表一个关系,包含三个键: * "source": 源概念(来自concepts列表) * "target": 目标概念(来自concepts列表) * "relation": 关系描述(字符串) 文本内容:{text_chunk}
""" payload = { "model": model_name, "prompt": prompt, "stream": False, "options": { "temperature": 0.1, # 低温度保证输出稳定性,避免天马行空 "num_predict": 1024 # 最大输出token数 } } try: response = requests.post(OLLAMA_API_URL, json=payload, timeout=180) # 设置较长超时 response.raise_for_status() result = response.json() # Ollama的响应在'response'字段中 response_text = result.get('response', '').strip() # 尝试从响应中解析JSON。模型有时会在JSON外加```json ```标记。 if response_text.startswith('```json'): response_text = response_text[7:-3].strip() elif response_text.startswith('```'): response_text = response_text[3:-3].strip() extracted_data = json.loads(response_text) # 简单验证结构 if "concepts" in extracted_data and "relations" in extracted_data: return extracted_data else: print(f"响应结构异常: {extracted_data.keys()}") return {"concepts": [], "relations": []} except json.JSONDecodeError as e: print(f"JSON解析失败,响应文本: {response_text[:200]}...") return {"concepts": [], "relations": []} except requests.exceptions.RequestException as e: print(f"API调用失败: {e}") time.sleep(5) # 失败后等待片刻 return None except Exception as e: print(f"未知错误: {e}") return {"concepts": [], "relations": []}关键技巧:
temperature参数设置为较低值(如0.1),是为了让模型输出更确定、更遵循指令,减少随机性。这对于生成结构化的JSON至关重要。如果发现模型经常不按格式输出,可以进一步优化Prompt,在最后加上“请确保输出是有效的JSON,可以直接被json.loads()解析”。
3.4.3 构建与可视化知识图谱
处理完所有文本块后,我们得到了大量的概念和关系对。接下来是融合、计算权重并构建图谱。
import pandas as pd import networkx as nx from collections import defaultdict from pyvis.network import Network def build_knowledge_graph(extraction_results: List[dict]) -> nx.Graph: """ 将LLM提取的结果构建成带权重的NetworkX图。 参数: extraction_results: 列表,每个元素是extract_concepts_and_relations的返回结果。 返回: networkx.Graph 对象。 """ # 用于存储边和关系的字典。键:(source, target), 值:{'weight': X, 'relations': set()} edge_dict = defaultdict(lambda: {'weight': 0, 'relations': set()}) all_concepts = set() for idx, result in enumerate(extraction_results): if not result: continue concepts = result.get('concepts', []) relations = result.get('relations', []) # 1. 收集所有概念 all_concepts.update(concepts) # 2. 处理LLM提取的显式关系 (W1) for rel in relations: src = rel.get('source', '').strip() tgt = rel.get('target', '').strip() rel_desc = rel.get('relation', '').strip() if src and tgt and src in concepts and tgt in concepts: # 确保边的方向一致,这里按字母排序统一,避免重复 edge_key = tuple(sorted([src, tgt])) edge_dict[edge_key]['weight'] += 1.0 # W1 权重加1 if rel_desc: edge_dict[edge_key]['relations'].add(rel_desc) # 3. 处理同一块内概念的共现关系 (W2) # 假设同一块内所有概念两两之间都存在弱关联 for i in range(len(concepts)): for j in range(i+1, len(concepts)): src, tgt = concepts[i], concepts[j] edge_key = tuple(sorted([src, tgt])) edge_dict[edge_key]['weight'] += 0.3 # W2 权重,通常比W1小 # 共现关系可以不加具体描述,或加一个通用描述 edge_dict[edge_key]['relations'].add('上下文共现') # 创建NetworkX图 G = nx.Graph() # 添加节点 for concept in all_concepts: G.add_node(concept) # 添加边 for (src, tgt), data in edge_dict.items(): if data['weight'] > 0: # 过滤掉权重为0的边(理论上不会有) # 将关系集合连接成一个字符串作为边标签 relation_label = '; '.join(sorted(list(data['relations']))) G.add_edge(src, tgt, weight=data['weight'], label=relation_label) return G def visualize_graph(G: nx.Graph, output_html: str = "knowledge_graph.html"): """ 使用Pyvis将NetworkX图可视化为交互式HTML。 参数: G: 构建好的知识图谱。 output_html: 输出的HTML文件名。 """ # 计算节点度中心性,用于决定节点大小 degrees = dict(G.degree()) max_degree = max(degrees.values()) if degrees else 1 # 创建Pyvis网络对象 net = Network(height="750px", width="100%", bgcolor="#222222", font_color="white") # 将NetworkX图转换为Pyvis可用的数据 net.from_nx(G) # 配置节点和边的可视化属性 for node in net.nodes: node_id = node['id'] # 节点大小与度中心性成正比 node_size = 10 + (degrees.get(node_id, 0) / max_degree) * 30 node['size'] = node_size node['color'] = '#97c2fc' # 统一颜色,也可根据社区分配颜色 node['font'] = {'size': 14, 'face': 'Arial'} for edge in net.edges: # 边宽度与权重成正比 edge_width = 0.5 + edge['weight'] * 2 edge['width'] = edge_width edge['color'] = '#cccccc' if 'label' in edge: edge['title'] = edge['label'] # 鼠标悬停显示关系标签 # 启用物理布局,让图更美观 net.set_options(""" var options = { "physics": { "forceAtlas2Based": { "gravitationalConstant": -50, "centralGravity": 0.01, "springLength": 100, "springConstant": 0.08 }, "maxVelocity": 50, "solver": "forceAtlas2Based", "timestep": 0.35, "stabilization": { "iterations": 150 } } } """) net.show(output_html) print(f"知识图谱已生成并保存为: {output_html}") print(f"总节点数: {G.number_of_nodes()}, 总边数: {G.number_of_edges()}")3.4.4 主流程串联
最后,我们将所有步骤串联起来,并加入进度条和简单的错误处理。
from tqdm import tqdm def main(pdf_path: str): """主函数,串联整个流程。""" print("步骤1: 加载并分块PDF文档...") text_chunks = load_and_chunk_pdf(pdf_path, chunk_size=350, overlap=50) print(f"共得到 {len(text_chunks)} 个文本块。") print("步骤2: 使用LLM逐块提取概念和关系(此步骤较慢)...") all_results = [] failed_chunks = [] for i, chunk in enumerate(tqdm(text_chunks, desc="处理中")): # 可选:跳过太短的块 if len(chunk.strip()) < 20: all_results.append({"concepts": [], "relations": []}) continue result = extract_concepts_and_relations(chunk) if result is None: # API调用失败,记录并跳过 failed_chunks.append(i) all_results.append({"concepts": [], "relations": []}) print(f"\n警告:第 {i} 块处理失败,已跳过。") else: all_results.append(result) # 避免请求过快,可添加短暂延迟 # time.sleep(0.5) print(f"提取完成。失败块数: {len(failed_chunks)}") print("步骤3: 构建知识图谱...") G = build_knowledge_graph(all_results) print("步骤4: 可视化...") visualize_graph(G, "my_knowledge_graph.html") # 可选:保存图数据供后续分析 # nx.write_gexf(G, "knowledge_graph.gexf") print("流程结束。请用浏览器打开 'my_knowledge_graph.html' 查看交互式图谱。") if __name__ == "__main__": # 替换为你的PDF文件路径 pdf_file = "./your_document.pdf" main(pdf_file)4. 常见问题、优化策略与避坑指南
在实际操作中,你几乎一定会遇到下面这些问题。这里是我踩过坑后总结的解决方案。
4.1 LLM提取质量不稳定
- 问题:模型有时会输出非JSON格式,或提取的概念过于琐碎/宽泛,关系不准确。
- 排查与解决:
- 优化Prompt:这是最有效的手段。在Prompt中给出更清晰的例子(Few-shot Learning)。例如,在指令后附上一小段示例文本和对应的理想JSON输出。明确告诉模型“不要输出任何解释性文字”。
- 后处理清洗:对提取出的概念进行后处理,比如过滤掉过短(如少于2个字符)或明显是停用词(如“这个”、“一种”)的# 1. 两数之和
题目
给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标。
你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。
你可以按任意顺序返回答案。
示例
示例 1:
输入:nums = [2,7,11,15], target = 9 输出:[0,1] 解释:因为 nums[0] + nums[1] == 9 ,返回 [0, 1] 。
示例 2:
输入:nums = [3,2,4], target = 6 输出:[1,2]
示例 3:
输入:nums = [3,3], target = 6 输出:[0,1]
提示
- 2 <= nums.length <= 104
- -109 <= nums[i] <= 109
- -109 <= target <= 109
- 只会存在一个有效答案
进阶
你可以想出一个时间复杂度小于 O(n2) 的算法吗?
解题思路
最简单的思路是暴力枚举,时间复杂度为O(n^2),空间复杂度为O(1)。
进阶的思路是用哈希表,遍历数组,对于每个元素,在哈希表中查找是否存在target - nums[i],如果存在,则返回两个下标,如果不存在,则将当前元素加入哈希表。时间复杂度为O(n),空间复杂度为O(n)。
性能
执行用时:36 ms, 在所有 Python3 提交中击败了92.71%的用户
内存消耗:16.5 MB, 在所有 Python3 提交中击败了5.18%的用户
声明
来源:力扣(LeetCode) 链接:https://leetcode-cn.com/problems/two-sum 著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。