一、项目介绍

1、概述

  • 本项目基于 Neo4j 图数据库构建知识图谱与向量索引,实现了一个前沿的检索增强生成 RAG 系统,用于提升大语言模型在私有知识库问答场景中的准确性、逻辑推理能力与可控性

  • 项目核心流程包括:文档加载与清洗、实体与关系抽取(IE)、文本分块(Chunking)与向量化嵌入(Embedding)、知识图谱构建与图结构存储。在用户提问时,系统不仅利用向量进行语义相似度检索,更结合 Neo4j 的图关联查询能力,精准捕捉核心实体及其外延的关联知识,融合形成多维度的上下文。最后,将这些富含拓扑关系的知识片段与问题一同输入大语言模型,从而生成基于事实和强逻辑关系的高质量回答

  • 在模型层面,项目选用通义千问(Qwen)大模型,通过引入 Graph RAG 架构,深度融合结构化知识与非结构化文本,有效缓解了大模型的“幻觉”问题,显著提升了跨文档复杂关联问答的准确性与可追溯性

  • 项目最终实现了一个可用于复杂知识库问答、企业全域文档检索与智能客服的、具备深度推理能力的轻量级 Graph RAG 系统

2、技术栈

  • Python 基础语法
  • PyTorch 深度学习库
  • Transformer 架构
  • LangChain 框架
  • Neo4j 图数据库
  • FastAPI 后端服务框架
  • Vue-cli 前端脚手架
  • MySQL 关系型数据库

二、项目开发

1、知识库搭建

  • 知识库的搭建非常重要,知识库的质量直接影响 RAG 系统检索效果

  • 知识库的搭建属于离线行为,即在我们构建 RAG 系统的客户端和服务器之前,就应该把知识库构建好

  • 医疗数据集为案例演示:

  • 数据集以及解析后存储代码:【F:\workspace\AI\neo4j_rag\feifan_rag_server\create_data\datasets\构建节点和关系-写入数据.py】

  • 测试:

import os

from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate
from langchain_core.runnables import RunnableMap, RunnablePassthrough
from langchain_neo4j import Neo4jGraph
from langchain_openai import ChatOpenAI
from langchain_neo4j import GraphCypherQAChain

# 初始化 Neo4j 连接
graph = Neo4jGraph(
    url="bolt://127.0.0.1:7687",
    username="neo4j",
    password="rootroot",
    database="neo4j"
)


# 初始化 LLM
llm = ChatOpenAI(
    model="qwen3.6-27b",
    api_key=os.getenv("DASHSCOPE_API_KEY"),
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)

# 构建 GraphCypherQAChain
chain = GraphCypherQAChain.from_llm(
    llm=llm,
    graph=graph,
    allow_dangerous_requests=True,
    verbose=True,
    top_k=10,
    validate_cypher=True
)

# 问题
questions = [
    "感冒有哪些常见症状?",
    "感冒的疾病详情是什么?",
    "咳嗽症状对应哪些疾病?"
]

answers = []

for q in questions:
    result = chain.invoke({"query": q})
    answers.append(f"问题:{q}\n回答:{result['result']}")

# 用 LLM 做综合总结
prompt = PromptTemplate(
    input_variables=["answers"],
    template="""
    请将以下问答进行综合归纳,生成一段完整、连贯的中文回答:
    {answers}
    """
)

# 构建管道
summary_pipeline = (
    RunnableMap({
        "answers": RunnablePassthrough()  # 直接传入 answers
    })
    | prompt                       # 将 answers 放入模板
    | llm                          # LLM 生成文本
    | StrOutputParser()            # 输出为字符串
)

# 执行
final_summary = summary_pipeline.invoke({
    "answers": "\n\n".join(answers)
})
print("综合回答:\n", final_summary)
  • 输出结果
综合回答:
 感冒是一种常见呼吸道疾病,其典型症状主要包括发烧、鼻塞、发热伴寒战、头痛、全身忽冷忽热、咽痛、咽喉干燥及灼热感以及流鼻涕等。从疾病详情来看,感冒主要分为普通感冒与流行性感冒两大类:普通感冒(中医称“伤风”)多由鼻病毒等多种病毒引起(约占30%–50%),而流感主要由流感病毒导致,传染性强且病毒易变异,即使曾患病仍可能再次感染。约70%–80%的感冒病例由病毒引发,细菌感染多为继发;受凉、淋雨、过度疲劳等因素会降低机体或呼吸道局部防御功能,老幼体弱或患有慢性呼吸道疾病者尤为易感。该病主要通过呼吸道飞沫或接触被污染物品传播,全年皆可发病,冬春季节高发;因病毒种类多、人体免疫力短暂且无交叉免疫,加之健康人群中存在隐性携带者,一年内可能多次发病。一般病程为7至14天,治愈率高达97%。在日常预防方面,建议坚持冷水洗脸与热水泡脚、早晚及餐后淡盐水漱口,可辅助饮用生姜红糖水或萝卜醋汤;同时应注意补充维生素C和E,保证每日8小时充足睡眠以维持免疫水平,并经常进行鼻部按摩,预防鼻咽部初始感染。如需前往三甲医院就诊,治疗费用通常在500至1000元左右,该病一般不在医保报销范围内。此外,咳嗽作为感冒常见的伴随症状,若持续不缓解、加重或性质改变,需警惕其可能提示的其他疾病,包括肺炎、支气管炎、肺结核、哮喘、禽流感等呼吸系统疾病,以及粒细胞缺乏症、气管肿瘤、膀胱移行细胞癌、睾丸肿瘤或药物性哮喘等。若症状复杂、迁延不愈或伴有其他异常表现,建议及时就医进行专业评估与针对性治疗。

2、服务器开发

2.1 搭建服务器项目

  • 创建一个 FastAPI 项目

2.2 聊天接口配置

  • 创建一个聊天接口子路由,功能代码先不写,先实现模板构建
  • 在项目根目录下创建api/controller文件夹,然后创建文件ChatController.py
from fastapi import APIRouter, Request


# 创建路由对象
chat_router = APIRouter()


# 聊天接口
@chat_router.get("/chat")
def chat(request: Request,question: str):
    pass
  • main.py 中配置跨域、注册子路由、配置启动
import uvicorn
from fastapi import FastAPI
from api.controller.ChatController import chat_router
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

# 跨域
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://127.0.0.1:8000"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# 注册子路由
app.include_router(chat_router)

# 启动服务
if __name__ == '__main__':
    uvicorn.run(
        app="main:app",
        host="127.0.0.1",
        port=8080,
        reload=True,
    )

2.3 工具封装

  • 在项目根目录下创建api/utils文件夹,然后创建文件Neo4jUtil.py,封装一个获取 Neo4j 连接的工具
import os

from langchain_neo4j import Neo4jGraph

def get_neo4j_conn():
    return Neo4jGraph(
        url=os.getenv("NEO4J_URL", "bolt://127.0.0.1:7687"),
        username=os.getenv("NEO4J_USERNAME", "neo4j"),
        password=os.getenv("NEO4J_PASSWORD", "rootroot"),
        database=os.getenv("NEO4J_DATABASE", "neo4j"),
    )


if __name__ == '__main__':
    print(get_neo4j_conn())
  • 在项目根目录下创建api/utils文件夹,然后创建文件MyModel.py,封装一个加载模型的工具
import os

from langchain_openai import ChatOpenAI


def load_model():
    return ChatOpenAI(
        model="qwen3.6-27b",
        api_key=os.getenv("DASHSCOPE_API_KEY"),
        base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
    )

2.4 聊天接口实现

  • 主要采取 MVC 分层思想,在业务逻辑层完成对应的聊天功能代码处理

  • 在项目根目录下创建api/service文件夹,然后创建文件ChatService.py

from api.utils.Neo4jUtil import get_neo4j_conn
from api.utils.MyModel import load_model

from langchain_neo4j import GraphCypherQAChain
from langchain_core.prompts import PromptTemplate

CYPHER_GENERATION_TEMPLATE = """Task:Generate Cypher statement to query a graph database.
Instructions:
Use only the provided relationship types and properties in the schema.
Do not use any other relationship types or properties that are not provided.
Schema:
{schema}

# 节点标签及含义
| 节点标签     | 含义             |
|--------------|------------------|
| Disease      | 疾病(核心节点) |
| Symptom      | 症状             |
| Check        | 检查项目         |
| Cureway      | 治疗方式         |
| Drug         | 药物             |
| Department   | 就诊科室         |
| Food         | 食物             |
| Dishes       | 菜肴             |
| Category     | 疾病分类         |

# 关系类型及含义
| 关系类型             | 含义              | 起始节点 | 目标节点    |
|----------------------|-------------------|----------|-------------|
| DISEASE_SYMPTOM      | 疾病症状          | Disease  | Symptom     |
| DISEASE_CHECK        | 相关检查项目      | Disease  | Check       |
| DISEASE_CUREWAY      | 治疗方式          | Disease  | Cureway     |
| DISEASE_DRUG         | 治疗或相关药物    | Disease  | Drug        |
| DISEASE_DEPARTMENT   | 就诊科室          | Disease  | Department  |
| DISEASE_DO_EAT       | 推荐进食的食物    | Disease  | Food        |
| DISEASE_NOT_EAT      | 不推荐进食的食物  | Disease  | Food        |
| DISEASE_DISHES       | 适合疾病的菜肴    | Disease  | Dishes      |
| DISEASE_ACOMPANY     | 并发症 / 伴随疾病 | Disease  | Disease     |
| DISEASE_CATEGORY     | 疾病所属类别      | Disease  | Category    |

# 查询示例
# 问:高血压有哪些症状?
MATCH (d:Disease {{name:"高血压"}})-[:DISEASE_SYMPTOM]->(s:Symptom) RETURN s.name AS symptom

# 问:感冒吃什么药?
MATCH (d:Disease {{name:"感冒"}})-[:DISEASE_DRUG]->(dr:Drug) RETURN dr.name AS drug

# 问:糖尿病不宜吃什么?
MATCH (d:Disease {{name:"糖尿病"}})-[:DISEASE_NOT_EAT]->(f:Food) RETURN f.name AS food

# 问:肺炎需要做什么检查?
MATCH (d:Disease {{name:"肺炎"}})-[:DISEASE_CHECK]->(c:Check) RETURN c.name AS check_item

# 问:高血压挂什么科?
MATCH (d:Disease {{name:"高血压"}})-[:DISEASE_DEPARTMENT]->(dep:Department) RETURN dep.name AS department

# 问:感冒的并发症有哪些?
MATCH (d:Disease {{name:"感冒"}})-[:DISEASE_ACOMPANY]->(a:Disease) RETURN a.name AS complication

# 问:糖尿病属于哪类疾病?
MATCH (d:Disease {{name:"糖尿病"}})-[:DISEASE_CATEGORY]->(c:Category) RETURN c.name AS category

# 问:高血压可以吃什么菜?
MATCH (d:Disease {{name:"高血压"}})-[:DISEASE_DISHES]->(dishes:Dishes) RETURN dishes.name AS dishes

# 问:哪些疾病会有头痛症状?
MATCH (d:Disease)-[:DISEASE_SYMPTOM]->(s:Symptom {{name:"头痛"}}) RETURN d.name AS disease

Note: Do not include any explanations or apologies in your responses.
Do not respond to any questions that might ask anything else than for you to construct a Cypher statement.
Do not include any text except the generated Cypher statement.

The question is:
{question}"""

QA_TEMPLATE = """你是一名专业的医疗智能问答助手,基于 Neo4j 疾病知识图谱为用户提供准确的健康咨询。

# 第一步:意图识别
判断用户问题是否属于【医疗疾病类】,包括但不限于:
- 疾病症状、病因、并发症
- 检查项目、就诊科室
- 治疗方式、用药建议
- 饮食宜忌、推荐菜肴
- 疾病分类与归属

# 第二步:按类别处理

## 情形 1:属于医疗疾病类 → 基于以下图谱查询结果作答
- 严格基于查询结果作答,不得编造疾病、药物或诊疗方案
- 若查询结果为空,回复:"知识库中未收录该疾病的相关信息,建议咨询专业医生"

## 情形 2:不属于医疗疾病类
- 忽略知识图谱查询结果
- 基于自身通用知识自然作答
- 回复中不得出现"知识库""图谱""上下文"等字样

# 输出要求
- 直接给出最终答案,不复述问题、不解释判断过程
- 涉及用药、治疗、剂量等敏感内容时,附加一句:"具体方案请遵医嘱"
- 语言简洁、准确、通俗易懂

---
【图谱查询结果】
{context}

【用户问题】
{question}

【回答】
"""


def chat(question: str):
    graph = get_neo4j_conn()
    llm = load_model()

    cypher_prompt = PromptTemplate(
        input_variables=["schema", "question"],
        template=CYPHER_GENERATION_TEMPLATE,
    )
    qa_prompt = PromptTemplate(
        input_variables=["context", "question"],
        template=QA_TEMPLATE,
    )

    rag_chain = GraphCypherQAChain.from_llm(
        llm=llm,
        graph=graph,
        cypher_prompt=cypher_prompt,
        qa_prompt=qa_prompt,
        verbose=True,
        allow_dangerous_requests=True,
    )

    return rag_chain.invoke({"query": question})
  • 修改api/controller/ChatController.py代码
import json
import time

from fastapi import APIRouter, Request
from starlette.responses import StreamingResponse

from api.service import ChatService

chat_router = APIRouter()


@chat_router.get("/chat")
def chat(request: Request, question: str):
    def streaming_content():
        try:
            result = ChatService.chat(question)
            answer = result.get("result", "") if isinstance(result, dict) else str(result)
            for ch in answer:
                payload = json.dumps({"content": ch}, ensure_ascii=False)
                yield f"data: {payload}\n\n"
                time.sleep(0.02)
            yield "data: [DONE]\n\n"
        except Exception as e:
            err = json.dumps({"error": str(e)}, ensure_ascii=False)
            yield f"data: {err}\n\n"
            yield "data: [DONE]\n\n"
    return StreamingResponse(
        content=streaming_content(),
        media_type="text/event-stream",
    )

2.5 测试

  • 这里的返回值没解析,是正确的

image-20260525123202770

3、客户端开发

3.1 搭建客户端项目

  • 创建以 vue-cli 项目
  • 下载以下库
"axios": "^1.3.3",
"dompurify": "^3.3.3",
"element-ui": "^2.15.14",
"marked": "^3.0.8",

3.2 对话页面设计

  • 套模板或者 AI 基于 element-ui 生成一个
<template>
  <div id="chat-container">

    <!-- 消息区 -->
    <div id="messages">

      <div
        v-for="(msg, index) in messages"
        :key="index"
        class="message"
        :class="msg.role"
      >

        <div class="avatar">
          {{ msg.role === 'assistant' ? '🤖' : '👤' }}
        </div>

        <div class="content">
          <div
            class="text-message"
            v-html="$renderMarkdown(msg.content)">
          </div>
        </div>

      </div>

    </div>

    <!-- 输入区 -->
    <div id="input-area">

      <div class="input-wrapper">

        <el-input
          type="textarea"
          v-model="input"
          :rows="2"
          placeholder="请输入你的问题..."
          @keydown.enter.native.prevent="sendMessage"
        >
        </el-input>

        <el-button
          type="primary"
          @click="sendMessage">
          发送
        </el-button>

      </div>

    </div>

  </div>
</template>

<script>
import Vue from "vue";

export default {
  name: "Index",

  data() {
    return {

      serverUrl: "http://127.0.0.1:8080/",

      input: "",

      messages: [
        {
          role: "assistant",
          content: "你好 👋,我是 AI 助手"
        }
      ]
    };
  },

  methods: {

    // 滚动到底部
    scrollToBottom() {

      this.$nextTick(() => {

        const box = document.getElementById("messages");

        box.scrollTop = box.scrollHeight;
      });
    },

    // 发送消息
    sendMessage() {

      if (this.input.trim() === "") return;

      const userMsg = this.input.trim();

      this.input = "";

      // 用户消息
      this.messages.push({
        role: "user",
        content: userMsg
      });

      // AI占位消息
      this.messages.push({
        role: "assistant",
        content: "AI 正在思考..."
      });

      this.scrollToBottom();

      const index = this.messages.length - 1;
      let aiContent = "";
      let finished = false;

      // SSE请求
      const params = new URLSearchParams({
        question: userMsg
      });

      const es = new EventSource(
        this.serverUrl + `chat?${params}`
      );
      let lastContent = "";
      es.onmessage = e => {
        if (e.data === "[DONE]") {
          finished = true
          es.close()
          return
        }

        let data = JSON.parse(e.data)

        if (data.content == "\n" && lastContent == "\n") {
          return
        }

        aiContent += data.content;
        Vue.set(this.messages, index, {
          "role": "assistant",
          "type": "text",
          "content": aiContent
        })

        this.$nextTick(() => {
          this.scrollToBottom()
        })
        lastContent = data.content;
      }

      es.onerror = () => {
        if (finished) return;
        es.close();
        Vue.set(this.messages, index, {
          role: "assistant",
          content: aiContent || "连接中断,请重试"
        });
      };
    }
  }
};
</script>

<style scoped>
/* ===== 页面 ===== */
body {
  margin: 0;
  background: #f5f7fa;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto;
}

#chat-container {
  width: 900px;
  max-width: 95%;
  height: 90vh;
  margin: 5vh auto;
  background: white;
  border-radius: 16px;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
}

/* ===== 消息区 ===== */

#messages {
  flex: 1;
  overflow-y: auto;
  padding: 24px;
  background: #f7f8fa;
}

/* ===== 消息 ===== */

.message {
  display: flex;
  gap: 10px;
  max-width: 75%;
  margin-bottom: 18px;
  padding: 12px 16px;
  border-radius: 14px;
  line-height: 1.6;
  font-size: 14px;
}

.message.user {
  margin-left: auto;
  background: #409EFF;
  color: white;
  flex-direction: row-reverse;
}

.message.assistant {
  background: white;
  border: 1px solid #ebeef5;
}

/* ===== 头像 ===== */

.avatar {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background: white;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}

/* ===== 内容 ===== */

.content {
  flex: 1;
}

.text-message {
  white-space: pre-wrap;
  word-break: break-word;
}

/* ===== 输入区 ===== */

#input-area {
  padding: 16px;
  border-top: 1px solid #ebeef5;
  background: white;
}

.input-wrapper {
  display: flex;
  align-items: flex-end;
}

.el-input {
  flex: 1;
  margin-right: 10px;
}

.el-button {
  height: 52px;
}

</style>

3.3 测试

image-20260525134931483