OpenMed 本地优先的 LlamaIndex 摄入脱敏变换:在拆分、嵌入与存储前保护节点文本与敏感元数据
【免费下载链接】openmedLocal-first healthcare AI: clinical NER & HIPAA PII de-identification that runs 100% on-device. 2,200+ medical models, 21 languages, Apple MLX + Python, no cloud, no patient data leaving your network. Apache-2.0项目地址: https://gitcode.com/GitHub_Trending/ope/openmed
OpenMed 为 LlamaIndex 提供了一套可选的本地优先(local-first)摄入变换(ingestion transform),可在节点被拆分(splitting)、嵌入(embedding)或持久化之前,先对节点文本和敏感元数据进行脱敏,同时严格保持 LlamaIndex 的"节点列表"(list-of-nodes)契约不变。阅读本文后,你将掌握如何用openmed[llamaindex]在IngestionPipeline中插入脱敏变换、如何通过LlamaIndexRedactionConfig精细调校本地脱敏引擎、如何读取仅含计数的审计元数据,以及该适配器在源码层面的设计边界。
安装与依赖边界
LlamaIndex 是 OpenMed 的可选依赖,需要显式安装额外依赖项:
pip install "openmed[llamaindex]"在 pyproject.toml 中,该 extra 声明为llama-index-core>=0.10,<1。关键设计在于:导入openmed或openmed.interop并不会导入 LlamaIndex。这一保证由两处机制共同落实:
- 适配器注册表(openmed/interop/init.py)中,
llamaindex被登记为惰性加载的 builtin 适配器,import openmed.interop只注册元数据,不触发第三方导入; - 工厂函数在运行期才通过
_load_optional_class动态导入llama_index.core.schema.TransformComponent等类(openmed/interop/llamaindex.py)。
单元测试test_registry_loads_llamaindex_redaction_adapter_lazily(tests/unit/interop/test_llamaindex_redaction.py)会在加载适配器后断言sys.modules中不存在任何llama_index前缀模块,验证了这一惰性边界。若未安装 extra 就调用工厂,会通过openmed.core.capabilities.raise_missing_backend抛出带openmed[llamaindex]提示的MissingOptionalDependencyError(见test_redaction_factories_raise_clear_error_without_extra,tests/unit/interop/test_llamaindex_redaction.py)。
在拆分之前完成脱敏:摄入变换的正确用法
核心原则是把脱敏变换放在 splitter 之前,这样后续所有 chunk 都由已受保护的文本派生而来,而不是在拆分后逐块脱敏(后者容易产生跨 chunk 的上下文泄漏与不一致)。
from llama_index.core.ingestion import IngestionPipeline from llama_index.core.node_parser import SentenceSplitter from openmed.interop.llamaindex import ( LlamaIndexRedactionConfig, create_redaction_transform, ) redaction_transform = create_redaction_transform( config=LlamaIndexRedactionConfig( numeric_metadata_allowlist=("page_number",), ) ) pipeline = IngestionPipeline( transformations=[ redaction_transform, SentenceSplitter(chunk_size=512, chunk_overlap=32), embed_model, ], disable_cache=True, ) redacted_nodes = pipeline.run(documents=documents, store_doc_text=False)该变换在源码中的行为(OpenMedRedactionTransform.__call__,openmed/interop/llamaindex.py)依次完成三件事:
- 复制节点:通过
_clone(优先调用 Pydantic 的model_copy(deep=True),否则回退copy.deepcopy)生成节点副本,原始节点绝不被修改; - 保留既有 chunk 元数据:脱敏仅作用于节点文本与元数据值本身,chunk 的结构信息(如
start_char_idx、end_char_idx)保持不变(见test_ingestion_transform_redacts_copies_before_storage,tests/unit/interop/test_llamaindex_redaction.py); - 以确定性 UUID 假名替换节点与关系标识符:节点
id_与relationships中的关联节点node_id会通过 UUIDv5 命名空间https://openmed.ai/llamaindex-redaction生成稳定假名(openmed/interop/llamaindex.py 与_pseudonymized_node_id,openmed/interop/llamaindex.py),实现存储安全(storage-safe)的链接一致性:同一来源标识符反复出现,得到的假名也相同。
测试test_ingestion_transform_keeps_source_ids_stable_across_calls验证了"同一 source id 两次变换得到相同 UUID 假名"(tests/unit/interop/test_llamaindex_redaction.py),且test_real_ingestion_sanitizes_related_node_metadata证明序列化后的存储元数据中不会残留任何原始姓名、文档 ID 或病历号(tests/unit/interop/test_llamaindex_redaction.py)。
数字元数据的白名单机制
numeric_metadata_allowlist用于放行经过审查、不具备识别性的数值元数据(如页码page_number)。源码逻辑见_redact_metadata_value(openmed/interop/llamaindex.py):
- 白名单内的数值字段原样保留;
- 其他数值字段会被替换为确定性假名,格式为
openmed-<sha256 摘要>,其中摘要基于"类型名 + 原值"计算(_pseudonymized_identifier,openmed/interop/llamaindex.py)。
例如病历号record_number=1234567会被替换成openmed-...形式的字符串,而page_number=2保持不变(见测试断言,tests/unit/interop/test_llamaindex_redaction.py)。
元数据脱敏的完整规则
当redact_metadata=True(默认开启)时,节点元数据会递归处理:
- 字符串值:走完整脱敏流程(键与值都处理),并自动写入
excluded_llm_metadata_keys/excluded_embed_metadata_keys,使脱敏后的元数据不会回流进 LLM/Embedding 的get_content()输出(_exclude_metadata_from_content,openmed/interop/llamaindex.py;测试见 tests/unit/interop/test_llamaindex_redaction.py); - 映射 / 列表 / 元组:递归处理,且带环检测——循环引用会抛出
ValueError("LlamaIndex metadata must not contain cycles")(openmed/interop/llamaindex.py); - 布尔与 null:原样保留;数字:按上述白名单规则处理;
- 其他类型(如
pathlib.Path):抛出TypeError,提示元数据仅支持字符串、数字、布尔、null、映射、列表或元组(测试test_postprocessor_rejects_unsupported_or_cyclic_metadata,tests/unit/interop/test_llamaindex_redaction.py)。
与缓存、并行及序列化的兼容性
变换通过to_dict()/__reduce__暴露稳定的缓存标识与可 pickle 重建能力(openmed/interop/llamaindex.py):
- 默认缓存标识为
default:<sha256(配置 repr)>,同一配置产生同一标识,不同配置(如mask与remove)产生不同标识,避免 IngestionCache 串用(_redaction_cache_identity,openmed/interop/llamaindex.py);传入自定义deidentifier时则退化为custom:<随机 hex>,保证自定义脱敏器下不误用缓存; - 提供
__reduce__使变换可跨进程 pickle,支持并行摄入(num_workers>0)与分布式场景(见test_real_llamaindex_transform_is_picklable_when_extra_is_installed与test_real_llamaindex_parallel_ingestion_when_extra_is_installed,tests/unit/interop/test_llamaindex_redaction.py)。
仅计数的审计元数据(Counts-only Audit Metadata)
变换仍然只返回节点列表,敏感信息不会混入返回数据。需要审计时,从变换实例上读取安全摘要:
audit = redaction_transform.audit_metadataaudit_metadata(以及等价的get_audit_metadata()与结构化对象last_audit)返回一个仅含计数的字典,字段定义见LlamaIndexRedactionAudit.to_dict()(openmed/interop/llamaindex.py):
| 字段 | 含义 |
|---|---|
nodes_processed | 处理的节点总数 |
nodes_changed | 发生至少一处变化的节点数 |
text_values_redacted | 被脱敏的文本值数量 |
metadata_values_redacted | 被脱敏的元数据值数量 |
entity_counts | 按规范类别(canonical label)统计的实体计数映射 |
source_ids_pseudonymized | 被假名化的来源标识符数量 |
numeric_metadata_pseudonymized | 被假名化的数字元数据数量 |
该摘要不含任何来源标识符、偏移量、输入文本、替换值或任意脱敏器元数据;它不会被写入节点元数据,也不会被发送给嵌入模型。审计对象在__post_init__中被冻结为不可变、经过校验的状态(openmed/interop/llamaindex.py):所有计数通过_safe_nonnegative_count强制为非负整数,实体计数通过_normalize_audit_counts折叠为规范的CANONICAL_LABELS集合内的类别,任何未知/恶意标签都会落入"OTHER"而不会泄露原始值(_safe_audit_label,openmed/interop/llamaindex.py)。
两个测试从攻击者视角验证了审计的安全性:
test_audit_collapses_untrusted_entity_labels_without_exposing_them:敏感标签SYNTHETIC_SENSITIVE_VALUE被折叠为{"OTHER": 2},且不出现在repr中(tests/unit/interop/test_llamaindex_redaction.py);test_audit_freeze_and_sanitizes_caller_owned_counts:负数与布尔计数值被清零/丢弃,调用方随后修改源字典也不会影响已冻结的审计(tests/unit/interop/test_llamaindex_redaction.py)。
可选检测器实体元数据的边界保护
若脱敏结果带有可选的实体元数据(如result.pii_entities),审计只从中提取计数,并施加双重边界:
- 每个被脱敏值最多读取10,000条实体观测(常量
_MAX_AUDIT_ENTITIES_PER_VALUE); - 实体类别数量最多为规范标签数 + 1(常量
_MAX_AUDIT_ENTITY_CATEGORIES,防哈希洪泛式膨胀)。
读取过程中任何异常(如ExplodingEntityMetadata迭代器抛错)都会被吞掉,畸形元数据不会导致脱敏失败(observe_value,openmed/interop/llamaindex.py;测试见 tests/unit/interop/test_llamaindex_redaction.py)。
用 LlamaIndexRedactionConfig 调校本地脱敏引擎
LlamaIndexRedactionConfig(openmed/interop/llamaindex.py)把 OpenMed 底层openmed.core.pii.deidentify的可调参数以 frozen dataclass 的形式暴露出来,核心字段如下:
| 字段 | 默认值 | 说明 |
|---|---|---|
method | "mask" | 脱敏方法,如mask、remove、replace、hash、format_preserve、aadhaar_mask、shift_dates等(对应 openmed/core/pii.py 的deidentify支持集合) |
model_name | None | 指定 PII 检测模型(默认英文模型由底层deidentify决定) |
confidence_threshold | 0.7 | 脱敏所需的最低置信度,默认 0.7 是为安全起见抬高的阈值 |
keep_year | False | 日期脱敏时是否保留年份 |
keep_mapping | False | 是否保留脱敏映射(用于可逆/映射审计场景) |
use_smart_merging | True | 智能合并(例如把拆散的日期片段01与/15/1970合并为完整日期) |
lang | "en" | 检测语言 |
redact_metadata | True | 是否递归脱敏节点元数据 |
numeric_metadata_allowlist | () | 数字元数据白名单(见上文) |
normalize_accents | None | 是否归一化重音字符(默认交给引擎决定) |
use_safety_sweep | True | 是否启用最终安全扫描兜底 |
consistent | False | 是否启用一致性脱敏(同一实体跨文本稳定输出) |
seed | None | 随机种子 |
locale | None | 区域设置 |
policy | None | 策略配置 |
calibration_thresholds_path | None | 校准阈值文件路径 |
extra_kwargs | {} | 透传的额外关键字参数 |
to_deidentify_kwargs()(openmed/interop/llamaindex.py)负责把这些字段映射为deidentify(...)的关键字参数,并做一道安全护栏:extra_kwargs不能覆盖任何已命名的安全配置字段,一旦碰撞立即抛出ValueError(测试test_extra_kwargs_cannot_override_named_safety_configuration,tests/unit/interop/test_llamaindex_redaction.py)。例如试图用extra_kwargs={"use_safety_sweep": False}关闭安全扫描会被拒绝——这保证了无论调用方如何组合参数,安全扫描这类防护默认值不会被静默绕过。
自定义脱敏器注入
除了配置驱动,create_redaction_transform(config=..., deidentifier=...)与create_redaction_postprocessor(config=..., deidentifier=...)都接受一个deidentifier可调用对象。其约定为:返回字符串,或返回带deidentified_text属性的对象(_deidentified_text,openmed/interop/llamaindex.py)。测试中的fake_deidentify展示了这一最小契约——接收文本与 kwargs,返回含deidentified_text的结果对象(tests/unit/interop/test_llamaindex_redaction.py)。
摄入期 vs 检索期:两条互补的防护路径
本文讲解的create_redaction_transform属于**摄入期(ingestion-time)防护:节点在写入索引前就被脱敏,保证存储与嵌入的内容本身不含 PII。而 OpenMed 还提供了检索期(retrieval-time)**节点防护——通过create_redaction_postprocessor生成BaseNodePostprocessor子类OpenMedRedactionPostprocessor(openmed/interop/llamaindex.py),在查询返回后、进入 LLM 前对NodeWithScore节点进行脱敏,同步方法postprocess_nodes与异步方法apostprocess_nodes(内部经asyncio.to_thread包装)均已实现,且不改变节点的 score(见test_postprocessor_redacts_fixture_nodes_without_an_llm与test_postprocessor_supports_async_retrieval,tests/unit/interop/test_llamaindex_redaction.py)。检索期 postprocessor 的storage_safe=False,因此不做节点 ID 假名化,只做内容脱敏——保持索引链接不变。
完整的检索期接入指南参见 LlamaIndex redaction postprocessor guide。
配套的 LlamaIndex 工具集成
同一适配器模块还通过get_llamaindex_tools()把 OpenMed 的注册表工具渲染为 LlamaIndexFunctionTool对象(openmed/interop/llamaindex.py),并支持在openmed.interop顶层以openmed.interop.get_llamaindex_tools()访问(openmed/interop/init.py)。这意味着脱敏变换、检索期后处理器与函数调用工具三者可以组合成一条完整的"脱敏摄入 → 安全检索 → 工具调用"链路,全部在本地完成,无需任何云服务。
总结
OpenMed 的 LlamaIndex 集成把本地优先的 PII/PHI 脱敏能力无缝嵌入 LlamaIndex 的数据摄取生命周期:通过把create_redaction_transform置于 splitter 之前,确保后续所有 chunk 均派生自受保护文本;通过确定性 UUID 假名化节点与关系标识符,实现存储安全的跨文档链接;通过仅计数的审计元数据,为管线遥测提供不含任何敏感内容的安全摘要。从源码可以看出,该适配器在每一层都做了防御性设计——惰性导入、配置冲突拒绝、审计标签白名单折叠、实体元数据上限、循环引用检测与可 pickle/可缓存——使其既能满足 HIPAA 场景下的严格隐私要求,又能作为 LlamaIndex 生态中稳定、可复用的标准变换组件。
【免费下载链接】openmedLocal-first healthcare AI: clinical NER & HIPAA PII de-identification that runs 100% on-device. 2,200+ medical models, 21 languages, Apple MLX + Python, no cloud, no patient data leaving your network. Apache-2.0项目地址: https://gitcode.com/GitHub_Trending/ope/openmed
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考