基于neo4j数据库和dify大模型框架的rag模型搭建

本文主要讲述关于如何从pdf文档中提取数据并用于生成知识图谱,搭建基于知识图谱的rag模型的过程。

(其实就是知识库?有一说一因为根本没上课其实我也不知道我做的是啥,但是大概是符合课设要求的)

前置准备

neo4j数据库

neo4j用于存储从pdf文档中提取的数据。

安装方式有docker或者直接安装两种方式。

可以参考https://neo4j.com/docs/operations-manual/current/installation/linux/debian/

由于我使用的是直接安装的,因此docker安装方式就不作过多介绍了。

直接安装的配置如下:

1
2
3
主机: Ubuntu 22.04(其实是虚拟机)
jdk: openjdk-21-jdk
neo4j: (version)2025.04.0

由于neo4j是依赖于java运行的,因此需要java环境,如果之前没安装过java的话那很幸运,只需要一个java即可;如果之前已经安装过java,则需要对java版本的优先级进行调整(具体去问ai)。有一说一,其实不推荐安装最新版,因为大多数ai对最新版都不怎么熟悉,很容易出错,不会由于当时我已经装完了,所以只能硬着头皮往下干。(沉没成本不参与重大决策?)

安装流程

1
2
3
4
5
6
7
8
9
10
11
12
# 检验java是否安装
java --version
apt list openjdk-*jdk
sudo apt install openjdk-21-jdk
java --version
# 安装neo4j并检验
wget -O - https://debian.neo4j.com/neotechnology.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/neotechnology.gpg
echo 'deb [signed-by=/etc/apt/keyrings/neotechnology.gpg] https://debian.neo4j.com stable latest' | sudo tee -a /etc/apt/sources.list.d/neo4j.list
sudo apt-get update
apt list -a neo4j
sudo apt-get install neo4j:2025.04.0
neo4j help

第一步是安装java,第二步是将neo4j所在的库添加到apt源中然后用apt安装。

(其实可以用deb文件包然后dpkg安装,但是官方文档给的会显示不是一个合法的deb文件,肥肠奇怪,无法理解)

安装完之后将neo4j启动

1
sudo neo4j start

启动成功之后会显示

image-20250525202405716

之后自动使用守护进程运行,转入后台。

在回显中可以看到plugins目录和conf目录。先进入/var/lib/neo4j/labs中,将其中的apoc开头的jar包拷贝到plugins目录中。如果有需要,还可以去apoc的github仓库中安装额外的补全插件(插件地址为:https://github.com/neo4j-contrib/neo4j-apoc-procedures/releases/download/2025.04.0/apoc-2025.04.0-extended.jar)。

然后进入/etc/neo4j目录中,其中存在neo4j.conf文件,先对其进行修改,修改之后如下。这个修改使得在neo4j的浏览器页面中可以调用apoc函数。

image-20250525203002386

再作修改,使得其他主机能够访问neo4j。如果在容器中还可能用到host.docker.internal(只适用于macos和windows),当然直接安装就没有这种问题了。

1
2
# neo4j.conf
server.default_listen_address=0.0.0.0

再在neo4j.conf的同级目录中新建一个apoc.conf,内容为

1
apoc.import.file.enabled=true

这样neo4j就算安装完成了。

neo4j的浏览器地址为http://ip:7474/browser/,bolt端口为7687(默认是这样)

使用docker安装的话可以使用-v将docker中的目录映射到主机中,尤其记得要将plugin目录和conf目录映射出来,不然就得使用docker exec进入docker容器中再安装插件,比较麻烦。

dify框架

dify框架的安装比较简单,直接使用docker就可以完成安装,而且和neo4j不存在端口冲突。

首先从github克隆下dify的仓库,按照readme文档中的内容去做即可。

1
2
3
4
5
git clone https://github.com/langgenius/dify.git
cd dify
cd docker
cp .env.example .env
sudo docker compose up -d

如果没有docker compose的可以试一下docker-compose,不然就是没安装。

1
2
# 关闭dify的命令,在docker-compose.yaml目录下
sudo docker compose stop

dify的浏览器访问地址为ip,默认端口为80

搭建思路

1
提取数据 -> 建立数据结构 -> 存入数据库 -> 搭建检索系统 -> 输入llm -> 得到最终输出

关于提取数据、建立数据结构、存入数据库这三个部分,可将其视为一个部分,也就是将文档中的数据存入数据库。

提取数据存入数据库

提取数据

数据存储在pdf文档中,在对数据进行读取时还需要对其进行词义分析,将其分类、分段,保留它原先的意思,但是又便于检索。

好在这一步不需要我们手动完成,只需要对数据进行提取,然后交给专门的语言模型处理即可😋。

由于数据是存储在pdf文档中,可以使用pdfplumer进行读取,再使用spacy对其进行分段。

1
2
补充关于token的小知识:
在大型语言模型(LLM)中,Token 是模型处理文本数据的基本单位。Token是LLM理解、处理和生成人类语言的基础单位。通过将文本分解为Token,LLM能够将复杂的语言转换为其可以进行数学运算的数字表示形式,从而实现各种强大的自然语言处理能力。

所以说将语句进行分段是必要的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 核心代码
with pdfplumber.open(pdf_path) as pdf:
chunks = []
# 使用spacy分句
nlp = spacy.load("zh_core_web_sm")
for idx,page in tqdm(enumerate(pdf.pages), total=len(pdf.pages), desc="Processing PDF"):
text = page.extract_text()
meta = {
"page_number": page.page_number,
"page_width": page.width,
"page_height": page.height
}
doc = nlp(text)
sentences = [sent.text.replace('\n', ' ').replace('\r', ' ').strip() for sent in doc.sents]
# 合并为段落(每段3句)
for i in range(0, len(sentences), 3):
chunk_text = "".join(sentences[i:i+3])
chunks.append({
"text": chunk_text,
"page_number": meta["page_number"],
"start_pos": i,
"end_pos": i+3
})

在上述代码中使用spacy中的专门处理中文的模型zh_core_web_sm对从pdf中提取出的数据进行分句,并给其标注信息,如页号等。

1
当然也可以使用其他的类似模型或者更好的模型,使用更好的模型得到的效果也就更好

生成向量索引

token的向量索引,也称为Token Embeddings。在token的向量索引中包含了token的词义信息,也可以快速的检索到token,在之后的检索系统中会用到。

1
2
如果不使用向量索引的话,也可以根据输入的关键词对数据库进行检索,这种就很好理解了。
其实使用向量索引也是类似这种用法,只不过是对关键词的词义也进行相关性检索,而不是单纯的仅凭关键词本身。

生成向量索引的过程自然也是不需要我们多虑的,也有专门的模型用于创建向量索引。
生成的向量索引维度为384。

1
2
3
4
5
model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
vectors = model.encode([chunk["text"] for chunk in chunks], batch_size=32)
for i, chunk in enumerate(chunks):
vectors[i] = [x for x in vectors[i]]
chunk["vector"] = vectors[i].tolist()
1
模型选择并不唯一,但是越好的模型生成的效果肯定是更好的

存储到数据库

接下来将其存储到数据库中,在本文中使用的是neo4j数据库,类型为nosql,主要是用于图的存储。

在存储到数据库中时需要创建不同的节点并且指出其相关关系,例如在如下代码中就创建了book节点和chunk节点两种节点,其中chunk节点属于book节点,构成了图。还添加了一些额外的meta data,便于管理和识别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
driver = GraphDatabase.driver("bolt://ip:port", auth=("neo4j", "password"))
with driver.session() as session:
# 创建Book节点
session.run("""
CREATE (b:Book {
book_id: $book_id,
title: $title,
author: $author
})
""", book_meta)

# 创建Chunk节点及关系
for chunk in chunks:
session.run("""
MATCH (b:Book {book_id: $book_id})
CREATE (c:Chunk {
chunk_id: $chunk_id,
text: $text,
page_number: $page_number,
start_pos: $start_pos,
end_pos: $end_pos,
vector: $vector
})
CREATE (c)-[:BELONGS_TO]->(b)
""", {
"book_id": book_meta["book_id"],
"chunk_id": f"{book_meta['book_id']}_chunk_{chunk['page_number']}_{chunk['start_pos']}",
**chunk
})

创建数据库索引

虽然听起来跟之前的向量索引有些相似,但是不是一个东西,之前的索引是作为节点的数据而存在,而现在要创建的数据库索引是为了提升数据库的查询效率而使用的,并且能够高效地执行语义相似度搜索,为检索系统的搭建作准备。

1
2
3
4
5
6
7
8
driver = GraphDatabase.driver("bolt://ip:port", auth=("neo4j", "password"))
with driver.session() as session:
# 创建vector的索引
session.run(
"""CREATE VECTOR INDEX chunk_vector_index
FOR (c:Chunk) ON (c.vector) OPTIONS
{ indexConfig: { `vector.dimensions`: 384,`vector.similarity_function`: 'cosine'}}"""
)

(这里有一个坑,就是如果你使用低版本的neo4j的话,ai会使用apoc中跟vector相关的代码,就可以直接使用。但是由于版本太高了,vector函数被内置到neo4j里,apoc的函数就废弃了,这个函数还是从官方文档里搜出来的,肥肠难受😫)

搭建检索系统

所有对数据的前置准备已经完成,接下来要搭建的是检索系统,用于从数据库中检索出跟用户输入最相关的词条。

首先,要将用户输入的内容也转为向量索引,才好进行比较。

然后使用neo4j中内置的vector比较函数,将相关的数据从neo4j中取出,按照相关性进行排序。

为了提高相关性的准确度,这里可以使用reranker模型进行重排序,得到真正相关性更高的结果。

然后再根据得到相关内容,回到文档中原来的位置,提取其上下文,提高答案的正确率。

因此,检索系统分为四个部分。

1
输入转换 -> 向量检索 -> 重排序 -> 提取上下文

后两个步骤是可选的,在我写的代码中设置了选项,在使用时可选也可不选。只是不选的话得到的答案比较没人样,或者直接得不到答案而已。

听起来比较简单,其实做起来也不是很难(毕竟大部分代码都不是我写的)。

不过由于后续要使用dify框架,所以这里将检索系统封装为了一个http的api,便于后续的调用,使用的是fastapi。(主要是我感觉dify里面的代码执行应该是不允许搞这么复杂的,当然我也没仔细研究过)

输入llm

这个部分也比较简单,选择一个自己喜欢的模型,然后设置好prompt和参数就好了。

prompt可参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
你是一个专业书籍问答助手,请严格基于用户提供的<上下文>回答问题。
若问题与上下文无关或超出知识范围,必须回答"未找到相关信息"。
回答必须满足以下要求:
1. 答案需完整覆盖问题核心,避免冗余
2. 必须使用中文口语化表达,禁用Markdown格式
3. 若上下文存在矛盾信息,需指出矛盾点

{{#context#}}

问题:
{{#sys.query#}}

请按以下格式回答:
【答案摘要】
简明总结核心答案(1-2句话)

【详细解释】
分点说明推导过程和依据

【相关延伸】
推荐2-3个关联知识点(可选)

实际上llm部分是在dify框架中完成的,所以要先启动dify,如何启动在前置准备章节中已经有详细描述,但是还有一个点没提到,那就是需要注册一个模型api平台的账号,这里我选择的是硅基流动,其实用deepseek或者豆包之类的平台也是一样的,只要它们有提供api的接口就可以。

硅基流动注册

注册只需要手机号验证就可以了(ps:我本来想用github登的,但是登完还是要手机号😓)

如果没有邀请码可以填我的:f1EqQpK5

双方都会获得免费的2000w tokens,我当时注册也是随便搜了一个邀请码就填上了。

注册成功之后在左侧的工具栏中有一个api密钥,新建一个,密钥描述随便写(当然填dify也可以)。

dify初始化

第一次进入dify会要求输入管理员账户,如果是自己用的话随便输一个也没事,有别人要用的话还是设置得复杂一点。

登录成功之后点击右上角的头像>设置>模型供应商,可以看到有很多家,选择硅基流动,输入之前新建的api密钥即可。

这就算初始化完成了。

dify_chatflow搭建

接下来根据之前的思路,也就是数据->检索->llm的流程搭建dify工作流,注意已经将检索系统封装为http的api并且在本地启动,并且数据也已经输入到数据库中。

image-20250525213001882

工作流的流程很简单,看看其内部构造

http请求

image-20250525213225134

封装了search接口,query是用户在对话框的输入,expand_window是上下文的窗口大小,默认开启了上下文检索但是没有开启重排序,参数类型为json。

代码执行

image-20250525213359658

使用http请求的返回值作为输入,解析json,并且提取其中的text的值,返回值为list。

llm

image-20250525213522940

prompt在上文中已给出,使用的是千问8B的模型(因为不要钱),真好,也可以自己在主机上跑一个开源的大模型,但是我指定不行,等下笔记本烧了😓。

最后一个选直接回复即可,以llm的输出作为输入。

对于小段文本的测试效果还不错,但是没有使用指标进行度量。其实是可以对性能进行评估的,但是我比较懒,课设的话没必要纠结太多(

而且如果要人工评估的话就要打标签,要用ai评估的话好像也比较麻烦,还得跑很多轮。

dify_workflow搭建

和chatflow差不多,只是开始时需要输入,结束时需要输出。

image-20250525221425847

改进方案

可以改进的方向有很多

1、在分句和创建向量时使用更好的模型

2、额外安装一个索引数据库,在neo4j中只存放索引的索引,可以有效提高查询的效率。

3、额外安装一个缓存数据库,如redis,也可以有效提高查询效率。

4、搭建检索系统时可以进行加权,对不同的权值进行调整

5、使用付费的高级llm和更加高级、专业的prompt

6、优化数据结构,例如在搭建的过程中我其实想给出每段文章的页码,但是这样的话要对数据结构进行修改,所以就没有做了

后续补充

今天想起来写报告的时候突然发现项目起不来了,一查才发现原来hugging face一直是被ban的,所以在import 模型之前就要先换源。(昨天梯子发力了我说,这都能跑起来)

1
2
3
4
5
6
7
8
9
10
import os
# 在导入 SentenceTransformer 之前设置环境变量
os.environ['HF_ENDPOINT'] = 'https://hf-mirror.com'

from fastapi import Body, FastAPI, HTTPException
from pydantic import BaseModel
from sentence_transformers import SentenceTransformer, CrossEncoder
from neo4j import GraphDatabase
from neo4j.exceptions import Neo4jError
import uvicorn

然后又发现报告里的检测评估居然是强制要求,所以只能跑个评测集了😭😭😭。

rag评估

虽然有搜索到开源的评估方法,比如RAGAS,但是发现这个是要有标准答案的。我哪知道标准答案是啥,所以就废弃这个选项吧,只看看跑出来的答案有没有个人样就好了。

所以以下内容大部分是关于如何调用dify的api在后端运行工作流以及其中的一些坑点。

ragas评估时所需的数据结构如下

1
2
3
4
5
6
7
data = {
"question": ["问题1", "问题2", ...],
"answer": ["生成的答案1", "生成的答案2", ...],
"contexts": [["检索到的文档1", "文档2"], ["检索到的文档1", "文档2"], ...],
"ground_truth": ["标准答案1", "标准答案2", ...]
}

当然我不是按照这个来的,正如我之前所说,我根本没有标准答案,不过我发现其他的都是可以得到的。

数据生成

首先使用ai生成100个问题,别管对不对了,总不能手写100个问题吧。

然后调用dify的后端进行工作流调用,关于调用的细节在api文档中已经给出,范例如下:

1
2
3
4
5
6
7
8
9
10
11
12
test_datas = []
headers = {
"Authorization": "Bearer {API KEY}",
"Content-Type": "application/json"
}
jsondata = {
"inputs": {"input": question},
"response_mode": "blocking",
"user": username
}
re = requests.post('http://192.168.35.129/v1/workflows/run',headers=headers,json=jsondata)
response_data = json.loads(re.text)

API KEY是用于识别工作流的,通过这个接口就可以在python代码中运行工作流而不是通过web端。如果不想通过python也可以发布工作流之后批量运行工作流。但是我没试过。

从提前准备好的问题文件中读取问题,然后调用dify的api执行,执行完成之后再输入文件,就可以得到所有问题的答案,而检索到的文档可以在调用之前封装的http的api的过程中也输入文件,这样一来,最后再合并成一个文件,就可以得到只缺少标准答案的文件了😊😊😊🍾🍾🍾

image-20250526214117344

具体代码如上,毫无技术可见的request,但是需要注意的是,最好每次执行循环就把返回包保存一次,不然如果中途崩了就会发现白跑了半个小时。

数据分析

由于没有标准答案,所以只是计算了一下答案的平均查询时间和平均长度,没有对rag模型作量化的评估,有待补充。虽然我肯定是懒得再做了。

源代码

评测集的问题和答案都已经更新到仓库中。

1
https://github.com/zx2023qj/rag_model