简介:一份基于大模型 RAG 知识库与知识图谱的问答平台完整毕业设计资料,面向计算机、通信、人工智能、自动化等专业学生及从业者,适用于期末课程设计、大作业或毕设参考。项目融合检索增强生成与知识图谱技术,含设计文档、源码与部署配置,答辩评审达98分,代码已调试测试,确保可直接运行。资源共130个文件,压缩包约19.27MB,以py后端逻辑、vue前端页面、png界面截图、sh部署脚本、yml与dockerfile容器配置等为主,目录结构清晰,便于按模块学习。已有192人学习下载。包内含完整项目源码、设计文档、Nginx与Docker部署配置、演示动图及说明文件,从环境搭建到代码逻辑均有覆盖,既可帮助小白理解RAG与知识图谱的落地流程,也适合基础较好的读者在此基础上二次开发,实现个性化问答功能。
1. 大模型RAG知识库与知识图谱问答平台:从文档证据到关系链,一次拆透这个毕业设计级项目
大模型RAG知识库与知识图谱问答平台这几年在毕业设计、期末大作业和企业知识库预研里都是高频选题。原因很直接:RAG知识库擅长从文档里捞证据回答问题,但遇到“谁负责XX项目”“XX系统依赖哪些模块”这种关系链问题,纯向量召回只能返回零散片段;知识图谱把实体和关系显式存下来,却缺少大模型的语言组织能力。这份资源把两条路合并成一个问答平台:文档切块后向量化召回,实体关系抽成图谱供查询,最后由大模型统一组织答案。适合要做毕设、大作业,或者想在企业里快速搭一个私有知识库问答原型的人。
2. RAG知识库底座:文档拆解、向量化与top-k召回参数怎么定
2.1 先分清两件事:RAG补文本上下文,图谱补结构化关系
提问“这个方案的风险点是什么”,答案通常是一段说明文字,直接埋在文档里,适合RAG去召回;提问“这个项目由谁负责,依赖哪些系统”,证据分散在多份文档的不同段落,用向量召回top_k=5很可能只覆盖其中一个角度,答出来残缺。知识图谱这时候比RAG直接:它把人员、项目、系统之间的“负责、依赖、属于”关系预先抽成三元组,查询时从某个实体出发,一跳两跳就能拿到完整关系路径。
所以这两条路不是竞争而是互补。RAG处理开放文本,图谱处理结构化关系。一个问答平台设计得合不合理,先看它是否在架构里明确区分了这两种证据,再看两条路径的结果有没有统一的组装环节。我见过不少项目把图谱三元组转成一段文字塞进知识库,等于放弃图的查询能力,最后只能靠相似度碰运气,关系链问题照样答不全。
2.2 文档解析与切分:为什么递归切分比硬切靠谱
文档解析最常翻车的不是TXT和Markdown,而是PDF。表格、页眉页脚、多栏排版会把抽取顺序打乱,段落顺序一乱,后面的切分和向量化全跟着错。我一般的做法是先用PyMuPDF或pdfplumber按页读文本,再按阅读顺序拼接成一条连续字符串,最后交给切分器。如果PDF是扫描件,必须先OCR,否则向量库里存的是乱码,检索效果无从谈起。自检办法很便宜:把抽取后的文本前50个字符打印出来看一眼,乱不乱一目了然。
切分这一步,常见的实现是递归字符切分器。它按分隔符优先级一层层劈,先保段落、再保句子、最后才用字符兜底,比“按固定长度硬切”对中文友好得多。
from langchain_text_splitters import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""], keep_separator=True, ) chunks = text_splitter.split_text(raw_text) print(f"共切出 {len(chunks)} 个片段") print(chunks[0][:120])逻辑说明:把整段文档文本切成长度可控的片段,后续每个片段单独向量化,查询时按相似度召回最相关的几个片段。参数说明:chunk_size是单片最大字符数,这里取500适合制度类和操作手册类文本;chunk_overlap是相邻片段的重叠长度,取80能让跨在切缝上的问题至少被一个片段完整覆盖。separators数组里把中文句末标点放在前面很关键,不这样做中文会被当成没有边界的字符串,被迫用空格和字符硬切,一句完整的话就被拦腰砍断。
chunk_size和chunk_overlap没有普适值,要按文档类型去试。论文、技术方案这类段落密集的文本,我会调到600~800;短条款、FAQ类文档用300左右。overlap一般取chunk_size的10%~20%,太少会丢上下文,太多则同一内容被重复索引,一次召回返回三个几乎一样的片段,白占上下文。
| 文档类型 | 建议chunk_size | 切分要点 |
|---|---|---|
| 制度/操作手册 | 300~500 | 条款相对独立,按条切 |
| 技术方案/论文 | 600~800 | 段落上下文长,overlap加大 |
| FAQ/短问答 | 200~300 | 一问一答,避免一条被拆成两半 |
提示:切分完成后抽相邻两个片段读一遍首尾,比任何参数调优都高效。
2.3 向量化与召回:嵌入模型选型、索引与top-k
嵌入模型直接决定了“语义相似度”这杆秤准不准。中文语料优先选在中文评测集上表现好的模型,比如bge系列;轻量用bge-small-zh,效果优先用bge-large。这里不要迷信模型越大越好,嵌入维度越高,向量索引文件越大,查询也越慢,对毕业设计或小规模知识库来说属于无谓开销。向量库方面,FAISS适合本地单机,不用额外起服务,适合这套项目;Milvus适合百万级以上向量;pgvector适合已有PostgreSQL的场景。
from sentence_transformers import SentenceTransformer import faiss import numpy as np model = SentenceTransformer("BAAI/bge-small-zh-v1.5") vectors = model.encode(chunks, normalize_embeddings=True, show_progress_bar=True) dim = vectors.shape[1] index = faiss.IndexFlatIP(dim) index.add(vectors.astype("float32")) query = "项目延期的主要原因有哪些?" q_vec = model.encode([query], normalize_embeddings=True) scores, ids = index.search(q_vec.astype("float32"), top_k=5) for rank, (sid, score) in enumerate(zip(ids[0], scores[0]), 1): print(f"Top {rank}: score={score:.4f}, chunk_id={sid}")逻辑说明:把每个chunk编码成一个向量,IndexFlatIP是内积索引,配合normalize_embeddings=True时内积等价于余弦相似度;查询时把query编码到同一向量空间,再按相似度取top_k个片段id与得分。参数说明:normalize_embeddings=True必须加,否则不同长度的query得分不可比;top_k=5是起步值,知识库大或问题分散时调到10,但要注意后面的上下文总量。
索引类型也要看数据量。几十万片段以内,暴力检索IndexFlatIP已经够用,响应在几十毫秒量级;超过这个量级再用IVF或HNSW。IVF的关键参数nlist影响召回精度,nlist太小会把相近向量分到不同桶里造成漏召回,nlist太大会让查询变慢。代码里看到ivf_flat或hnsw参数时,要意识到这是速度和精度的权衡,不是默认值就适用一切数据量。
还有一种常见疑问:RAG知识库能存储图片吗?如果要做图片问答,那是多模态RAG,文本切分、图片编码、跨模态检索和纯文本链路完全是两套工程。这套资源的主线是文本知识库,图片要么先用OCR转成文本再入库,要么单独走多模态向量索引,别硬塞进同一个文本向量库。否则图片向量和文本向量很难对齐,召回质量很飘。
3. 知识图谱构建:实体建模、三元组抽取与Cypher查询路径
3.1 建模边界:哪些实体和关系值得进图
图谱建模的第一原则是:进图的必须能被问答问题当成“起点”。人员、项目、部门、系统、文档是常见的节点类型;属于、负责、参与、依赖、引用是常见的关系类型。属性可以放工号、状态、发布时间这类短字段,长文本描述别往属性里塞。
判断标准很简单:某个信息是否会被“沿着关系找下去”的问题用到。比如“小王参与了哪些项目”,就需要把(小王)-[参与]->(项目)存成关系;而“项目背景是什么”是一段长文本,留在文档chunk里由RAG召回即可,放进图里既不合适也没必要。有人会问:为什么不直接用关系数据库存三元组?三层以内关系用SQL多表join还能撑,四层、五层关系路径的查询SQL会写得非常长,而“邻居的邻居”这种遍历在图数据库里天然高效。这就是图谱区别于表结构的地方。RAG负责文本,图谱负责路径遍历,两边各干各的活。
3.2 三元组抽取:大模型批量抽,规则做兜底
从文档构建图谱,常见做法是先用大模型做批次抽取,再清洗合并。规则和正则抽取快,但长句里的关系经常被拆断,召回率低;大模型抽取效果更有保证,代价是耗时和调用成本。我一般先标注少量样本确定实体类型和关系类型,再让大模型按统一格式输出。
extract_prompt = """从下面文本中抽取三元组,格式为 JSON 数组,每个元素是 [实体1, 关系, 实体2]。 实体类型限定:人员、项目、部门、系统、文档。 关系限定:属于、负责、参与、依赖、引用。 只输出 JSON,不要解释。 文本: {text}""" # 常见做法:把文本按段落分批发给大模型 # 对返回结果做 json.loads 后统一清洗逻辑说明:实体类型和关系类型先写死在prompt里,避免大模型抽出一堆无法归类的边。参数说明:批次文本不宜超过800字,超过之后长尾关系容易丢;temperature设成0或接近0,抽取任务要确定性,不需要创造性输出。清洗这一步至少要处理三类问题:实体名归一、关系方向统一、重复关系去重。
实体名归一化的典型例子:同一人在不同文档里可能写作“张伟”“张伟(项目经理)”“项目经理张伟”。不归一,问题里提到“张伟”时Cypher查不到“项目经理张伟”这个节点,表现就是图谱问答冷冰冰返回空结果。我习惯在建库时给每个实体维护一个aliases别名列表,写入图数据库时用规范名,查询时先做别名映射。
清洗第二个高发问题是关系方向。同一句话“研发部由张三负责”,可能被抽成(研发部, 负责, 张三),也可能抽成(张三, 负责, 研发部)。我的办法是在抽取prompt里直接定义关系语义:负责表示负责人指向被负责对象。这样批量结果里方向混乱的比例会大幅下降,但无法完全归零,入库前还是要做方向校验规则。
3.3 查询路径:从Cypher到问答上下文
图谱建好之后,问答环节要做两件事:把问题中的实体映射到图节点,再把它翻译成Cypher查询。实体映射可以用词表匹配,也可以让小模型先抽实体再查别名;Cypher推荐用参数化查询,不要用字符串拼接。
MATCH (p:Person)-[:BELONGS_TO]->(d:Department) WHERE d.name = $dept RETURN p.name AS person_name, p.title AS title逻辑说明:从部门节点出发,沿着BELONGS_TO关系找到所有属于该部门的人员节点,返回姓名和职务。参数说明:$dept是参数占位符,运行时传入部门名,避免用户输入直接拼进查询语句;关系方向在Cypher里用箭头体现,方向写反就查不到结果。多个关系跳转时用链式MATCH或OPTIONAL MATCH,比如“项目依赖哪些系统,系统又属于哪个部门”需要两跳:
MATCH (pr:Project)-[:DEPENDS_ON]->(sys:System) OPTIONAL MATCH (sys)-[:BELONGS_TO]->(dept:Department) WHERE pr.name = $project_name RETURN sys.name AS system_name, dept.name AS department_nameOPTIONAL MATCH的作用是系统可能没有归属部门时仍然返回系统行,不会被过滤掉。这一步返回的结果需要被转成自然语言片段喂给大模型,比如“系统A归属研发部;系统B归属运维部”。转换逻辑放在第4章的统一组装步骤里。
最近常听到的GraphRAG、ontology RAG,本质上就是把这类图谱结构引入RAG链路,区别只是图谱在召回阶段充当路由约束,还是在生成阶段充当额外证据。这套项目的参考写法是后一种:先Cypher查出证据,再作为结构化上下文和RAG片段一起进prompt。理解这一点很关键,答辩时如果被问“你和GraphRAG有什么关系”,你可以准确说出你的图谱是查询证据源,而不是图遍历生成引擎。
4. 问答平台串联:意图路由、上下文组装与FastAPI服务
4.1 意图路由:先判断这个问题该走哪条检索链路
同一个用户可能问“XX系统怎么部署”也可能问“XX系统依赖哪些服务”。前者是文本问答,后者是关系问答。如果所有问题都走同一条链路,要么RAG答关系问题答不全,要么图谱答文本问题答不出细节。所以第一步是路由。
路由可以很简单:规则匹配。含“谁负责、属于哪个、依赖哪些、有哪些系统”这类词的问题优先走图谱;其他走RAG。也可以做得更准:用LLM对query做一次分类,输出rag、graph或both三类。毕业设计里用规则就够展示,生产环境用规则兜底加LLM分类的双保险。
def route_query(query: str) -> str: if any(w in query for w in ["谁负责", "属于哪个", "依赖哪些", "有哪些系统"]): return "graph" if "的关系" in query or "关联" in query: return "graph" return "rag"逻辑说明:先用封闭词表判断问题是否涉及关系链,命中就走图谱路由。参数说明:这个名单要根据自己的业务问题扩展,不要照抄。规则路由的好处是可解释、零延迟;坏处是换个问法就失效,比如“这个项目的接口方是谁”没命中“谁负责”,会被错误分到RAG。生产级做法是规则兜底加LLM分类,由分类结果覆盖规则漏掉的问法。
4.2 上下文组装:把证据组织成大模型能回答的格式
两条链路返回的证据格式不一样。RAG返回的是文档片段,带原文和相似度;图谱返回的是关系行,带节点和关系类型。不能直接把原始数据堆给大模型,否则它不知道哪些可信、哪些是噪声。我习惯先拼一个evidence块,让两类证据有清晰的编号和边界。
def build_evidence(rag_chunks, graph_rows): evidence_lines = [] for i, chunk in enumerate(rag_chunks, 1): evidence_lines.append(f"[文档{i}] {chunk}") for j, row in enumerate(graph_rows, 1): evidence_lines.append(f"[关系{j}] {row['source']} - {row['relation']} -> {row['target']}") return "\n".join(evidence_lines)逻辑说明:把两类证据统一编号后拼接,大模型在prompt里能看到证据边界,回答时能引用“文档1”或“关系2”。参数说明:rag_chunks和graph_rows在传入前都做了截断,RAG片段单条控制在200字左右,图谱行控制在5条以内,防止上下文过长。还有一个容易被忽略的细节:prompt里要明确“无法回答时直接说不知道”。加了这句话之后,模型在证据不足时编答案的概率会明显下降。
4.3 FastAPI串联:从query到answer的一个完整接口
前面所有步骤最终要收敛成一个HTTP接口。常见做法是用FastAPI写一个/lang端点,内部依次做路由、召回、组装、生成。本地大模型可以用ollama或vLLM拉起一个OpenAI风格接口,FastAPI侧用openai SDK去调用,这样模型换仓只需要改base_url。
from fastapi import FastAPI from openai import OpenAI app = FastAPI() client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama") def ask_llm(system_prompt: str, user_prompt: str) -> str: resp = client.chat.completions.create( model="qwen2.5:7b", temperature=0.2, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], ) return resp.choices[0].message.content @app.post("/lang") def answer_platform(query: str): route = route_query(query) if route == "graph": graph_rows = query_graph(query) evidence = build_evidence([], graph_rows) else: rag_chunks = retrieve(query) evidence = build_evidence(rag_chunks, []) prompt = f"依据以下证据回答问题,不知道就说不知道。\n{evidence}" return {"answer": ask_llm("你是企业内部知识库助手", prompt)}逻辑说明:接口内部按路由分发,RAG和图谱的结果统一转成evidence,再交给大模型生成。参数说明:temperature设为0.2,保证事实性回答时不过度发散;base_url指向本地模型服务,切换云端模型时只需改url和model名。如果想让流式输出,把stream=True打开,用SSE往前端推。
这一套东西在macOS上同样能跑起来,FAISS和FastAPI都是跨平台的。如果你不想写前端,Dify这类低代码平台也能把检索、图谱查询、模型调用编排成应用,底层思路和上面这段代码一致。接口里没有写会话历史,但实际问答通常需要多轮。多轮的常见做法是在prompt中携带最近两轮问答的摘要,而不是把全部历史塞进去。因为检索类系统每一轮问题相对独立,历史越长prompt越容易被无关话题带偏。我在项目里一般只保留上一轮query和answer,做成“历史会话”字段拼在system prompt下方,既兼容追问,又不把上下文撑爆。
5. 避坑与排查:RAG+图谱问答效果翻车的五个高频原因
5.1 切分把语义切断,召回片段前后接不上
现象:问题明明在文档里有完整答案,检索召回的片段却只有半句话,大模型只能靠上下文猜,或者干脆答错。
原因:chunk_size设得太小,separators里没有“。!?”这些中文句末标点,一句话被从中间硬切;或者答案分布在一个片段头部和另一个片段尾部,而chunk_overlap设得不够,导致召回时哪半边都不完整。这个问题在PDF解析顺序错乱时尤其明显,段落本身就是乱的,切分再准也救不回来。
解决:把chunk_overlap调到chunk_size的15%以上;检查separators确认包含“。!?;”且顺序靠前;对长文档切两刀抽查相邻片段,看首尾是否衔接自然。这一步是整套RAG效果的地基,地基没打好,后面所有调优都是玄学。我见过有人连续调了三天prompt,最后把chunk_size从1000降到400,问题立刻解决。
5.2 实体名不归一化,图谱查询返回空结果
现象:RAG能答上来的关系类问题,走图谱路径却返回空列表,最终答案退化成纯RAG结果,或者直接说“查不到”。
原因:文档里叫“张伟(项目经理)”,问题里叫“张伟”,两个写法在图上不是同一个节点。Cypher默认按完全匹配查节点名,别名对不上就是空。更隐蔽的是同一实体被重复写入成多个节点,查询按名字匹配时命中了其中一个,但关系只挂在另一个节点上,看起来就是“有空节点但没邻居”。
解决:入库时给每个实体维护一个aliases别名列表,查询时先做实体链接,把问法映射到规范名再拼Cypher。同时给图谱节点建唯一性约束和索引,比如在Neo4j里用CREATE CONSTRAINT ON (p:Person) ASSERT p.name IS UNIQUE,防止重复实体悄悄堆积。这步做完,关系类问题的空结果率会明显降下来。
5.3 top-k拉高后上下文过载,生成反而变差
现象:top_k从5调到10以后,答案没有更完整,反而变得啰嗦,甚至引用无关片段,出现幻觉式输出。
原因:排在前面的片段确实相关,后面的几个相似度已经很低,属于噪声。喂给大模型的上下文越长,注意力越分散,离核心证据越远的内容越可能被模型当成依据。这本质上是RAG的上下文瓶颈:召回数量不是越多越好,证据精炼比数量重要。
解决:top_k提高的同时,把每段提取长度压缩到150~200字。或者在组装证据前加一个重排环节,用cross-encoder或LLM对候选片段二次打分,只保留强相关的5条。第4章的build_evidence函数里加一个重排过滤参数,返回前先跑一遍打分,这条路径在检索质量不稳定时比调prompt见效快。
5.4 本地部署大模型显存不足,服务频繁OOM
现象:本地起7B或13B模型后,启动成功但一问就崩,日志里出现CUDA out of memory,有时连embedding模型都一起被挤爆。
原因:上下文越长显存占用越高;FastAPI进程同时加载着embedding模型、向量索引和生成模型,显存叠加超出显卡容量。很多项目在Mac或小显存机器上部署时忽略了embedding模型也占显存,以为只算大模型一份。
解决:生成模型用量化版本,比如Q4_K_M的7B模型日常显存占用在5~6G量级,比FP16小不少;embedding模型优先用小于1G的bge-small,并允许它跑CPU;对话上下文长度限制在4096以内,配合第4章的“只保留最近两轮历史”策略。如果还是超,就把生成模型换成更小参数量,别硬扛着调参。
5.5 只调生成prompt,不查检索证据质量
现象:prompt改了好几版,答案还是不对,最后把最终答案和检索证据一起打印出来才发现,召回片段里根本没有答案。
原因:把效果问题全归因到大模型身上,忽略了RAG系统的上游。检索没召回,生成再强也巧妇难为无米之炊。图谱侧同理,Cypher查出来的节点不对,后面喂什么文本都白搭。
解决:把最终答案和它对应的证据一起打印出来,逐条看证据是否覆盖答案。覆盖了,问题在生成环节;没覆盖,问题在切分或嵌入模型。这是排查RAG问题最直接的方法,别凭感觉盲调。我自己的习惯是每次改动前先把“问题→证据→答案”三元组存成一个日志文件,跑完一轮翻日志,比盯着终端输出猜原因高效得多。
6. 进阶验收技巧:用证据链路检查代替盲调prompt
6.1 一张评估集、三次检查、一个回归脚本
做到第5.5条时,有一个切实可用的落地方法:把“最终答案+证据”同时吐出来,逐条人工打分。准备20~30个覆盖文档问答和图谱问答的测试问题,跑一次完整链路,记录三个指标:证据里是否包含答案(召回覆盖)、大模型是否依据证据回答(生成忠实)、图谱路径是否查到了正确关系(图谱准确)。这三个指标分开看,才不会把问题归错方向。
def evaluate_one(query: str, expected: str): route = route_query(query) rag_chunks = retrieve(query) if route != "graph" else [] graph_rows = query_graph(query) if route == "graph" else [] evidence = build_evidence(rag_chunks, graph_rows) answer = ask_llm("你是企业内部知识库助手", f"依据证据回答。\n{evidence}") return { "query": query, "route": route, "have_evidence": expected_keywords_in(evidence, expected), "answer": answer, "evidence": evidence, }逻辑说明:返回结果里同时保留答案和证据,每条样本都能复盘。参数说明:expected_keywords_in是一个简单的关键词包含判断,从expected里抽两三个关键词去evidence里搜索,用来快速检查证据是否覆盖答案点;想严格一点,就把answer和expected交给另一个LLM打分,更贴近人对“答没答对”的感知。运行完评估,先把“证据里没答案”的样本全部修掉,再谈prompt优化。这个顺序很关键:先保证上游能找到答案,再让下游把答案组织准确。图谱侧的验证同理,单独抽10个关系类问题,直接看Cypher返回的节点对不对,不经过大模型,这样图谱建模的问题能隔离出来。
下载这份资源后,建议先按第4章的接口骨架跑通一个最小demo,再逐步把第2章、第3章的资料换成自己的业务文档,效果检验就靠这一章的评估脚本。我在这类项目上的血泪教训是:RAG+图谱问答平台的绝大多数翻车,都发生在“证据链路”而不是“大模型”上。从那以后我每次调优都强制走一遍“问题→证据→答案”的三段式检查,先把证据查清楚,再决定要不要动prompt。希望帮到你。
本文还有配套的精品资源,点击获取