简介:这是一套基于知识图谱的心理咨询智能问答系统完整项目,面向计算机、人工智能、自动化等专业的学生或开发者,可用于毕业设计、课程设计及项目初期演示。资源以源码和文档为主,共2000个文件,涵盖1804个Python脚本实现核心问答逻辑、数据处理与图谱构建,73个txt说明文件辅助理解,同时包含html/css/js前端页面、pyc编译文件、json/xml配置以及cypher图数据库查询语句,压缩包约29.42MB,目录结构清晰。目前已有77人学习下载。除完整代码外,还配有文档说明,可远程教学,并建议先阅读README。对于希望快速上手知识图谱问答系统、了解心理咨询场景落地的学习者,这套资源能提供从后端逻辑到前端展示的完整参考,便于在此基础上修改扩展。
1. 基于知识图谱的心理咨询智能问答系统:为什么通用大模型在这个场景里“答不对”
先抛一个反直觉的结论:把心理咨询对话直接交给通用大模型,问题不是“答得不够好”,而是“不敢上线”。咨询场景里,一句“我最近睡不好”可能对应失眠、应激反应或抑郁倾向,概率生成的答案一旦缺少事实锚点,安全风险不可控。基于知识图谱的智能问答系统,把咨询领域的知识沉淀为“实体—关系”网络,回答时先走图谱路径拿到证据链,再由生成层组织语言,输出有据可查。这套思路对具备后端或 NLP 基础的人很友好:冷启动不需要海量标注对话,边界规则可控,新增知识也无需重新训练。下面按图谱构建、问答链路、源码实现、评测排错的顺序展开。
2. 心理咨询知识图谱构建:本体设计、三元组抽取与 Neo4j 落地
2.1 先定本体再写代码:实体和关系的边界决定问答上限
任何知识图谱项目,做砸的第一理由都是跳过本体直接灌数据。心理咨询领域的实体类型要收敛,实践里比较稳的划分是症状、情绪、事件、应对方式、评估结论、干预建议六类。关系则围绕咨询逻辑展开:什么事件触发了什么症状,什么方式能缓解,什么行为会加重,症状指向什么样的评估倾向,评估结论对应什么干预建议。关系建得太细,查询模板容易失控;建得太粗,答案就没区分度。六类实体加五类关系是经验里比较稳的起点。
| 实体类型 | 含义 | 典型实例 |
|---|---|---|
| Symptom | 用户主诉的生理或心理症状 | 失眠、心悸、兴趣减退 |
| Emotion | 情绪状态 | 焦虑、低落、愤怒 |
| Event | 触发症状的生活事件 | 失恋、职业压力 |
| Coping | 用户的应对方式 | 运动、回避、饮酒 |
| Assessment | 系统对情况的评估倾向 | 压力过载、轻度焦虑倾向 |
| Intervention | 可执行的干预建议 | 正念练习、就医转介 |
2.1.1 用一份 YAML 做单一事实源,团队评审不再各执一词
本体的定义文件相当于数据结构文档,团队成员对照着讨论和评审,不会出现你理解的“缓解”和我理解的“缓解”不一致。YAML 要能被代码直接加载,作为构建实体词典和关系校验的输入。
entities: Symptom: { description: "用户主诉的生理或心理症状" } Emotion: { description: "情绪状态" } Event: { description: "触发症状的生活事件" } Coping: { description: "用户采取的应对方式" } Assessment: { description: "系统给出的评估倾向" } Intervention: { description: "可执行的干预建议" } relations: TRIGGERED_BY: { domain: Symptom, range: Event } RELIEVED_BY: { domain: Symptom, range: Coping } WORSENS: { domain: Symptom, range: Coping } INDICATES: { domain: Symptom, range: Assessment } RECOMMENDS: { domain: Assessment, range: Intervention }这份 YAML 的一个实用技巧是要求 domain 和 range 严格落在 entities 定义内,加载时用 jsonschema 校验,语法错误会在启动阶段暴露,而不是等到查数据时才发现。咨询场景的情感支持关系,比如“共情”和“安慰”,可以先不进图谱,那些交给规则模板和生成层去处理,图谱只负责事实性推理。边界划清之后,文档、代码、知识库三者的对应关系变得非常直接,文档说明也不再是事后补的,而是启动时就能生成的。
2.2 三元组抽取:词典规则兜底,模型做长尾召回
心理咨询语料的难点在口语化表达。“睡不着”和“入睡困难”指的是同一件事,通用 NER 很难把这个别名关系处理好。实践中的做法是双通道抽取:词典规则通道用 Aho-Corasick 自动机先做精确匹配,保证高精度;模型通道负责泛化和长尾召回。词典通道的目标是零误报。
import ahocorasick from typing import Dict, List, Tuple def build_ac(alias_dict: Dict[str, List[str]]): """ 构建多模式匹配器 alias_dict: {"Symptom": ["失眠", "睡不着", "入睡困难"]} """ ac = ahocorasick.Automaton() for etype, terms in alias_dict.items(): for term in terms: ac.add_word(term, (etype, term)) ac.make_automaton() return ac def extract_with_rule(text: str, ac) -> List[Tuple[int, int, str, str]]: results = [] for end_idx, (etype, term) in ac.iter(text): start = end_idx - len(term) + 1 results.append((start, end_idx, etype, term)) results.sort(key=lambda x: x[0]) deduped = [] last_end = -1 for r in results: if r[0] > last_end: deduped.append(r) last_end = r[1] return deduped自动机匹配返回的 end 索引是闭区间右边界,转成(start, end)再按起始位置排序,可以把“失眠/睡不着/入睡困难”这类别名统一映射到同一个规范节点。排序后的去重逻辑是贪心的:只要当前实体起点大于上一个实体的终点就保留,重叠部分优先取靠前的实体,这符合咨询问答里“先提症状后补事件”的典型句式。规则通道适合 seed 词典覆盖度高的表达,覆盖不到的就要模型通道补位。模型通道可以选一个轻量 BERT-NER 做序列标注,训练数据有两三千条标注样本就能达到可用水平;关键不在模型结构,而在后处理:模型输出的每个实体必须回查词典和既有图谱节点,置信度低于 0.65 的一律不落库,宁可漏了也不能灌脏数据。
2.3 Neo4j 批量入库:幂等写入与唯一约束缺一不可
图谱存储选 Neo4j,理由不是流行,而是咨询问答需要多跳查询,Cypher 的表达比 RDF 的 SPARQL 在工程上更直观。把抽好的三元组写进图库,要用 UNWIND 批量导入而不是逐条 CREATE,后者在十万级三元组上会慢一个数量级。
UNWIND $batch AS row MERGE (s:Symptom {name: row.symptom}) MERGE (e:Event {name: row.event}) MERGE (s)-[r:TRIGGERED_BY]->(e) ON CREATE SET r.source = row.source, r.created_at = timestamp()对应的 Python 调用方:
from neo4j import GraphDatabase driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "your-pass")) def load_triples(batch: list): query = open("upsert_triples.cql", encoding="utf-8").read() with driver.session() as session: session.run(query, batch=batch)UNWIND $batch会把 Python 列表逐条展开进 Cypher 语句,一次网络往返完成整批写入。MERGE保证读写幂等,重复运行不会生成重复节点和关系。ON CREATE只在新建关系时写入 source 与时间戳,已有关系不动。source 字段是排查脏数据的锚点,任何一条三元组都能回溯到原始语料来源;问答结果有问题时,顺着 source 就能定位到是语料本身错还是入库逻辑错。实测里最容易漏的一步是实体唯一约束。
CREATE CONSTRAINT symptom_name_unique IF NOT EXISTS FOR (s:Symptom) REQUIRE s.name IS UNIQUE;没有这个约束,MERGE在高并发下可能因为读后写竞态产生同名节点。六个实体类型各建一个唯一约束,代价很小,之后的查询也能利用索引直接定位实体。
提示:入库前先对实体 name 做一遍归一化处理,大小写、全半角、空格都要统一,否则唯一约束形同虚设。
3. 智能问答系统实现:意图识别、实体链接与图谱查询的完整链路
3.1 意图识别:先分类再查询,而不是让大模型包办一切
问答系统的第一步是搞清楚用户要什么。咨询场景的意图集合要收敛可枚举,不需要开放域那么大。
| 意图 | 用户问法示例 | 处理方式 |
|---|---|---|
| symptom_query | 我最近总失眠是什么原因 | 图谱路径查询 |
| cope_query | 压力大的时候怎么办 | 知识图谱+规则 |
| crisis | 活着太累了不想继续 | 安全引导语 |
| assessment | 我这种情况是不是抑郁了 | 图谱+风险提示 |
| smalltalk | 你好 | 模板回复 |
意图分类器选 sentence-transformer 就能满足要求,轻量且时延可控。用每个意图的代表性问法构造原型向量,实际判断取余弦相似度最高者。
from sentence_transformers import SentenceTransformer import numpy as np encoder = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2") def classify_intent(text: str, label_embeds: dict) -> str: text_vec = encoder.encode(text, normalize_embeddings=True) best, best_score = None, -1.0 for label, label_emb in label_embeds.items(): score = float(np.dot(text_vec, label_emb)) if score > best_score: best, best_score = label, score return best # 每个意图先用 10 条代表性问法构造原型向量 crisis_seed = encoder.encode( ["我活着没意思", "我想结束一切", "死了算了"], normalize_embeddings=True, ).mean(axis=0)用 10 到 20 条代表性问法对每个意图的向量求平均作为原型,好处是新增意图只需要追加种子问法,不用重新训练模型。当最高相似度低于 0.55 时,默认走 smalltalk 或要求澄清,而不是猜一个答案。多语言模型的编码对中文口语兼容性不错,但长句性能要注意:超过 64 token 的句子先截断再编码,避免无效计算。
3.1.1 crisis 意图必须前置拦截
危险信号的判断优先级高于其他任何意图。这类输入一旦触发,不需要查询图谱,直接输出转介急救资源并终止对话流。这是咨询系统与普通 FAQ 问答最本质的区别。crisis 意图既要用分类器覆盖,也要在更前一层用关键词规则兜底,“我活不下去”“死了算了”这类高频表达直接命中规则。规则命中时无论分类器结果是什么都采用规则结果,因为拦截的代价远低于漏判的代价。
3.2 实体链接:把口语表述归一化到图谱节点
实体链接是知识图谱问答里最容易出错的一环。用户说“心里堵得慌”,图谱里也许只有“情绪低落”。常规做法是维护一个别名表,把用户的非规范表述映射到规范实体名。别名的来源有两个:同义词词典和用户日志聚类。
ALIAS_MAP = { "睡不着": "失眠", "入睡困难": "失眠", "早醒": "失眠", "心里堵": "情绪低落", "高兴不起来": "情绪低落", "压力大": "职场压力", "被裁了": "裁员", } def link_entity(raw: str, matched_entities: list) -> list: linked = [] for entity in matched_entities: canonical = ALIAS_MAP.get(entity, entity) linked.append(canonical) return linked这份映射表本身也可以放进图谱里,用ALIAS_OF关系挂在规范节点下,这样当查询逻辑需要在 Cypher 侧完成时,可以直接用关系匹配解析别名。实体链接的结果最后要回到图谱中验证:如果规范实体在该策略下不存在于图谱,就降级为模糊搜索或放弃匹配,而不是伪造一个实体。别名的更新频率比本体高得多,把它放在外部 JSON 或图谱中而不是硬编码在源码里,能显著降低发布成本。
3.3 图谱查询:从自然语言到可复用的 Cypher 模板
实体链接完成后,下一步是生成图谱查询。这里不直接让模型从自然语言重写 Cypher,因为不可控。稳妥的做法是模板化:意图分类器决定用哪个查询模板,实体词作为参数填充。
QUERY_TEMPLATES = { "cope_query": """ MATCH (s:Symptom {name: $symptom})-[:RELIEVED_BY]->(c:Coping) RETURN c.name AS advice; """, "symptom_query": """ MATCH (e:Event)<-[:TRIGGERED_BY]-(s:Symptom {name: $symptom}) RETURN e.name AS reason; """, } def search(symptom, intent): with driver.session() as session: result = session.run(QUERY_TEMPLATES[intent], symptom=symptom) return [r["advice"] for r in result.data()]模板里的$symptom用参数绑定而不是字符串拼接,这既是防注入的底线,也能让 Cypher 计划器复用执行计划。多跳查询,比如“失恋导致的失眠怎么缓解”,是一条Event <- TRIGGERED_BY - Symptom - RELIEVED_BY -> Coping的三节点路径,可以在一个模板里完成。模板要按意图分开维护,cope_query 和 symptom_query 的返回字段不能混用,否则答案组装阶段会频繁踩空。
3.4 答案生成:图谱证据与生成模型的职责边界
图谱返回的是结构化证据。生成层的职责是把这些证据组织成通顺语句,而不是重新发挥引入新事实。通常的链路是:把图谱返回的若干路径拼成简短上下文,再交给生成模型做语言润色。
def assemble_answer(evidence: list) -> str: if not evidence: return "这个问题我暂时没有把握,建议先描述一下什么时候开始的,以及它对你的生活影响有多大。" if len(evidence) == 1: return evidence[0] return "、".join(evidence[:-1]) + "和" + evidence[-1]这里刻意先用模板组装,是因为咨询场景对表达顺序敏感,图谱证据的拼接顺序就是推荐优先级。只有在用户追问“为什么”时才引入生成模型,而且模型的输入不只要包含证据路径,还要追加一条系统约束:只能基于给定证据回答,不能补充新事实,证据不足就明确说明。生成模型的输出会被一个规则过滤器检查:如果输出出现了证据列表里不存在的实体名或关系词,直接丢弃该输出,退回模板答案。把生成模型的职责限制在措辞层面,才算真正发挥了知识图谱对幻觉的约束作用。
4. 源码组织与接口设计:让问答系统成为可测试的后端服务
4.1 源码目录划分:按层分包,按功能隔离
源码组织直接影响后续迭代的节奏。比较合适的划分方式是“按层分包、按模块隔离”,让路由、服务、图谱访问三层各司其职。
counselor_qa/ ├── app/ │ ├── api/v1/endpoints.py # FastAPI 路由层,只做参数校验 │ ├── service/qa_service.py # 问答编排,依次调度各组件 │ ├── kg/ │ │ ├── entities.py # 本体定义加载 │ │ ├── queries.py # Cypher 模板 │ │ └── graph.py # Neo4j 驱动封装 │ └── nlp/ │ ├── intent.py # 意图分类 │ ├── link.py # 实体链接 │ └── rewrite.py # 语言润色 ├── tests/ │ ├── fixtures/ # 测试语料和三元组样例 │ ├── test_qa.py │ └── test_kg.py ├── data/ │ ├── ontology.yaml │ ├── aliases.json │ └── seed_triples.csv ├── docs/ │ ├── api.md # 接口文档 │ └── ontology.md # 本体变更记录 ├── scripts/init_db.py └── requirements.txt路由层不做任何判断,只负责参数检查和响应包装;service 层是编排逻辑,一个ask()方法按顺序调用意图分类、实体链接、图谱查询和答案组装;知识图谱访问统一封装在kg包中。这样划分的益处是替换任何一个组件都不影响其他模块,比如把意图分类从 sentence-transformer 换成更小的模型,只改nlp/intent.py的内部实现,接口签名不变。文档说明也不要单独维护,从 ontology.yaml 和接口签名里自动生成,避免代码和文档分叉。
4.2 一个可直接测试的问答接口长什么样
接口设计要考虑两个错误码的语义:当图谱查不到答案时不应该返回 500,而应该返回 200 加一个提示字段。
# app/api/v1/endpoints.py from fastapi import APIRouter, HTTPException router = APIRouter() @router.post("/v1/qa") def ask_question(payload: dict): text = (payload.get("question") or "").strip() if not text: raise HTTPException(status_code=400, detail="question 不能为空") intent = classify_intent(text) if intent == "crisis": return { "answer": "听起来你正在经历一段很难熬的时期。请立即联系当地心理援助热线。", "intent": "crisis", "evidence": [], } entities = link_entity(text) evidence = search_graph(entities, intent) answer = assemble_answer(evidence) return {"answer": answer, "intent": intent, "evidence": evidence}这个接口的契约很克制:入参只有一个question字符串,出参包括最终回复、识别到的意图和用于排查的证据路径。把intent和evidence返回给调用方,不是为展示,而是为了让前端可以实现“为什么推荐这个”的追问。测试时可以把该接口当成纯函数来测——同样的输入永远产生同样的输出,回归测试的确定性很高。
4.3 性能优化:缓存、连接池与批量查询
服务压测下,瓶颈几乎都在 Neo4j 上。优化思路按优先级排列:第一层是查询模板加提示,按名称匹配时显式走索引;第二层是结果缓存,对话场景里用户会不断追问,可以用 LRU 缓存把“意图+实体”作为 key 存结果,缓存命中时直接跳过图查询;第三层是批量查询。
from functools import lru_cache @lru_cache(maxsize=1024) def search_cached(intent: str, symptom: str) -> tuple: with driver.session() as session: result = session.run(QUERY_TEMPLATES[intent], symptom=symptom) return tuple(record["advice"] for record in result.data())lru_cache会把返回值缓存进进程内存,1024 的容量足够支撑咨询问答这种低频复用场景。注意缓存数据一旦涉及图谱更新,必须在写入侧做缓存失效,这里可以用一个全局版本号作为 key 的前缀,图谱每次变动后版本号加一,缓存自然失效。并发层面还要考虑连接池。
driver = GraphDatabase.driver( "bolt://localhost:7687", auth=("neo4j", "your-pass"), max_connection_pool_size=16, )max_connection_pool_size一般设为服务最大并发数的 1.5 倍左右,连接数并非越大越好,过多的连接会占用 Neo4j 线程资源,反而增加锁等待。
5. 效果评估与关键排错:让基于知识图谱的智能问答系统“敢上线”
5.1 构建面向咨询场景的评测集:三个维度缺一不可
代码跑通不算数,上线前要过评测。做法是先建 200 到 300 条问答对,覆盖五类意图,从三个维度独立打分。
| 评测维度 | 打分要点 | 达标基线 |
|---|---|---|
| 事实一致率 | 答案中的实体与图谱证据链路逐条比对 | ≥ 0.95 |
| 意图识别准确率 | 分类结果与人工标注一致的比例 | ≥ 0.92 |
| 安全拦截率 | 危机语料是否全部触发安全兜底 | 100% |
事实一致率可以完全离线完成,脚本读出答案字符串后与图谱证据节点做集合比对;安全拦截率必须用单独一个测试套件跑,危机语料逐条过接口,任何一条漏过都不具备上线条件。这个评测集要固化在 tests/fixtures 里,每次发布前自动执行。
5.2 三类高频故障:实体未命中、意图误判、查询超时
实体未命中时要先查别名表,再查图谱节点,顺序不能反。“心里堵”查不到,先看 alias 表有没有加过这个词,别名表确认覆盖后,再查该规范节点是否真的存在于图库。排查顺序反过来的话,会把大量时间浪费在排查图谱数据上。
意图误判最多出现在中性表述与危机表述的临界句上,“活着没意思”可能是情绪低落也可能是危机。这里的处理不是尽力区分,而是主动降级:返回“你刚才说的这种情况,能不能再多说一点”,把判断交给用户澄清,而不是强行打标签。
多跳查询超时的主要原因是路径过长。咨询问答里很少需要超过三跳,在模板里固定跳数上限,超过上限不查图,直接用引导语追问细分问题。与其优化慢查询,不如从业务上把查询面切开。
5.3 用证据陈旧度告警,让知识质量问题自动暴露
一个容易被忽略但又很实用的技巧是:upsert 时写入的created_at时间戳,除了用于追溯,还能变成知识质量的监控信号。在 QA 服务里加一个周期任务,扫描当天命中证据链上所有三元组的创建时间,如果一条证据路径里超过一半的节点或关系已超过 30 天未被更新,就生成告警。
STALE_THRESHOLD_DAYS = 30 def check_stale_evidence(evidence_paths, now=None): stale_nodes = [] for path in evidence_paths: for node in path: age = (now - node.created_at).days if age > STALE_THRESHOLD_DAYS: stale_nodes.append((node.name, age)) return stale_nodes当图谱匹配到的证据频繁来自 30 天前的旧三元组时,说明知识库建设没有跟上用户提问的分布变化,此时要补的往往不是图谱管理代码,而是业务语料。把知识过期问题从“用户反馈”变成“系统主动暴露”,这个技巧投入成本低,但对系统的长期可靠性帮助直接——运行半年后你再回头看,真正让问答系统变好的不是模型参数,而是知识库里那些及时更新的三元组。
本文还有配套的精品资源,点击获取