知识图谱驱动的心理咨询问答系统:从规则到关系推理
简介这是一份基于知识图谱的心理咨询智能问答系统完整项目适合计算机、人工智能、电子信息等相关专业的在校生用于毕业设计、课程设计或初期项目演示也适合想学习知识图谱与问答系统结合开发的开发者进阶参考。资源共2000个文件压缩包29.42MB主体为1804个Python源码文件另有大量文本说明、配置文件、HTML页面、C扩展头文件、JSON/XML数据以及Cypher查询脚本等覆盖后端问答逻辑、图谱数据构建、前端展示与运行环境配置等多个模块。已有77人学习下载。通过该项目可掌握从心理咨询领域知识抽取、图谱存储到基于图谱的语义匹配与答案生成的完整实现思路同时附带文档说明与作者远程教学支持便于快速跑通并二次扩展。代码注释与运行说明齐备下载后可直接对照文档启动服务。1. 知识图谱驱动的心理咨询问答从「关键词匹配」走向「关系推理」传统 FAQ 客服遇到「最近总是半夜醒来白天昏昏沉沉」这种描述大概率答非所问因为「半夜醒来」和「失眠」在字面上完全不同。心理咨询领域的问题恰恰高度口语化用户很少直接说出术语而知识图谱的实体-关系结构能把「早醒、易醒、入睡困难」归到「睡眠障碍」之下再沿「应对策略」这条边找到「睡眠卫生」建议。下面要展开的是一条可复现的完整链路设计心理领域本体用规则和轻量模型从文本抽实体关系导入 Neo4j 存图再实现意图识别、实体链接和查询生成落地成一个能跑起来的小型智能问答系统。标题里的「源码文档说明」对应的是可独立运行的 Python 项目结构和一份能讲清数据来源、实体字典、启动参数的项目说明文档。这条链路适合正在做垂直领域知识图谱构建的同学也适合想给现有客服系统加关系推理能力的后端工程师。2. 先搭骨架心理领域图谱的本体设计与初始数据准备2.1 实体与关系的顶层设计先想清楚「用户会怎么问」知识图谱项目最容易翻车的地方是一上来就到处抓数据。心理领域本身边界模糊「情绪」「症状」「障碍」经常混着说如果不对实体类型做约束后面每条查询都可能踩到类型不明的边上。我一般先把用户真实问题收集 200 条左右哪怕是从公开问答、客服日志里手工整理再反向设计本体。这里给出一个最小可用的本体设计。实体类型不用多六类足够起步实体类型说明例子Symptom症状/表现失眠、早醒、心慌、手抖Emotion情绪状态焦虑、抑郁、烦躁Disorder心理障碍广泛性焦虑障碍、抑郁症Strategy应对策略/干预方式认知行为疗法、正念呼吸Scale心理量表PHQ-9、SAS、GAD-7Population人群属性大学生、产后女性、老年人关系类型围绕问答路径来定不是学术本体里那种严格分类。核心关系就 5 种HAS_SYMPTOM 表示 Disorder 到 Symptom 的包含关系比如抑郁症常伴早醒SYMPTOM_OF 是它的反向冗余查询时省去方向判断RELIEVED_BY 连接症状或情绪到应对策略是求助建议类问题的主干RECOMMEND_SCALE 连接症状到筛查量表RISK_FOR 表示障碍之间的风险传导比如长期失眠会增加抑郁风险。从用户问法倒推关系设计是知识图谱构建里比较省力的一条路。用户问「焦虑怎么办」实际在查 Emotion 到 Strategy 的缓解路径问「失眠是不是抑郁症」需要的是 Symptom 到 Disorder 的归属路径再加障碍描述两条路径的组合。把可能问法写成一棵树每种问法对应一条图路径本体就基本稳定了。2.2 用结构化数据准备第一批三元组本体设计完之后先用 Python 脚本准备首批三元组输出成 TSV 给后续导入用。这一步的目的是把「已确认的知识」先沉淀下来形成可校验的基线数据为后续规则抽取和人工审核提供参照。# build_kg.py # 定义首批三元组输出为 Neo4j 可导入的 TSV triples [ # (head, relation, tail, head_type, tail_type) (失眠, RELIEVED_BY, 认知行为疗法, Symptom, Strategy), (失眠, RELIEVED_BY, 睡眠限制疗法, Symptom, Strategy), (早醒, SYMPTOM_OF, 抑郁症, Symptom, Disorder), (焦虑, RELIEVED_BY, 正念呼吸, Emotion, Strategy), (广泛性焦虑障碍, HAS_SYMPTOM, 坐立不安, Disorder, Symptom), ] # 实体表和关系表分开输出导入图数据库只需要两个文件 entities {} for h, r, t, ht, tt in triples: entities.setdefault(h, ht) entities.setdefault(t, tt) with open(entities.tsv, w, encodingutf-8) as f: f.write(name\ttype\n) for name, typ in entities.items(): f.write(f{name}\t{typ}\n) with open(relations.tsv, w, encodingutf-8) as f: f.write(head\trelation\ttail\n) for h, r, t, _, _ in triples: f.write(f{h}\t{r}\t{t}\n)注意这里的关系方向保留了两条冗余边HAS_SYMPTOM 和 SYMPTOM_OF 同时存在。因为在问答环节用户可能从「这是什么病」问也可能从「这个症状说明什么」问方向冗余可以少写一半的查询分支。生产环境可以把这段脚本改成读取一个kg.csv挂到 CI 里做完整性检查比如检查实体表里每个关系两端是否都有对应节点。2.3 实体归一与别名扩展心理领域术语口语化程度极高。用户说「睡不好」「睡不着」「入睡困难」不完全是一个意思但在图谱里都要能落到「失眠」或至少落到「睡眠障碍」这类父级节点上。所以除了本体表还必须维护一张别名表。# aliases.py # 别名表口语表达 - 标准实体名 ALIASES { 失眠: [睡不着, 睡不好, 入睡难, 夜不能寐], 早醒: [总是醒得早, 凌晨两三点醒, 天没亮就醒], 焦虑: [莫名心慌, 坐立不安, 静不下来, 容易紧张], 抑郁: [开心不起来, 对什么都没兴趣, 活着没意思], } # 反向索引供后续实体链接模块使用 ALIAS_INDEX {} for entity, names in ALIASES.items(): for name in names: ALIAS_INDEX.setdefault(name, entity) ALIAS_INDEX.setdefault(entity, entity) # 标准名本身也要能命中这段代码是后面实体链接的召回基础。进阶做法是把别名也建成节点用 ALIAS_OF 关系挂到标准实体下那一条 Cypher 就能做近义词匹配。第一版先放在内存字典里等别名条数超过 5000 再考虑入图或落到 Redis。3. 从非结构化文本抽取知识实体识别与关系抽取的工程取舍3.1 领域字典 AC 自动机最小成本的 NER 方案前面那份结构化数据只能覆盖已经规范的条目真正让图谱有增量能力的是从咨询记录、科普文章、问答社区文本里抽取新的三元组。做知识图谱构建的人都知道最常见的入门做法是先用领域字典做实体识别而不是立刻上 BERT。心理领域术语比较固定加上口语别名后一个几千词的字典能覆盖大部分实体表达。我一般用pyahocorasick构建 AC 自动机做多模式匹配一次扫描能命中所有字典词速度上完全够用。# ner.py import ahocorasick def build_automaton(vocab): # vocab: {失眠: Symptom, 焦虑: Emotion, ...} a ahocorasick.Automaton() for word, etype in vocab.items(): a.add_word(word, (word, etype)) a.make_automaton() return a def extract_entities(text, automaton): hits [] # automaton.iter 返回 (end_index, (word, entity_type)) for end_idx, (word, etype) in automaton.iter(text): start_idx end_idx - len(word) 1 hits.append({start: start_idx, end: end_idx, word: word, type: etype}) # 按位置合并重叠结果优先保留最长匹配 merged [] for h in sorted(hits, keylambda x: (x[start], -(x[end] - x[start]))): if merged and h[start] merged[-1][end]: continue merged.append(h) return mergedAC 自动机的优势是构建好后扫描是线性的对长文本尤其划算。需要注意字典词之间的重叠比如「焦虑」和「广泛性焦虑障碍」同时存在时应该保留更长匹配否则同一位置会抽出两个互相矛盾的实体。上面代码用「按开始位置排序、重叠时跳过后续短词」的方式做了简单处理。3.2 关系抽取先上规则模板别急着训练模型实体抽出来之后关系抽取是更麻烦的一环。预训练关系模型在通用领域尚可在心理领域没有像样的监督数据时效果未必比规则好。第一版我通常用 spaCy 的中文依存句法加规则模板# relation_extract.py import spacy nlp spacy.load(zh_core_web_sm) def extract_symptom_of(sent): # 只处理系表结构X 是/为 Y 的表现、症状、征兆 doc nlp(sent) for token in doc: if token.text in {是, 为}: subj [w for w in token.lefts if w.dep_ in {nsubj, top}] obj [w for w in token.rights if w.dep_ in {attr}] if subj and obj: yield (subj[0].text, SYMPTOM_OF, obj[0].text)这条规则覆盖面很窄但它立住了「模板 依存关系」这个框架。实际工程中模板会写成 JSON 配置文件判断条件包括触发动词、依存关系、两侧实体类型约束比如「缓解」只能连接 Symptom/Emotion 到 Strategy。把这个函数改成读取配置文件里的一组模板就能在不改代码的情况下持续加规则。3.3 什么时候值得上 BERT 微调以及怎么退回去当标注数据超过 1000 条、且模板 F1 连续一两周提不上去时才值得做序列标注微调。常见做法是标注 BIO 格式用 transformers 加载中文预训练模型跑 token 分类。数据量从 1000 涨到 5000F1 提升如果不到 2 个点说明领域特征已接近上限这时候止损继续用「字典 模板 人工审核」的组合更划算。无论用哪种方式抽出来的三元组都要过一轮人工审核。我通常按天导出待审核文件格式是三列head|relation|tail一条条过错误的直接删行并在旁边标注原因。这个审核文件本身就是后续模型微调的天然训练集不要丢弃。4. 把三元组交给图数据库Neo4j 导入与查询设计4.1 标签与关系类型映射让图结构贴近问答逻辑实体表和关系表准备好以后导入 Neo4j 之前要先把 schema 定下来。节点标签直接用实体类型关系类型用大写连字符。尤其要注意的是name属性必须在同类型节点内唯一否则实体链接时会出现一对多歧义。// schema.cypher CREATE CONSTRAINT symptom_name IF NOT EXISTS FOR (n:Symptom) REQUIRE n.name IS UNIQUE; CREATE CONSTRAINT emotion_name IF NOT EXISTS FOR (n:Emotion) REQUIRE n.name IS UNIQUE; CREATE CONSTRAINT disorder_name IF NOT EXISTS FOR (n:Disorder) REQUIRE n.name IS UNIQUE; CREATE CONSTRAINT strategy_name IF NOT EXISTS FOR (n:Strategy) REQUIRE n.name IS UNIQUE; CREATE INDEX scale_name_idx IF NOT EXISTS FOR (n:Scale) ON (n.name);这里用REQUIRE ... IS UNIQUE是 Neo4j 4.4 之后的语法如果还在用 3.x要改成CREATE CONSTRAINT ON (n:Symptom) ASSERT n.name IS UNIQUE。把实体类型写进约束名出问题时能快速定位是哪张节点表排查效率高不少。4.2 用 LOAD CSV 做可重复的批量导入批量导入的第一选择是 LOAD CSV它比neo4j-admin import更灵活支持增量追加也方便从 Python 代码里触发。我这里用「先清空再导入」的幂等写法方便反复重跑// import.cypher :auto USING PERIODIC COMMIT 500 LOAD CSV FROM file:///entities.tsv AS row FIELDTERMINATOR \t WITH row WHERE row[0] name CALL apoc.create.node([row[1]], {name: row[0]}) YIELD node RETURN count(*); // 关系导入按 head 和 tail 的名称匹配两端节点 :auto USING PERIODIC COMMIT 500 LOAD CSV FROM file:///relations.tsv AS row FIELDTERMINATOR \t WITH row WHERE row[0] head MATCH (h {name: row[0]}) MATCH (t {name: row[2]}) CALL apoc.create.relationship(h, row[1], {}, t) YIELD rel RETURN count(*);这段脚本依赖 APOC 插件没装的话可以改回MATCH ... CREATE的形式但会慢不少。特别注意WHERE row[0] name是为了跳过 TSV 表头。导入完成后做一次连通性抽查比如以「失眠」为中心展开两跳看返回的相邻节点是否符合预期如果结果为空优先检查文件名和 import 目录权限Neo4j 只允许读取dbms.directories.import配置目录下的文件。4.3 把「用户问法」翻译成图查询图谱设计得再好落不到查询上都是零。设计 Cypher 时我始终带着用户原话来写而不是拿标准实体测试。用户说「我焦虑得睡不着怎么办」分词出来至少两个实体焦虑、睡不着。那么查询就要覆盖两条路径// 方案 A先取焦虑的缓解策略 MATCH (n:Emotion {name: $emotion})-[:RELIEVED_BY]-(s:Strategy) RETURN s.name AS strategy LIMIT 5;实际问答系统里一条用户问句会映射出多条候选路径最后按「路径覆盖实体数」排序。这个排序逻辑放在 Python 层因为 Cypher 里做会比较绕。参数$emotion由实体链接模块传入永远不要用字符串拼接的方式把用户输入直接塞进查询。5. 问答系统核心链路意图识别、实体链接与答案生成5.1 意图识别先分对「用户在干嘛」问答系统最外层的分类是意图识别。心理咨询场景下意图不需要太多五类够用求助建议典型问法是「怎么办」「怎么缓解」症状了解典型问法「是什么」「为什么」量表推荐问「要不要做测试」「怎么评估」紧急干预检测到自伤或极端情绪倾向直接转人工不生成答案寒暄不在系统能力范围内用固定话术回应。实现上不需要上大模型。几百条带标注的问法就能训练一个轻量 fastText 分类器或者直接用关键词正则先顶着# intent.py import re INTENT_PATTERNS { 求助建议: [怎么办, 怎么缓解, 如何改善, 有什么办法, 帮帮我], 症状了解: [是什么, 为什么, 怎么回事, 算不算], 量表推荐: [量表, 自测, 测试, 评估一下, 评分], 紧急干预: [想自杀, 不想活了, 活着没意思, 伤害自己], 寒暄: [你好, 在吗, 嗨], } def detect_intent(text): for intent, patterns in INTENT_PATTERNS.items(): for p in patterns: if re.search(p, text): return intent return 求助建议 # 领域默认意图默认意图设成求助建议是因为心理咨询场景里「怎么办」是频次最高的一类问题。如果拿不准直接问一句澄清也不是不行比给出一个错误的确定答案体验更好。5.2 实体链接从口语表达落到图谱节点实体链接分两步先召回、再消歧。召回用前面维护的别名表把文本里命中的口语别名映射到标准实体消歧则利用图结构信息。这里给一个按节点度数排序的消歧方案# link.py def link_entities(text, alias_index, driver): hits list({entity for alias, entity in alias_index.items() if alias in text}) if len(hits) 1: return hits ranked [] for name in hits: # 度数越高说明该实体在图谱中和其它实体关联越紧密 result driver.execute_query( MATCH (n {name: $name})-[r]-() RETURN count(r) AS degree, namename, ) ranked.append((result[0][degree], name)) ranked.sort(reverseTrue) return [name for _, name in ranked]注意driver.execute_query是较新驱动的写法如果用的是 4.x 老版本改成session.run(...).single()[degree]即可。暴力遍历别名表效率不高但第一版数据量小时胜在简单。消歧依据也不只有度数还可以加「实体类型与意图的相容性」求助建议意图下优先选 Strategy症状了解意图下优先选 Symptom这个交叉约束能明显改善链接准确率。5.3 查询编排与模板化答案生成意图和实体都确定后进入查询编排。每个意图对应一组 Cypher 模板模板参数由实体链接结果填充。如果命中多个实体就按顺序依次查询取第一条有结果的路径。# qa.py def answer_question(text, driver, alias_index, automaton): intent detect_intent(text) if intent 紧急干预: return 你目前的状态听起来很难受。请先联系身边信任的人并尽快拨打当地心理援助热线系统不做应急响应。 if intent 寒暄: return 我还不太会闲聊你可以聊聊失眠、焦虑、情绪低落这些话题。 entities link_entities(text, alias_index, driver) candidates [] for entity in entities: if intent 求助建议: result driver.execute_query( MATCH (n {name: $name})-[:RELIEVED_BY]-(s) RETURN s.name AS name LIMIT 5, nameentity, ) if result: candidates.append(f针对「{entity}」可以尝试) candidates [f- {r[name]} for r in result] elif intent 症状了解: result driver.execute_query( MATCH (n {name: $name})-[r]-(d) WHERE type(r) IN [SYMPTOM_OF, HAS_SYMPTOM] AND d:Disorder RETURN DISTINCT d.name AS disorder, nameentity, ) if result: candidates.append(f「{entity}」常见于) candidates [f- {r[disorder]} for r in result] elif intent 量表推荐: result driver.execute_query( MATCH (n {name: $name})-[:RECOMMEND_SCALE]-(s) RETURN s.name AS scale LIMIT 3, nameentity, ) if result: candidates.append(建议关注以下量表) candidates [f- {r[scale]} for r in result] if not candidates: return 这块超出了我目前能回答的范围换个说法试试或者把情况描述得更具体一些。 return \n.join(candidates)这个函数刻意把查询和答案生成耦合在一起为的是让人一眼看懂链路。实际工程里查询模板要拆成独立配置模块方便在不改代码的情况下调整语句答案模板单独维护文案更新不触发发版。所有 Cypher 一律用$name参数不能直接拼接字符串这能省掉大量转义麻烦。5.4 兜底策略澄清、风险转交与体验兜底没有命中的时候最忌讳的是重复播报同一句「抱歉」。更合理的做法是引导用户补信息提取到部分实体但没有足够关系时反问「你是想了解怎么缓解还是想评估一下严重程度」没有提取到任何实体时给出几个常用入口比如「可以聊聊失眠、焦虑、情绪低落、人际关系压力」风险语句命中后除了返回固定文案还要把对话原文写入高风险日志表标记为需要人工回访。这些兜底逻辑决定了系统第一天上线的体验。问答系统的口碑往往不是来自答对的那 80%而是来自没答对时有没有让人不舒服。6. 用「路径回溯」评估问答效果并把源码整理成可交付状态6.1 评测集设计准确率之外更要看路径命中率知识图谱问答的评估不能只看最终答案对不对还要看中间环节有没有走错。我按「意图、实体、路径、答案」四个维度逐条标注测试集每一条问法都写清楚期望的图路径。问法期望意图期望实体期望路径备注我睡不好怎么办求助建议失眠Symptom → RELIEVED_BY → Strategy别名要能映射焦虑是一种病吗症状了解焦虑Emotion → SYMPTOM_OF → Disorder需要复合推理我需不需要做测试量表推荐(null)直接返回量表引导话术无实体也要能答每次跑完记录三个指标意图正确率、实体链接正确率、答案路径命中率。路径命中率比端到端准确率更有诊断价值——答案错了但路径对了多是文案问题路径错了才是图谱或链接的问题。6.2 在问答日志里加一条 trace排错效率翻倍排查线上问题时光看用户原话和返回答案不够还得知道中间发生了什么。我给每个问答请求生成 trace_id把意图、链接到的实体、执行的 Cypher、命中的路径数、答案模板编号按 JSON Lines 格式追加进日志log_entry { trace_id: trace_id, question: question, intent: intent, linked_entities: entities, cypher: executed_queries, path_hits: path_hits, answer_tpl: template_ids, } # 写入 answers_trace.jsonl行级别 JSON方便 grep 和接入日志分析系统实际代码里就是每次回答前初始化一个 dict在链路各个节点往里塞字段最后统一落盘。排查时拿用户原话搜日志马上能看到是意图分错了、实体没链接上还是图谱里根本没有这条路径不用再靠猜。6.3 源码目录结构与文档说明至少要有这些内容标题里带着「源码文档说明」交付时目录结构要让人拿到就能跑。最小可用结构是psy_qa/ ├── data/ # 实体表、关系表、别名表、评测集 ├── scripts/ # 导入脚本、审核脚本、构建脚本 ├── kg/ # 本体定义、NER 与关系抽取管道 ├── server/ # 问答接口、意图识别、答案生成 ├── cypher/ # schema、import、查询模板 ├── tests/ # 评测集和回归脚本 └── README.mdREADME 里最容易被忽略的三块数据来源说明哪些是人工整理、哪些是规则抽取、审核到什么程度环境依赖精确到 Python 版本和 Neo4j 版本一条从空库开始跑到第一次问答成功的完整命令序列。把这三块写清楚比把源码注释写得像小说有用得多。新接手的人照着命令跑通一次再改起来才有底气。提示把人工审核记录也提交进仓库它是图谱迭代最重要的依据。注意图谱规模超过 5 万三元组后实体链接别再遍历内存字典先把别名索引落到 Redis 或 Elasticsearch 里否则单条问答延迟会明显超过 200ms。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →