1. GEO数据的查询之痛与知识图谱的切入点
1.1 一次真实可复现的“找数据”经历
GEO(Gene Expression Omnibus)里的芯片和测序数据,几乎是每个做表达分析的人的起点。但同时,只要真正查过数据的人都会同意:这个库的价值和它的查询体验是严重不成正比的。有位做临床科研的朋友跟我吐槽过一次,他为了确认某个瘤种在肿瘤组织和癌旁组织之间是否存在稳定的差异表达基因,整整用了一下午,只为了把几十个Series的元数据逐一翻过去。他的结论是:这个库回答的从来都不是问题,而是一页又一页的XML字段。
我一开始还觉得他夸张,直到自己深入看了一批GSE记录,才发现他说得一点都不过分。同一个疾病,在不同数据集里的样本标题可以写成“tumor”“cancer”“carcinoma”“malignant neoplasm”,甚至直接给你一个登录号和板式编号。分组信息藏在Sample_characteristics字段里,用冒号、分号、制表符随意分隔。想回答一个“这个疾病下哪些基因显著上调”的问题,你得先下载、解压、解析、清洗、手工合并,才能真正开始工作。
这个项目就是想解决这个问题。我们把GEO中散落的高通量表达数据整理成一张可查询的知识图谱,在图谱上架设一个问答库。用户可以用自然语言直接提问,比如“肺腺癌组织对比癌旁组织显著上调的基因有哪些”,系统给出可溯源的答案,而且每条答案都能定位到具体的GSE编号和样本分组。这篇文章就围绕这个项目的设计思路、实现细节、踩坑经历和评估结果展开,适合正在做医学信息、知识图谱问答或者打算接入GEO数据的研发团队参考。
1.2 GEO数据的结构化程度:比你想象的更不结构化
很多人以为GEO既然叫数据库,内在一定是整齐的表格。实际上去翻一遍就会发现,它的数据结构是“三层壳”:GSE(一个实验整体)、GSM(单个样本)、GPL(实验平台)。每一层都有富余的元数据字段,看起来像结构化的,但字段里全是自由文本。
拿GSM样本来说,建库时典型的元数据长这样:
Sample_characteristics_ch1 = "tissue: brain" Sample_characteristics_ch1 = "disease: parkinson" Sample_characteristics_ch1 = "age: 70"看起来有规律,但下一个数据集的写法就变成了“cerebral tissue”“PD”“70 y/o”甚至是“77 years”。统计下来,光“疾病状态”这一个概念,在我们的语料里就有上百种表述方式。而“platform”字段虽然给出了平台ID,但不同GPL注释文件的更新程度又不一致,有的平台探针ID早就废弃了,平台注释还停留在旧版本。
所以做知识图谱的第一步,不是把数据导进图数据库,而是先做一轮“清洗+对齐”。我们后来总结出一句话:在医疗知识图谱项目里,实体对齐的复杂度通常被严重低估,大部分模型效果问题,根源都在这一步。
1.3 为什么要做成“问答库”,而不是又一个搜索框
刚开始我们只是想把GEO数据做成一张可视化图谱,给内部团队看。但真正放到演示环节,有人问“你看我这个基因在哪些疾病里高表达”,图谱页面就卡住了——因为原始交互是“先找节点,再拖线”,本质上还是数据库浏览器的逻辑,对非专业用户并不友好。
问答库的价值在于把“查询逻辑”藏起来。用户的自然语言问题,经过程序转换成图查询,最后返回的是一句有依据的自然语言答案。就像你把一份数据库表单交给了一个训练有素的导览员,普通人不需要知道字段名、连接条件和图遍历算法,也能拿到可靠结果。
从工程角度看,问答库也不是简单套一个语言模型。我们采用的方式是:底层用图数据库保存事实,问答层只做“意图识别+槽位抽取”,答案内容必须来自图中真实存在的实体和关系。这样做既利用了自然语言的灵活性,又守住了事实边界,避免模型胡编乱造。
2. 系统边界与总体设计
2.1 我用到的技术栈与版本选择
这个项目我们用的核心组合是Python、社区版图数据库Neo4j,以及一个内部封装好的R分析管线。选择Python是因为生态齐全,BioPython处理GEO摘要文本,pandas做表格清洗,requests抓取补充数据,顺滑。选择Neo4j是因为它的Cypher查询语言对“实体-关系”这种模式表达非常自然,并且社区版已经足够支撑这个量级的数据。
R在这里的作用是基因差异表达分析。我们用了几种主流的R包来处理表达矩阵与分组比较。一开始也想过全部用Python复刻一遍差异分析逻辑,但最后还是决定用R包做这个环节。理由是这些包经过大量生物信息学流程验证,比对结果的稳定性和可解释性都更好。
整个系统分成四个模块:
- 数据采集层:负责GEO的批量下载、SOFT文件解析、平台注释对齐
- 知识抽取层:负责实体对齐、关系抽取、差异表达分析结果转化
- 图存储层:负责schema设计、数据写入、索引与约束
- 问答层:负责意图识别、槽位抽取、Cypher生成、答案封装
这四层是流水线式的,每一层都单独跑,中间用Parquet格式的文件传递结果。这么做的好处是每一层都能独立重跑,不用全链路重新计算。
2.2 Schema设计:六个核心实体和四类关系
图谱的schema决定了所有后续查询能力的上限,这一步一定要想清楚再动手。我们最终收敛成六类核心实体和四类关系。
实体类型:
- GSE数据集:代表一个实验研究,包含平台信息、物种、摘要
- GSM样本:代表一个具体样本,包含组织来源、疾病状态、处理方式
- Gene基因:代表一个标准基因实体,用标准基因命名体系做唯一标识
- Disease疾病:代表标准化的疾病实体
- Tissue组织:代表样本来源组织
- Pathway通路:代表基因参与的功能通路
关系类型:
- GSE包含GSM:数据集到样本的包含关系
- GSM来源于组织:样本到组织的来源关系
- GSM处于疾病状态:样本到疾病的关联
- 基因在疾病中差异表达:这是最核心的关系,带方向(上调或下调)和证据属性
这个schema是做过几次调整之后的结果。最初我们还加过“药物”和“细胞系”两种实体,后来发现当前语料里药物注释的覆盖比例太低,强行建模只会增加稀疏实体和空查询,就暂时拿掉了。做知识图谱设计要接受一个原则:实体类型宁可少,也不能虚高;与其让用户问一个只有3条数据的实体,不如让他专注在最关键的50万条高质量关系上。
2.3 数据导入管线的整体链路
整条数据管线的运行逻辑是:
- 按关键词从GEO检索下载相关Series
- 解析SOFT文件,提取GSE、GSM、GPL和表达矩阵
- 对每个样本的Characteristics字段做清洗和标准化
- 将疾病描述与医学主题词表映射,将组织描述与标准组织词典映射
- 表达矩阵结合样本分组信息,运行差异表达分析
- 把分析结果转换成三元组写入Neo4j
其中第4步和第5步的产出会互相验证。简单来说,疾病实体映射得准,差异分析的分组才分得清楚;分组分得清楚,抽取出来的“疾病-基因-表达差异”关系才可靠。所以我们在实操中把这两步放在同一个流水线里反复迭代,而不是一次性跑完就收工。
3. 数据加工层的完整实现:从GSE编号到高质量三元组
3.1 GEO元数据的抓取与解析策略
GEO的数据下载有两种常见方式:一种是直接下载SOFT格式文件,里面有全部GSE、GSM字段;另一种是下载GSE Series Matrix文件,也就是表达矩阵。我们两个都要,因为元数据和矩阵分属不同文件。
解析SOFT文件时很考验内存管理与异常处理。文件头是GSE级别的字段,中间有一段Platform描述,后面是成百上千个GSM块。我的习惯是按块读,遇到连续的"^SAMPLE"标签后开始累积数据,而不是一次性读入内存。因为有些GSE包含上千个样本,解析脚本稍不注意就会把内存吃满,轻则卡死,重则影响同机的其他服务。
矩阵文件还要处理一个细节:不同GPL平台的探针ID命名规则完全不一样,有Affymetrix的带后缀"_at"的探针ID,也有Illumina的裸编号。解析时统一保留原始探针ID,进入对齐阶段后再统一转换,这一步不能在采集阶段就做归一化,否则后期想回溯某一条记录的原始出处会非常困难。
3.2 实体对齐:基因名规范化与疾病词典映射
基因名规范化是整个项目中我花时间最多的一块。
GPL平台注释文件给出了“探针ID→基因Symbol”的映射,但不同平台注释往往用了不同版本的基因命名。同一个基因,可能有大小写差异,比如“CDK2”写成“cdk2”;更麻烦的是同一个Symbol在不同平台注释里对应不同Entrez编号,或者一个Symbol曾经是某个基因的旧名,后来被废弃重命名。
我们的处理方式是建立三层对齐表:
第一层是“原始探针→Gene Symbol”的直接映射;第二层是“Gene Symbol→基因别名表”的语义扩展;第三层是“多重映射冲突消除”规则——当一个探针在一个平台注释里同时映射到多个基因,设置权重并抽样人工核查,避免把探针的污染信号当成真实表达。
疾病实体对齐同样繁琐。GEO里的疾病描述文本高度口语化,比如“normal”“healthy”“control”这些词在不同数据集里可能代表完全不同的对照组条件。我们维护了一个“对照组概念词表”,把“non-disease”“normal tissue”“unaffected”等表达归一成统一的NormalControl实体;同时把“parrkinson”“parkinsons disease”“PD”等变体统一到标准疾病名称上。
这个阶段给我的教训是:不要试图用一个大模型一步完成对齐。现在的效果反而来自分层分解:基础字段用规则和实体词典,模糊匹配再交给模型。原因是医疗术语的边界很敏感,宁可保守地漏掉,也不能激进地错配。
3.3 差异表达分析与“疾病-基因-表达差异”自动关系抽取
表达矩阵进入知识图谱的最高价值转化,是把它变成“基因在疾病中显著上调/下调”的可信关系。如果只存原始表达量,用户得自己设计查询和计算,库的价值就大打折扣。
我们用R包对每个有明确分组的GSE执行差异表达分析。流程是:
- 抽取GSE里的样本分组信息
- 标准化的表达矩阵归一化处理
- 运行组间比较,得到每个基因的logFC和调整后p值
- 按照阈值筛选差异基因
- 将差异基因结果连同证据GSE编号、分组信息、p值一起写入图谱
需要特别说明的是:我们不跑一次就自动采信。每个GSE的分析结果都附带一个“可信度标记”,只有分组信息明确、样本量足够的子集,才进入“高置信关系集”并对外问答。分组信息含糊的GSE,宁可降低图谱覆盖度,也不让错误结论混进来误导用户。
3.4 关系去重与证据链设计
同一对“疾病-基因”可能在多个GSE里都被判定为上调。我们会在图谱里保留多次出现的关系,但设计了一个“证据数”属性,记录这个关系被多少个独立数据集支持。问答结果输出时,如果证据数>=3,就标记为“多次验证”;如果只有1次,输出后会附带提示“该结论来自单个数据集,建议结合原文判读”。
这个设计直接提高了结果可信度,也让评估阶段有了客观口径。实际测试中,用户对“多次验证”结果的信任度,明显高于仅有单次证据的回答。
4. 问答引擎的实现:意图识别、图谱检索与答案生成
4.1 问题分类与槽位抽取
问答层我们分两步走:先判断问题类型,再抽取关键实体。
问题类型我们划分成四类:
- 数据集查询:问“有哪些包含某疾病转录组数据的GSE”
- 基因表达查询:问“某个基因在某个组织中表达量如何”
- 差异基因查询:问“某疾病中显著上调的基因有哪些”
- 多跳关系查询:问“与某基因相关且在疾病中也显著改变的基因有哪些”
槽位抽取的目标是从自然语言中抽取出疾病、基因、组织、关系方向。我们维护了一套医学核心术语词典,并叠加一个轻量序列标注模型。模型的作用不是直接给出答案,而是把“洛杉矶地区”这种无关实体给过滤掉,保证最终填充进查询模板的槽位只来自受控词典。
4.2 八类Cypher查询模板的设计
既然核心查询模式是有限的,我们就按“模板+槽位”来生成图查询,而不是每次现场动态构建Cypher。模板数量我最终收成了八类。覆盖面比较广的两条示例:
根据疾病查数据集:
MATCH (d:Disease {name: $disease})<-[:HAS_DISEASE]-(s:GSM)<-[:CONTAINS]-(g:GSE) RETURN DISTINCT g.seriesId, g.title LIMIT 50根据疾病查上调基因:
MATCH (d:Disease {name: $disease})<-[r:DIFF_EXPRESSED_IN]-(gene:Gene) WHERE r.direction = 'up' RETURN gene.symbol, r.logFC, count(r) AS evidence ORDER BY count(r) DESC LIMIT 20把模板写死有不少好处:查询性能可控,返回字段固定,答案格式好统一。用户问题再怎么千变万化,只要槽位抽取对了,最终都落到这些模板上。对于医疗问答场景来说,稳定远比聪明重要。
4.3 图算法加分项:多跳路径与相似性召回
图谱比传统关系表多出的一个优势,是支持多跳图遍历。我们在线问答里最常被问到的多跳问题是:“和A基因相关、且在B疾病中也差异表达的基因有哪些”。这个问题用传统SQL写会很绕,用Cypher只需两层路径即可,大致逻辑是:从A基因出发找到关联关系,再连接疾病节点,再反查疾病下其他差异基因。
在实现中我们还用到了简单的相似度召回:同一个疾病下共现频率高的基因,按“共同出现的疾病数量”排序。这个指标虽然不是严格的统计学共表达,但对于回答“最相关”的问题已经足够直观。它达不到论文级准确性,胜在速度快、结果能解释,适合作为问答候选集的一种扩增手段。
4.4 可追溯答案输出与“不答”策略
医疗场景里,模型答案的可追溯性是一条不能妥协的红线。我们输出的每一条结论,都强制附带证据链:结论对应的Cypher查询语句、支撑结论的GSE编号、样本数量和统计数值。这句话说直白点:用户应该能根据答案反查到图谱里的原始节点,而不是面对一个黑盒输出。
另一个被低估的功能是“不答”策略。当图谱中没有对应记录时,系统直接回答“图谱中没有查找到相关资料”,同时推荐相近的疾病名或基因名,比如“您是否想问‘帕金森病’?”这种坦诚的边界,比强行生成一段看起来合理但实际编造的答案要可靠得多。
5. 联调期间的五个典型故障与修复过程
5.1 基因名旧符号导致的知识碎片化
联调中我们遇到了一个印象很深的问题:用户问某个经典基因,系统却始终返回“图谱中没有该基因”。检查起来很奇怪,这个基因在原始平台注释里明明大量存在,但知识图谱里就是搜不到。
排查链路是这样的:先在基因实体表里模糊搜索,发现基因Symbol和我记忆中的不一样,系统里保存的是更名后的新符号。再回头看平台注释文件,发现数据源头用的是旧符号。我们对齐表设计时只做了大小写归一化,没有处理“旧名→新名”的别名继承。
修复方法是扩充基因别名表,把“曾用名”作为一个独立的别名属性挂到基因节点上。问答层槽位抽取时,先查正式名,再查别名,两者都映射到同一个标准基因节点。这个问题排查下来就发现,医疗数据里的“同一实体多表述”不是少数,是常态。
5.2 样本分组信息缺失时的降级处理
第二批数据导入后,我们发现很多GSE的分组信息是不完整的。有些Series的Characteristics字段直接写成“individual, treated, cell line”,既没有对照组,也没有疾病状态。按原计划流程,这类数据会被丢弃,但真实数据里这种情况占比不低,全丢的话图谱覆盖度受损。
我当时的处理方案是引入“降级模式”:把无法做差异分析的样本,仍然保留为“样本-组织-基因”的表达值关系。用户问某个样本某个基因的表达量时,依然能答出来,只是不能回答这个基因在某个疾病中是否显著改变。
这意味着同一个系统里存在两种等级的数据:高置信关系集和低置信表达集。问答模板里区分了它们的证据字段,用户能看到答案属于哪个等级。这种处理让覆盖率提升了将近三成,副作用是schema里的关系类型多了一层,但可解释性没有下降。
5.3 图谱索引设计不当导致慢查询
问答库上线第一个版本后,某些多跳查询的响应时间竟然到了二十多秒,完全没法用。用PROFILE看执行计划,发现每次查询都在全库扫描节点标签,因为图里节点的属性没有建索引。
解决的办法是在Neo4j里对高频查询字段统一建全等索引和文本模糊索引。对实体名称建索引,对关系方向建索引,再把某些两跳路径拆成两步单跳查询,用应用层做组装。经过一轮优化,绝大多数查询控制在几百毫秒以内。
这里有个经验值得分享:图数据库的性能问题,优化优先级一定是“索引优先,路径拆分其次,硬件扩容最后”。不要一慢就加机器,先看执行计划。
5.4 “疾病”与“组织”实体边界的歧义
有一类问题在医疗数据里特别容易引起歧义,比如一个字段写着“brain”。它到底代表疾病位置还是组织类型?有时候“breast cancer”是疾病,但单独一个“breast”只会被用户理解为组织。我们在最初的词典映射里,把“brain”同时加入了疾病词典和组织字典,结果产生了严重的错误关联:某基因的图谱关系里出现了“该基因在脑组织中高表达”,实际数据描述的是“脑部疾病”。
修复方案是给实体词典加了“上下文约束”:如果一个词出现在疾病描述字段里,优先映射疾病实体;如果出现在器官/组织字段里,优先映射组织。同时增加了人工维护的“歧义词清单”,对于brain、lung、breast这类词单独走上下文规则。
这也说明了一个问题:知识图谱的实体类型边界如果不清,下游所有遍历路径都会被污染。宁可多花时间在词典维护上,也不要指望模型自动“猜到”实体类型。
5.5 接入语言模型之后的反噬
初始版本我们尝试过用语言模型直接生成答案,实践了大概两周就决定降级。原因是模型生成的答案虽然读起来很通顺,但经常把图谱里不存在的细节以理所当然的口吻补出来。最典型的一次,系统回答“某药可能通过抑制某个基因表达起到作用”,这句话听上去完全合理,但图谱里根本没有这条关系。
后来我们调整了策略:语言模型只做两件事,一是从用户问题中提取槽位,二是把图谱检索到的结构化结果润色成自然语言。至于“什么是事实”,完全以图谱检索结果为准。换句话说,在严谨性要求高的行业,生成模型必须被绑定在证据检索链下,让“有依据才能说”,否则宁可不说。
6. 评测结果、使用边界与后续计划
6.1 测试集设计与评测指标
为了评估系统效果,我们整理了一套100道题的测试集,覆盖三类问题:单实体事实查询、双实体关系查询、多跳路径查询。同时请了几位同事手动标注正确答案,再与系统输出做比对。
评测指标用了准确率、漏答率和答案可溯源率三项。准确率是核心指标,漏答率反映系统“诚实承认不知道”的能力,可溯源率则验证每条回答是否能回到图谱证据。最终整体准确率大概在87%左右,其中单实体事实查询最高,接近94%;多跳路径查询最低,大约在78%。这个结果提示我们,多跳场景的意图识别还有提升空间,后续计划扩充路径模板并增加联合约束。
6.2 典型问答样例与效果表现
下面列几条有代表性的问答结果,方便理解系统实际输出形态:
| 问题类型 | 用户问题 | 系统输出 | 备注 |
|---|---|---|---|
| 数据集查询 | 有哪些包含肺腺癌转录组数据的GSE | 返回5个GSE编号及样本量 | 命中 |
| 基因表达查询 | TP53在正常肺组织中表达量如何 | 返回中位表达区间 | 命中 |
| 差异基因查询 | 帕金森病中显著上调的基因有哪些 | 列出5个基因及证据数 | 命中 |
| 多跳路径查询 | 与EGFR相关且在肺腺癌中差异表达的基因 | 返回候选基因及共现疾病数 | 部分命中 |
| 未知实体查询 | 某某假说基因的作用是什么 | 无图谱记录,推荐近似写法 | 诚实拒答 |
这个过程中我们发现,用户问得越具体,系统答得越准;越是开放式的“机制”“作用”类问题,越需要显式提示知识边界。未来我们会把这一类问题在引导界面做出更清晰的范围提示。
6.3 医疗场景必须守住的红线
围绕这个库,我们内部反复强调过几个原则,写在这里给做同类系统的团队参考:
第一,该库定位是科研辅助工具,不是临床辅助诊断设备。它的输出只能辅助凝练假设、辅助检索证据,不能直接作为诊断和治疗依据。所有界面都加了明确的用途声明。
第二,有依据才能说。每个回答必须能定位到图谱中的原始实体和关系,不能出现无中生有的推理链。这对问答引擎的设计约束很大,但也是医疗场景的基本要求。
第三,证据等级透明化。来自多个数据集验证的关系,与来自单个数据集的关系必须明显区分。不能因为系统答得流畅,就让人误以为结论同等可靠。
6.4 后续扩展方向
从项目现状看,后续扩展我会优先做三件事。
第一件事是数据更新机制。GEO每周都有新增提交,目前我们靠手工批量导入,后续应该做成定时任务,自动抓取新增Series并执行差异分析。知识图谱最大的风险是“静止”,不及时更新会逐渐失去参考价值。
第二件事是引入更多异源数据互补。单个数据库的表达数据口径有限,可以考虑融合其他公共数据库的蛋白质互作和通路注释,丰富多跳关系类型。但也要注意新增数据源必然带来新的实体对齐问题,必须重新走一遍蒸馏流程。
第三件事是把交互界面做成可视化查询模式。知识图谱加上问答很好用,但如果在界面上同时展示答案对应的图谱子图,用户能更快理解结论对应的证据路径。我们已经在原型图上做过验证,用户体验提升非常明显。
最后给同行的一句话
这个项目做下来,我最大的感受是:医疗行业的知识图谱问答库,真正的难点永远不在模型本身,而在数据加工质量、证据链设计和一套面向红线的工程取舍。先别急着上大模型,先把实体对齐做好,把自己的“不答边界”设定清楚。那些看似朴素的环节,最终决定了一个系统是经得起临床同事追问,还是只存在于演示文档里。