第 1 篇:「Glass-Box 的骨架」------全景架构与六层数据流水线
阅读本文你将了解: Semantica 官方宣传的 27 个模块如何组织成六层数据流水线;一条"文档进、图谱出"的主链路在源码里长什么样(GraphBuilder 逐段拆解);双时态事实模型为何选择"包装器而非替换"的兼容设计;以及用 1.9GB 依赖面换来的"确定性"到底值不值。本篇是全系列的地基:先建立六层心智模型,后面 02-06 篇逐层打穿。
关键源码事实: 版本锚点 main @
30fc644(v0.6.8)。所有file:line均为完整仓库相对路径,可在本地镜像..\semantica\直接定位。
1. 从一句官网话术说起
官网首页的口号是 "The Glass-Box Alternative to Black-Box Intelligence"------向量库回答"什么是相似的",Semantica 回答"什么是关联的、为什么、如何关联"。这句话在架构上的兑现方式,是把"知识"拆成三种一等公民:
- 事实(Facts)------实体、关系、带双时态有效期的事实,存于知识图谱;
- 决策(Decisions)------AI 或人做出的每个决策本身也是图节点,带完整因果链;
- 溯源(Provenance)------每条事实到原始来源的 W3C PROV-O 血缘,带 SHA-256 哈希链审计。
这三种公民分别由 semantica.kg、semantica.context、semantica.provenance 三个模块承载。理解了"三类一等公民",六层流水线的每一层就都有了明确的归宿:上层数据经过加工变成事实,事实之上沉淀决策,一切动作留下溯源。
2. 官方六层 ↔ 源码 29 个目录:一张对照表
官方文档 /modules 页把 27 个模块组织为六层。我们逐层对照源码目录(行数为 wc -l 实测),这张家地图是全系列的索引基座:
| 官方层 | 官方模块 | 源码目录(行数) | 一句话职责 |
|---|---|---|---|
| Input Layer | ingest / parse / split / normalize | semantica/ingest/(20,890) parse/(7,801) split/(4,694) normalize/(6,059) |
把世界上的原始数据搬进来、洗干净、切好块 |
| Core Processing | semantic_extract / kg / ontology / reasoning | semantic_extract/(11,614) kg/(13,343) ontology/(9,298) reasoning/(4,035) |
抽取实体关系、建图、建模式、推新知 |
| Storage | embeddings / vector_store / graph_store / triplet_store | embeddings/(3,054) vector_store/(12,067) graph_store/(7,829) triplet_store/(6,313) |
向量、属性图、RDF 三元组三类持久化 |
| Quality Assurance | deduplication / conflicts | deduplication/(4,634) conflicts/(4,951) |
实体去重与事实冲突仲裁 |
| Context & Memory | context / provenance / change_management | context/(21,541) provenance/(3,808) change_management/(2,115) |
Agent 记忆、决策留痕、PROV-O 溯源、版本管理 |
| Output & Orchestration | export / visualization / pipeline / explorer | export/(11,602) visualization/(6,461) pipeline/(3,965) explorer/(9,691) |
导出 12+ 格式、可视化、流水线 DSL、Web 工作台 |
| Utilities(官方不单独分层) | llms / mcp_server / seed / evals / core / utils | llms/(1,571) mcp_server/(983) seed/(1,151) evals/(574) core/(3,604) utils/(5,172) cli.py(4,848) |
provider 门面、MCP 服务、编排器、CLI |
这张表里第一个值得注意的信号是体量分布:Input 层合计约 3.9 万行、Storage 层约 2.9 万行------"把数据搬进来存下去"占了全仓三分之一;而宣传中被反复强调的 reasoning 只有 4,035 行。体量不会说谎:这个项目的工程重心在数据管道与存储抽象,推理引擎是"亮点功能"而非"主体结构"。
3. 六层流水线架构图

这张图在回答什么问题 :Semantica 的 29 个源码目录之间到底谁调用谁------它给出的是官方文档"六层分组"背后的真实数据流向 。关键节点有三处:其一,kg(GraphBuilder)是全图的枢纽,L1-L4 的加工成果最终都汇入它产出的"图谱字典";其二,context 并不直接依赖 L2 的抽取模块,而是通过 build_from_conversations 等入口自行组织,这是很多人误读的地方------决策智能是"平行于 KG 管道"的第二主干,而非管道的下游;其三,L6 的 pipeline DSL 是对 L1/L2 的编排外壳(虚线),不是数据流的必经之路------每个模块都可以独立 import 使用(官方页首句 "Every module works independently" 的源码依据见第 7 节的惰性导出机制)。三处 note 标出了后文将逐行拆解的两个枢纽类。
4. 主链路解剖(一):GraphBuilder.build() 的三态输入
全链路的枢纽是 GraphBuilder.build()(semantica/kg/graph_builder.py:422)。它接受"一态以上"的输入:原始文本字符串、预抽取的 Entity/Relation 对象、或者官方 quickstart 推荐的 {"entities": [...], "relationships": [...]} 字典。真实签名如下(节选,完整 docstring 见源码):
python
# semantica/kg/graph_builder.py:422
def build(
self,
sources: Union[List[Any], Any],
second_arg: Optional[Any] = None,
pipeline_id: Optional[str] = None,
**options,
) -> Dict[str, Any]:
# options 关键项(源码 docstring 摘录):
# extract (bool): 传入原始文本时是否运行抽取,默认 True
# extract_triplets (bool): 文本抽取时是否抽三元组,默认 True
# ner_method (str): "ml" / "pattern" / "llm",默认 "ml"
# relation_method (str): 默认 "pattern"
# entity_resolver: 覆盖 builder 配置的 EntityResolver 实例
# Returns: {"entities": [...], "relationships": [...], "metadata": {...}}
docstring 里有一句对生产环境极其关键的承诺:"Raw-text extraction uses local extractors by default and needs no provider or API key" (semantica/kg/graph_builder.py:455 附近)------默认路径完全本地、确定性、零 API 成本。这正是它"确定性基础设施"卖点在主链路上的落点。分发的实现是 _process_item()(semantica/kg/graph_builder.py:144),用鸭子类型探测逐一分派:
python
# semantica/kg/graph_builder.py:144-186(节选)
def _process_item(self, item, all_entities, all_relationships, **options):
if isinstance(item, str):
self._extract_from_text(item, all_entities, all_relationships, **options)
return
if hasattr(item, "text") and (hasattr(item, "label") or hasattr(item, "type")):
# Entity 对象 → dict
entity_dict = {
"id": getattr(item, "id", getattr(item, "entity_id", item.text)),
"name": item.text,
"type": getattr(item, "label", getattr(item, "type", "UNKNOWN")),
"confidence": getattr(item, "confidence", 1.0),
}
all_entities.append(entity_dict)
elif hasattr(item, "subject") and hasattr(item, "predicate") and hasattr(item, "object"):
# Relation 对象 → dict
...
elif isinstance(item, dict):
if "source_id" in item and "source" not in item:
item["source"] = item["source_id"]
if "subject" in item and "source" not in item:
item["source"] = item["subject"]
...
三个细节值得圈点。其一 ,dict 分支的字段归一化是"宽容输入"设计:source_id→source、subject→source、object→target,多套命名习惯都能喂进来------这与上游 semantic_extract 返回 Relation(subject=..., predicate=..., object=...) 对象、官方文档示例又用 source/target/type 字典的两套口径直接相关,_process_item 实际上承担了"API 方言翻译器"的角色。其二 ,文本分支 _extract_from_text()(semantica/kg/graph_builder.py:352)通过 _get_extractor()(semantica/kg/graph_builder.py:243)按 (kind, method) 缓存 extractor 实例------这是为了避免 NER 对象反复加载 spaCy 模型的经典优化。其三 ,build() 返回的是纯字典 而非图对象:{"entities", "relationships", "metadata"}。持久化是可选的下一步(传 graph_store=store 才落库),这个"先内存字典、后可选落库"的两段式,是理解后面存储层设计(03 篇)的前提。
5. 主链路解剖(二):实体合并后的端点重写
GraphBuilder 里最能体现"图谱工程成熟度"的是 _remap_relationship_endpoints()(semantica/kg/graph_builder.py:265)。问题背景:实体消歧会把 "Apple"、"Apple Inc."、"AAPL" 合并成一个规范实体,合并前收集的关系边仍然指向旧实体 ID------不重写就是一张悬空边遍布的坏图。
python
# semantica/kg/graph_builder.py:265-308(节选)
def _remap_relationship_endpoints(self, entities, relationships) -> int:
endpoint_map: Dict[Any, Any] = {}
for entity in entities:
if not isinstance(entity, dict):
continue
canonical_id = entity.get("id")
if canonical_id is None:
canonical_id = entity.get("entity_id")
if canonical_id is None:
continue
# 规范 ID 自映射,merged_from 里的每个旧 ID 映射到规范 ID
try:
endpoint_map[canonical_id] = canonical_id
except TypeError:
continue
merged_from = entity.get("merged_from") or []
if isinstance(merged_from, (list, tuple, set)):
for source_id in merged_from:
if source_id is not None:
try:
endpoint_map[source_id] = canonical_id
except TypeError:
continue
...
注意那两个 try: ... except TypeError: continue------对不可哈希的 ID(比如调用方塞了个 list 当 ID)跳过而不是让整图构建失败 ,注释原话是 "Invalid/unhashable IDs are left for graph validation to report rather than making graph construction fail here"(semantica/kg/graph_builder.py:289)。这种"宽进严出"的防御姿态贯穿整个仓库:上层输入尽量容错,把严格性留给专门的校验模块(GraphValidator)。代价是调试时坏 ID 会静默丢失、直到校验阶段才浮出------对大海捞针式的数据质量排障并不友好,算是一个可辩护但有成本的取舍。
6. 主链路解剖(三):双时态事实的"包装器"设计
semantica/kg/temporal_model.py:28 定义了全项目时间智能的原子类型 BiTemporalFact。先看它携带的四个时间字段,这是读懂 04/05 篇时间相关逻辑的钥匙:
python
# semantica/kg/temporal_model.py:17-64(节选)
class TemporalBound(Enum):
"""Sentinel bounds for open-ended temporal intervals."""
OPEN = "OPEN"
@dataclass
class BiTemporalFact:
valid_from: Optional[datetime] # 有效时间起点(事实在现实中成立)
valid_until: Optional[datetime | TemporalBound] # 有效时间终点(OPEN=仍然成立)
recorded_at: datetime = field(default_factory=_default_recorded_at) # 事务时间(我们何时得知)
superseded_at: datetime | TemporalBound = TemporalBound.OPEN # 被修订时间
@classmethod
def from_relationship(cls, relationship: Dict[str, Any]) -> "BiTemporalFact":
valid_until_raw = relationship.get("valid_until", TemporalBound.OPEN)
if valid_until_raw is None:
valid_until_raw = TemporalBound.OPEN
...
设计文档里藏着一个重要的架构决策,类 docstring 写得坦白:"Facts continue to live as plain relationship dicts in the graph. This wrapper is only used internally for normalization" (semantica/kg/temporal_model.py:31-35)。也就是说,图里的事实永远是普通 dict(兼容旧版调用方),BiTemporalFact 只在归一化时临时把 dict 包装成带类型的数据类,序列化时再拆回 dict 字段(to_relationship_fields(),semantica/kg/temporal_model.py:61)。这个选择的收益是零迁移成本的向后兼容------代价是类型安全只在归一化瞬间成立,图里游荡的 dict 随时可能缺字段。与之配套,查询侧的 is_active()(semantica/context/context_graph.py:430)把 at_time 和存储值统一归一到 tz-naive UTC 再比较,实测验证过:valid_from 在未来的节点在历史时点快照中被正确过滤(见 04 篇实测数据)。
一个实测发现的 API 陷阱要在此预警:ContextGraph.add_node(timestamp=...)(semantica/context/context_graph.py:1124)里的 timestamp 只是普通属性,不参与时态判断 ;真正生效的是 valid_from/valid_until(add_node docstring 有注明,但参数名极具误导性)。我们在隔离环境实测中第一版测试脚本就栽在这里------timestamp 传入历史时间后 state_at() 仍返回了"未来"节点。
7. 模块装配机制:为什么"每个模块可独立使用"是真的
官方反复强调 "Every module works independently: use only what you need"。源码依据是全仓库统一的**惰性导出(lazy export)**模式。包级 semantica/__init__.py:200 定义了模块级 __getattr__(PEP 562),子包 semantica/ingest/__init__.py:166 则维护一张显式导出表:
python
# semantica/ingest/__init__.py:166-290(节选)
_LAZY_EXPORTS: Dict[str, Tuple[str, str]] = {
"FileIngestor": (".file_ingestor", "FileIngestor"),
"WebIngestor": (".web_ingestor", "WebIngestor"),
...
}
def __getattr__(name: str) -> Any:
if name not in _LAZY_EXPORTS:
raise AttributeError(...)
module_name, attr_name = _LAZY_EXPORTS[name]
... # 首次访问才 import 对应模块
这个模式在三处产生了实际影响。第一 ,pip install semantica 的重型依赖(torch/transformers/spacy)虽然声明在核心依赖里装了 1.9GB(实测),但 import semantica.ingest 这样的轻量使用并不触发 torch 加载------安装重、运行可以很轻,两者被解耦了。第二 ,官方文档承认有 6 个 ingestor(DuckDB/Elastic/GDrive/HuggingFace/Mongo/Pandas)"ship but aren't re-exported"------就是导出表没登记,必须深路径导入,这是惰性模式的维护成本实证。第三 ,MCP server 专门加了 SEMANTICA_DISABLE_PROGRESS=1 环境变量防进度条污染 JSON-RPC stdout(semantica/mcp_server/__init__.py,注释记录了真实故障),说明"打印噪音"已经真实咬过他们一口。
8. 生产视角:1.9GB 的入场费与 doctor 深探针
我们在 Windows + Python 3.13.12 隔离环境实测了完整安装(PyPI 0.6.8,清华镜像):36 分 36 秒、site-packages 1,900 MB、200+ 包。除了宣传中提到的 torch/transformers/spacy,实测还拖进了 opencv-python、librosa、soundfile、onnxruntime------后三者来自 fastembed 的传递依赖,对只需要"文本+图"的团队纯属被动负重。
安装后的第一道防线是 semantica doctor(semantica/cli.py:791 起,约 150 行)。它检查 Python/rich/图存储连通性/向量库可 import 性/LLM key/配置与日志目录,亮点是一个 --deep-embeddings 深探针:真正实例化 TextEmbedder 并 embed 一句探针文本,专治"import 成功但模型下载失败"这类环境病(CLI 注释引用 issue #994)。这与第 10 节要讲的"静默降级"直接相关------doctor 是官方给出的对抗手段,但需要你主动跑。
部署形态上,仓库提供了多阶段 Dockerfile(node:26-alpine 构建 Explorer 前端 → python:3.13-slim 运行时,digest pin + --require-hashes 供应链锁定 + 非 root + HEALTHCHECK),以及覆盖 helm/azure/gcp/fly/railway/render 七平台的 deploy/ 模板。docker-compose.yml 会附带启动一个 FalkorDB 容器------但截至 v0.6.8,后端 semantica/explorer/app.py:47-51 的注释明说 GraphSession 不连接外部图库,compose 里的 FalkorDB 目前是摆设,别被部署清单误导。
9. 进阶视角:两条技术路线的分野
把 Semantica 放回"LLM 时代知识图谱"的坐标系里,它代表了与 Microsoft GraphRAG / LightRAG 相反的一条路线。三条关键差异,每条都能锚定到源码:
| 维度 | Semantica(确定性路线) | GraphRAG/LightRAG(LLM 摘要路线) |
|---|---|---|
| 图怎么来 | 规则/spaCy/本地 ML 抽取,默认零 API key(semantica/kg/graph_builder.py:455 docstring 承诺;semantica/semantic_extract/methods.py:755 spaCy 路径) |
LLM 逐块摘要+抽取,token 成本与语料线性相关 |
| 一致性 | 确定性函数,同输入同输出;冲突检测是精确比较(semantica/conflicts/conflict_detector.py) |
LLM 输出有方差,同一语料两次构建图不同 |
| 审计能力 | PROV-O 哈希链(semantica/provenance/manager.py:1450 实测 verify_chain valid=True) |
摘要文本无法回溯到结构化血缘 |
代价同样锚定得住:确定性抽取的召回上限受制于规则与本地小模型------官方 quickstart 自己都要靠 NERExtractor(method="llm") 分支补精度;而 semantic_extract/methods.py 里 spaCy 路径的 confidence 恒为 1.0(无置信度可用,hasattr 检查形同虚设),下游如果按置信度过滤会全部放行。选型判据因此可以一句话说清:监管审计优先 → Semantica;问答质量优先 → GraphRAG 路线;两者都要 → 用 Semantica 做底座、把 LLM 抽取接到 ner_method="llm" 的插槽上。
10. 小结与下一篇
本篇建立了三层地基:六层心智模型 (官方分组 = 真实数据流)、枢纽类地图 (GraphBuilder 汇聚 L1-L4,ContextGraph 平行第二主干)、装配机制 (PEP 562 惰性导出让"模块独立使用"成为源码事实而非口号)。同时埋下两个贯穿全系列的伏笔:timestamp 参数名陷阱与 embeddings 静默降级------它们分别会在 04 篇(实测数据)和 03 篇(semantica/embeddings/text_embedder.py 源码)展开。
下一篇进入 Input 层的内部:31 个文件 2.1 万行的 ingest/ 里,企业连接器是真材实料还是空壳?官方宣传的 13 种分块策略中,为什么我们审读判定"5 种宣传里只有 2 个半是真的"?------semantica/split/methods.py:1345 的一行存根代码将给出铁证。
关键源码事实表
| # | 事实 | 锚点 |
|---|---|---|
| 1 | build() 主入口接受文本/对象/dict 三态输入,默认本地抽取零 API key | semantica/kg/graph_builder.py:422 |
| 2 | 鸭子类型分派:str→抽取、Entity 对象→dict、Relation 对象→dict、dict→字段归一 | semantica/kg/graph_builder.py:144 |
| 3 | extractor 按 (kind, method) 缓存,避免重复加载 spaCy 模型 | semantica/kg/graph_builder.py:243 |
| 4 | 实体合并后按 merged_from 重写关系端点,防悬空边;不可哈希 ID 静默跳过 | semantica/kg/graph_builder.py:265 |
| 5 | BiTemporalFact 四字段:valid_from/valid_until(有效时间)+ recorded_at/superseded_at(事务时间) | semantica/kg/temporal_model.py:28 |
| 6 | 事实在图中永远是普通 dict,BiTemporalFact 仅归一化时临时包装(零迁移兼容设计) | semantica/kg/temporal_model.py:31 |
| 7 | 时态判断只认 valid_from/valid_until,add_node(timestamp=...) 是误导参数 | semantica/context/context_graph.py:1124 |
| 8 | is_active() 把 at_time 与存储值归一为 tz-naive UTC 再比较 | semantica/context/context_graph.py:430 |
| 9 | 全仓惰性导出:PEP 562 getattr + _LAZY_EXPORTS 显式表;6 个 ingestor 未登记需深路径导入 | semantica/ingest/__init__.py:166 |
| 10 | doctor --deep-embeddings 真实实例化 TextEmbedder 做探针(对抗静默降级) | semantica/cli.py:791 |
| 11 | 实测安装:36m36s / 1,900MB / 200+ 包(含 opencv/librosa 被动拖入) | 复现脚本 semantica-test/test_quickstart.py |
| 12 | docker-compose 附带 FalkorDB 但 Explorer 后端不连接(预留) | semantica/explorer/app.py:47 |
常见误区 FAQ
Q1:pip install semantica 后 import semantica 会不会加载 torch?
不会立即加载。依赖安装了但 import 靠惰性导出(第 7 节),轻量使用 semantica.ingest 等不触发 torch。但要生成嵌入时(fastembed/sentence-transformers 路径)就会加载,首次还有模型下载。
Q2:build() 传入的 dict 关系字段到底该叫 source/target 还是 subject/object?
都可以。_process_item 的 dict 分支做了 source_id→source、subject→source、object→target 等多套归一(第 4 节)。但团队内部建议固定一套(与官方文档一致的 source/target/type),减少代码阅读时的方言切换。
Q3:为什么 state_at() 返回了我还没"创建"的节点?
大概率是给 add_node(timestamp=...) 传了历史时间并期望它影响时态------timestamp 只是普通属性。改用 valid_from="2024-01-01T00:00:00+00:00"(ISO 字符串)。
Q4:这张六层图和官方模块页有什么区别?
官方 /modules 页是"按层分类的模块清单";本文图 3 补上了官方没画的真实数据流向(尤其是 context 作为平行主干、pipeline 作为编排外壳这两点),并以 note 标注了两个枢纽类的源码行号。