做本地知识库问答最尴尬的场景,不是模型效果不够好,而是你辛辛苦苦搭好了一套 RAG 流水线,结果问它“A 和 B 之间是什么关系”这类问题,它只能甩给你两段语义相近但互不关联的文本片段。传统向量检索擅长找相似段落,却很难理解实体之间的网状关联。GraphRAG 正好补上这个短板——它先在文档里抽取实体和关系,构建成知识图谱,再基于图谱做检索增强生成。这篇文章分享一套完整落地过程:用 Ollama 做本地推理引擎,把 GraphRAG 索引管线和 Neo4j 图数据库串起来,从零搭一套能回答复杂关系问题的私有化问答系统。整个过程不依赖任何云端服务,数据不出内网,适合企业知识库、内部文档问答,也适合想搞懂 GraphRAG 原理的开发者直接参考。
1. 整体设计拆解:GraphRAG + 本地模型为什么能成
1.1 传统 RAG 的短板和 GraphRAG 的补位
传统 RAG 的流程是:把文档切片,做向量化,建立向量索引。用户提问时,拿问题向量去库里找 Top-K 相似片段,再把片段拼进 prompt 丢给大模型。这个流程在“这个问题在某一段落里有明确答案”的时候很好用,比如“物流部门的负责人是谁”。但一旦问题是跨段落、跨文档的,比如“研发部负责的 XX 项目用了哪些技术栈”,传统 RAG 就抓瞎了。因为答案分散在多个实体和关系里,向量相似度检索只会给你“看似相关但不完整”的片段。
GraphRAG 的思路是把信息抽成图:文档里的“部门”“项目”“技术栈”都是节点,它们之间的关系是边。比如(研发部)-【负责】->(XX 项目)-【使用】->(Python)。问答时我们先在图里找到相关实体,再沿着边跳一步或两步,把所有直接相关的路径捞出来,交给大模型综合回答。这样得到的上下文不是零散碎片,而是结构化的关系网络,回答“谁和谁什么关系”这类问题就顺理成章了。
实际部署里我不建议把图检索和向量检索做成二选一。我的做法是:核心关系存在图里,细节描述继续走向量召回。两者结果合并后一起进 prompt,既能给出关系型答案,又能保留原始文档里的关键描述,效果比单一方案稳得多。
1.2 选用 Ollama 做本地推理的几个关键理由
本地化推理引擎有很多选择:llama.cpp、LM Studio、vLLM、FastGPT 自带推理模块等。我最终选择 Ollama,倒不是因为它的推理速度最快,而是在 GraphRAG 这个场景里,Ollama 的集成成本最低。
第一,Ollama 内置 OpenAI 兼容接口。GraphRAG 索引管线和问答模块只需要按照 OpenAI 客户端的方式调用,把base_url指到http://localhost:11434/v1就行。这省掉了自己封装推理接口的工作量,也不用改 GraphRAG 底层代码。
第二,模型管理和量化开箱即用。Ollama 一条命令就能拉取模型、做量化参数选择、加载到显存里跑推理。模型文件用 GGUF 格式,显存不够就换小量化级别,非常灵活。它不像 vLLM 那样需要写一堆调度配置,也不像 llama.cpp 那样要自己编译和写启动脚本。
第三,Ollama 支持“抽取用模型”和“问答用模型”分离。知识图谱的实体抽取任务比较重,但对创造性要求不高;最终问答则更考验语言表达和逻辑。我可以让抽取流程用 7B 的 Qwen,问答流程用 14B 甚至更大的模型,互不干扰。这种分工只用修改配置,成本上很划算。
1.3 系统整体工作流程
整套系统从原始文档到用户回答,分两条链路。
索引链路是离线的:文档经过清洗和分块后,交给大模型做实体和关系抽取。抽取结果写入 Neo4j,同时把每个实体关联的原始文本片段做向量化,存储到向量索引里。GraphRAG 的索引管线还会额外做社区检测,把关系密集的一组实体合并成社区摘要,后续做全局提问时能直接利用社区级的上下文。
问答链路是在线的:用户问题进来后,先做实体识别,把问题里的关键实体定位到图上;然后在图里做多跳扩展,找到相关路径和邻居节点;同时用向量检索召回相关文档片段。图谱路径和文本片段合并,统一拼进 prompt,交给 Ollama 上的本地模型生成最终答案。
这个链路的好处是每个环节都可以独立调试。实体抽取得不准就调提示词;图路径扩展太宽就限制跳数;向量召回结果不好就换 embedding 模型。后面我会逐个环节讲实操细节。
2. 环境准备与基础部署
2.1 Ollama 安装:Windows / Mac / Linux 通用流程
先装 Ollama。在 Linux 服务器上,官方提供了安装脚本:
curl -fsSL https://ollama.com/install.sh | sh脚本执行完成后,用ollama --version验证安装。Windows 则是去官网下载 OllamaSetup.exe,双击安装即可。Mac 有 brew 方式:
brew install ollama这里有个坑:Windows 默认安装路径在 C 盘,模型也默认存 C 盘,系统盘小的话很快就会被撑爆。我建议从一开始就把模型目录迁移到另外的盘,避免后面返工。
Windows 上设置模型路径的方法是:先彻底退出 Ollama(任务栏图标右键退出,保险起见再去任务管理器结束ollama app.exe),然后按Win + R输入sysdm.cpl打开系统属性,在“高级—环境变量”里新建用户环境变量:
变量名:OLLAMA_MODELS 变量值:D:\ollama\models再把 Ollama 重新启动,输入ollama pull qwen2.5:7b,打开文件夹D:\ollama\models\blobs,看到模型文件在增长,说明迁移成功。
安装后最好确认一下服务状态。Linux 上 Ollama 本身是 systemd 服务,会自动启动。想调整监听地址,打开/etc/systemd/system/ollama.service,修改Environment里的OLLAMA_HOST,比如改成0.0.0.0:11434,然后:
systemctl daemon-reload systemctl restart ollama这样就允许局域网内其他机器访问这台机器的推理服务,后面做私有化知识库问答很有用。
2.2 模型下载与国内网络优化
Ollama 官方模型仓库的下载速度在部分地区不太稳定,经常出现pull model manifest: file does not exist或下载到一半超时。遇到这种情况,别死磕ollama pull,更快的办法是从国内的模型托管平台下载 GGUF 文件,再用 Ollama 导入。
以通义千问 7B 为例。先从魔搭社区的模型仓库下载qwen2.5-7b-instruct的 GGUF 量化文件,选q4_k_m版本,大小约 4.4GB。下载后放到一个目录,比如D:\models\qwen2.5-7b。
然后创建一个Modelfile,内容如下:
FROM ./qwen2.5-7b-instruct-q4_k_m.gguf TEMPLATE """{{- if .System }} <|im_start|>system {{ .System }}<|im_end|> {{- end }} <|im_start|>user {{ .Prompt }}<|im_end|> <|im_start|>assistant """ PARAMETER temperature 0.3 PARAMETER top_p 0.9 PARAMETER stop "<|im_end|>"这个文件告诉 Ollama 如何加载 GGUF 模型,同时定义了 prompt 模板。然后执行:
ollama create qwen2.5-7b-chat -f Modelfile创建完成后,ollama run qwen2.5-7b-chat就能直接用了。如果下载的是其他模型,比如 DeepSeek 或 ChatGLM,Modelfile 里的 TEMPLATE 要对应改成该模型的对话模板,否则回答格式会乱。
Embedding 模型我建议用bge-m3,中文语义效果好于开源界大部分同类模型。它也有 GGUF 版本,同样可以用 Modelfile 导入。当然,Ollama 官方仓库里也有nomic-embed-text,如果你网络允许,直接ollama pull nomic-embed-text最省事。
2.3 模型选择:不同显存怎么挑
本地跑大模型,显存是第一约束条件。我根据自己的实测拉了一张推荐表,方便你按机器配置直接选:
| 显存规模 | 推荐模型 | 量化级别 | 用途 |
|---|---|---|---|
| 4GB | Qwen2.5-3B | q4_k_m | 简单抽取、问答 |
| 6GB | Qwen2.5-7B | q4_k_m | 实体抽取、通用问答 |
| 8GB | Qwen2.5-7B / DeepSeek-R1-Distill-Qwen-7B | q4_k_m / q5_k_m | 抽取 + 中文问答效果均衡 |
| 12GB | Qwen2.5-14B | q4_k_m | 高质量抽取、复杂关系推理 |
| 24GB+ | Qwen2.5-32B | q4_k_m | 生产级知识图谱问答 |
注意两件事:显存是“至少”值,因为除了模型权重,还要算 KV Cache。上下文长度设得越长,KV Cache 占显存越多。我早期用 8G 显存跑 7B 模型,上下文拉到 8192,加载模型还算顺利,但一进行长时间对话就显存爆,后来把num_ctx降到 4096 才稳定。
另一个经验是:抽取实体关系时,模型输出的是结构化结果,不是自然语言,其实 7B 就够用。真正考验模型推理能力的还是最终问答环节,如果机器允许,单独给问答分配一个大模型,效果提升非常明显。
2.4 部署 Neo4j,准备知识图谱存储
Neo4j 是目前最成熟的开源图数据库之一,GraphRAG 产出的是图和关系数据,用 Neo4j 存起来,后续查询、可视化、调优都方便。部署直接用 Docker Compose,不用在本地手工装 Java 环境。
新建docker-compose.yml:
services: neo4j: image: neo4j:5-community container_name: neo4j ports: - "7474:7474" - "7687:7687" environment: - NEO4J_AUTH=neo4j/your_password - NEO4J_PLUGINS=["apoc"] volumes: - ./neo4j/data:/data - ./neo4j/logs:/logs - ./neo4j/plugins:/plugins restart: unless-stopped启动:
docker compose up -d等容器状态变为running后,浏览器打开http://localhost:7474,用neo4j和刚才设置的密码登录。这里我建议装一下 APOC 插件,后面做路径搜索、图算法分析时能省很多 Cypher 代码。
如果机器没装 Docker,或者内存只有 4G 跑不动 Neo4j,可以先把数据落在 GraphRAG 生成的 parquet 文件里,图存储延后。但生产环境我还是建议上 Neo4j,否则后面查询关系路径还得自己写图遍历算法,维护成本很高。
3. 核心实操:从文档到知识图谱再到问答
3.1 数据清洗与分块
知识图谱的数据质量决定了抽取质量,这一步不能省。我先把原始 Word、PDF、Markdown 统一转成纯文本,再用脚本清洗掉页眉页脚、目录、超链接残留、乱码字符。最终每篇文档保存为单独的.md文件,统一 UTF-8 编码。
接下来做分块。GraphRAG 的索引管线本身会管理文本单元,但那是把整篇文档自动切成小块,再交给 LLM 抽取。如果我们手工分块,要给 GraphRAG 提供比较干净的输入。实践下来的推荐参数是:块大小 800 字左右,前后重叠 100 字。
分块目的有两个。一是让模型在单次抽取里看到足够完整的语义,太短会丢失上下文,太长又会超出模型上下文窗口;二是为了后续做社区检测时,文本单元之间有足够的关联度。我踩过一个大坑:把块切到 200 字,结果实体抽取特别碎,“北京总公司”和“北京分公司”被识别成两个完全不同的实体,后期还得费劲做实体对齐。
清洗后的文档放在项目根目录的input文件夹下,子目录可以让 GraphRAG 做更细粒度的文档分组。
查看数据目录结构:
graphrag-project/ ├── input/ │ ├── 研发部项目总结.md │ ├── 市场部产品手册.md │ └── 2025年度技术规划.md ├── output/ ├── prompts/ └── settings.yaml3.2 实体与关系抽取:用 GraphRAG 索引管线跑一遍
GraphRAG 索引管线会把文档切块、调用 LLM 抽取实体关系、生成社区摘要,最后输出结构化数据。它支持通过环境变量和配置文件控制 LLM 类型。
项目根目录下初始化:
graphrag init --root .这会生成settings.yaml。然后改成 Ollama 后端:
llm: api_key: ollama api_base: http://localhost:11434/v1 model: qwen2.5-7b-chat temperature: 0 max_tokens: 2000 embedding: api_key: ollama api_base: http://localhost:11434/v1 model: bge-m3 chunks: size: 800 overlap: 100 input: file_type: .md cache: type: file base_dir: cache注意temperature: 0,抽取任务不需要任何创造性,温度设为 0 可以最大限度减少模型自由发挥,保证输出稳定。
然后执行索引:
graphrag index --root . --verbose首次跑会比较久,7B 模型处理几千字文档也需要几分钟,因为每个文本块都要调一次抽取接口。中途如果提示rate limit或连接错误,是因为 Ollama 并发处理能力有限,可以在环境变量里限制一下:
export GRAPHRAG_LLM_CONCURRENCY=4 export GRAPHRAG_EMBEDDING_CONCURRENCY=8跑完后在output目录里可以看到entities.parquet、relationships.parquet、communities.parquet等文件。这些文件就是图谱数据。
如果不想用 GraphRAG 自带的提示词,可以修改prompts/entity_extraction.txt。我一般会把抽取目标限定为“人物、组织、产品、技术栈、项目、地区、时间”七类,并在提示词里强调:
只抽取文本中明确出现的实体,不要联想;关系必须能通过原文佐证;输出格式严格为 JSON 数组;每个实体至少给出一个所属类型。
这样会让 7B 模型抽取质量稳定很多。
3.3 构建索引并入库 Neo4j
GraphRAG 输出的 parquet 文件是“图数据中间产物”,但真正想跑 Cypher 查询、做可视化,还得把它们导入 Neo4j。我写了一个导入脚本,思路很简单:读entities.parquet建节点,读relationships.parquet建边。
import pandas as pd from neo4j import GraphDatabase driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "your_password")) entities = pd.read_parquet("output/20250601_100000/entities.parquet") edges = pd.read_parquet("output/20250601_100000/relationships.parquet") with driver.session() as session: for row in entities.to_dict("records"): session.run( """ MERGE (e:Entity {id: $id}) SET e.name = $name, e.type = $type, e.description = $description """, id=row["id"], name=row["name"], type=row["type"], description=row.get("description") ) for row in edges.to_dict("records"): session.run( """ MATCH (a:Entity {id: $source}) MATCH (b:Entity {id: $target}) MERGE (a)-[r:RELATES_TO]->(b) SET r.type = $relation, r.description = $description """, source=row["source"], target=row["target"], relation=row["relation"], description=row.get("description") ) driver.close()导入完成后,在 Neo4j Browser 里执行:
MATCH (n:Entity) RETURN n LIMIT 100如果能看到实体节点和关系线,图谱就建起来了。这里容易翻车的是导入字段名不对,GraphRAG 不同版本的 parquet 列名略有差异,建议先print(entities.columns)确认完再跑。
导入脚本刚跑完时,图谱里可能有大量重复节点,比如“总公司”和“北京总公司”。要么在抽取提示词里强调“同名实体合并”,要么在导入时写一个简单的实体对齐规则。我的做法是导入后执行一次 Cypher 合并:
MATCH (a:Entity), (b:Entity) WHERE a.name = b.name AND id(a) < id(b) MERGE (a)-[r:ALIAS]->(b)不过这种对齐规则要看具体数据形态,别盲跑大数据集,会卡住。
3.4 实现本地问答接口
图谱数据和文本向量都准备好之后,最后一步就是把它们联合起来接 LLM。推荐先用 GraphRAG 自带的查询命令验证:
graphrag query --root . --method global --query "XX项目使用了哪些关键技术?"它会做全局搜索,利用社区摘要回答。但全局搜索对小型私有知识库来说太重了,我更常用“本地搜索”加自定义图遍历的方式。下面给出一个直接用 Python 调 Ollama 的问答实现。
import os from openai import OpenAI from neo4j import GraphDatabase client = OpenAI( base_url="http://localhost:11434/v1", api_key="ollama" ) driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "your_password")) def extract_entity_with_llm(question: str) -> str: prompt = f"从问题中抽取核心实体,只返回实体名:{question}" resp = client.chat.completions.create( model="qwen2.5-7b-chat", messages=[{"role": "user", "content": prompt}], temperature=0 ) return resp.choices[0].message.content.strip() def query_graph(entity: str, hops: int = 2): cypher = """ MATCH (a:Entity {name: $entity})-[r*1..%d]-(b:Entity) RETURN a, b, r LIMIT 100 """ % hops with driver.session() as session: results = session.run(cypher, entity=entity) return [record.data() for record in results] def ask(question: str) -> str: entity = extract_entity_with_llm(question) graph_res = query_graph(entity) context = f"问题:{question}\n实体:{entity}\n图谱检索结果:{graph_res}\n" messages = [ {"role": "system", "content": "你是一个企业知识库问答助手,请根据给定上下文中出现的事实回答问题,不要编造。若上下文不足,直接回答无法从知识库找到答案。"}, {"role": "user", "content": context} ] resp = client.chat.completions.create( model="qwen2.5-14b-chat", messages=messages, temperature=0.2 ) return resp.choices[0].message.content这里做了三步:先调 LLM 抽实体,再根据实体查 Neo4j 图路径,最后把路径结果拼入 prompt 让大模型回答。实际效果比单纯向量召回更接近“关系型”答案。
想做成线上服务,用 FastAPI 包一层:
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class Query(BaseModel): question: str @app.post("/qa") def qa(query: Query): return {"answer": ask(query.question)}然后运行:
uvicorn qa_server:app --host 0.0.0.0 --port 8000局域网内其他服务就可以通过POST http://你的IP:8000/qa调用这个提问接口。
4. 常见问题与调优笔记
4.1 Ollama 下载慢、安装包获取难怎么破
这是本地部署里最普遍的问题之一。安装包体积、模型权重动辄几个 GB,一旦网络不稳定就前功尽弃。我的处理顺序是:官网下载安装包如果太慢,先找一个靠谱的网盘或镜像站下载同样的安装包,但下载完必须校验哈希值,防止文件损坏或被人改动。
模型下载同理。ollama pull如果卡在waiting for server或者下载到 90% 报错,最好别反复重试,改成从国内模型平台下载 GGUF 文件,然后通过Modelfile导入。这个方法基本一次成功。
还有一个需要留意的点是:用ollama create导入模型时,如果提示file does not exist,说明 Modelfile 里的FROM路径写错了。改成相对路径并确认文件确实在那个目录。Windows 下路径要用./开头,不要带盘符。
4.2 显存不足与 OOM 问题
跑抽取任务时,Ollama 进程会突然消失,或者终端输出Killed,十有八九是显存不够触发了系统 OOM。解决思路:
- 换更小模型或量化级别,比如从
q4_k_m换到q3_k_m。 - 限制上下文长度,在 Modelfile 里设置
PARAMETER num_ctx 4096。 - 关闭并行加载,设置环境变量
OLLAMA_MAX_LOADED_MODELS=1。 - 让抽取和问答共用同一个模型时,两个任务别同时跑,否则模型会被反复调度。
查看显存占用用nvidia-smi,重点看Memory Usage和GPU-Util。我见过不少同事以为显存爆了,其实是num_ctx设置过大导致 KV Cache 占了大部分显存,调低后立刻能跑。
4.3 知识图谱效果不好怎么办
实体抽取质量差是最容易让新手心态崩溃的问题。症状通常是:实体识别不全、关系类型太泛、节点重复率高。
优先检查你的提示词。默认 GraphRAG 提示词对英文友好,处理中文时我会专门调整抽取提示词,明确加入中文实体类型和示例。此外,模型输出 JSON 时偶尔会少括号,建议在max_tokens上给足余量。
如果数据量很大,可以把文本拆小一点,分多次抽取,再做实体合并。比如每块 400 字,重叠 50 字,能显著提升实体召回率。但代价是抽取时间变长,需要做时间换质量。
另一个容易忽略的问题是:知识图谱抽取结果的质量上限,取决于你的文档“是否真的包含关系描述”。如果原文写作风格特别碎片化,没有主语、没有逻辑连接词,那模型再强也抽不出合理关系。我一般会先把这类文档做一轮“改写”预处理,把它变成逻辑清晰的段落,再喂给抽取管线。
4.4 检索效果和回答质量的调优
图谱答案错误或回答偏题,不一定是大模型能力问题,更多是检索上下文不够准。我建议按以下顺序调:
- 先确认实体识别是否准确。问题里的实体名和图谱里的节点名如果不一致,后续一概查不到。
- 再确认图谱遍历的跳数。跳数太少会漏,跳数太多会把无关路径都拉进来,噪音剧增。我常用 1 到 2 跳,最多 3 跳。
- 然后确认向量检索阈值。如果混合了向量召回,误召回太多反而干扰大模型,可以设定相似度阈值,低于阈值的片段直接过滤。
- 最后调 prompt。不要把图谱原始 JSON 直接扔给模型,先清洗成“A 和 B 之间存在某关系”这种自然语言描述,模型理解起来更省力。
实际调试时,我还会给问答接口加日志,把每次请求的实体、图谱路径、向量片段、最终 prompt 都记录下来。这样用户说效果差,直接看日志就知道是卡在“没抽到实体”还是“图路径为空”还是“prompt 太乱”,不用瞎猜。
这套系统我跑了小半个月,最大的体会是:GraphRAG 真正难的点不在模型选型,而在“怎么把图谱建模得恰到好处”。实体粒度太粗,问题答不细;粒度太细,图谱爆炸;检索跳数太广,上下文全是噪音。这个度没有标准答案,只能拿着自己的文档一遍遍调。但只要你把这条链路跑通,企业里大部分“跨文档关系型”问题,它都能给出比传统 RAG 扎实得多的答案。如果让我重新搭一遍,我会先把抽取提示词和文档预处理做扎实,再着急上图谱和向量,后面维护成本会低很多。