杂乱数据如何变成一张可解释、可审计的知识图谱 4 / 10

第 4 章

实体关系抽取

源码核对基于 semantica-agi/semantica commit `1ee2ae88`,tag `course-anchor-20260824`

场景还原

风控团队上线了一套「合同要素抽取」脚本:把一份租赁合同扔给 LLM,让它把承租人、出租人、租金、起租日抽成 JSON。第一周运行正常。第二周,模型在某一份合同上把「张三年租金 12 万」抽成了实体「张」,租金写成了「12 元」,还编造了一个合同里根本没写的日期「2025-13-45」。更麻烦的是,下游已经把这批三元组写进了知识图谱,没人说得清「张」这个节点是从哪句话、哪个模型、哪次调用产生的。

三个问题摆在眼前。第一,LLM 的输出格式会漂移,上次是 {"entities": [...]},这次可能多包一层 data 或者干脆吐一段散文。第二,置信度是模型自己报的,模型说 0.95 不代表真的 0.95,更不会因为你业务上只关心「承租人、出租人」这些类型而调整。第三,抽取结果没有任何溯源,坏了查不到源头。

Semantica 的 semantic_extract 模块对这三个问题的回答,集中在这一章要读的四条线上:类型化输出把「格式漂移」压死,回退链保证「永远有结果」,结果缓存挡住重复调用,溯源包装层让每个实体能指回来源。四条线最终汇聚成一个主张:抽取结果可信,靠的是代码里的约束,不靠模型自觉。

逐行精读

统一结果模型:三个 dataclass

抽取层所有组件共享同一套结果类型,定义在 types.py。实体、关系、三元组各一个 dataclass,字段少而明确。

semantica/semantic_extract/types.py12:52
12@dataclass13class Entity:14    """Entity representation."""1516    text: str17    label: str18    start_char: int19    end_char: int20    confidence: float = 1.021    metadata: Dict[str, Any] = field(default_factory=dict)222324@dataclass25class Relation:26    """Relation representation."""2728    subject: Entity29    predicate: str30    object: Entity31    confidence: float = 1.032    context: str = ""33    metadata: Dict[str, Any] = field(default_factory=dict)343536@dataclass37class Triplet:38    """RDF triplet representation."""3940    subject: str41    predicate: str42    object: str43    confidence: float = 1.044    metadata: Dict[str, Any] = field(default_factory=dict)4546    def get(self, key: str, default: Any = None) -> Any:47        """Get attribute value like a dictionary."""48        return getattr(self, key, default)4950    def __getitem__(self, key: str) -> Any:51        """Get item like a dictionary."""52        return getattr(self, key)

注意三处细节。其一,Entity 携带 start_charend_char,实体永远能指回原文切片,这是后续溯源和审计的地基。其二,Relation 的 subject 和 object 是 Entity 对象而非字符串,所以关系天然绑定到「哪个实体在哪个位置」,context 字段再存一截上下文,审计时能看全句。其三,Triplet 的三个位置都是字符串,因为三元组面向 RDF 序列化,不再需要字符偏移;它还实现了 get__getitem__,让三元组同时能像字典一样被取用。

整条抽取链的数据流如下,实体先出,关系随后,最后收敛成三元组,溯源包装层横跨全程。

flowchart LR A["原始文本"] --> B["NERExtractor\n实体抽取"] B --> C["RelationExtractor\n关系抽取"] C --> D["TripletExtractor\n三元组与 RDF"] B --> E["EntityClassifier\n标签归一"] D --> F["TripletValidator\n结构与置信度"] C --> G["ProvenanceMixin\n来源追溯"] D --> G

命名实体识别:协调者与真正的实现分离

NamedEntityRecognizer 是模块对外的门面,但它自己不做抽取,只把参数整理好,交给内部 NERExtractor

semantica/semantic_extract/named_entity_recognizer.py57:115
57class NamedEntityRecognizer:58    """59    Named entity recognition handler.6061    • Extracts named entities from text62    • Classifies entities by type and category63    • Provides confidence scores for entities64    • Supports custom entity types65    • Handles multiple languages and domains66    • Processes batch text collections67    """68    def __init__(69        self,70        methods: Optional[List[str]] = None,71        confidence_threshold: float = 0.5,72        merge_overlapping: bool = True,73        include_standard_types: bool = True,74        method=None,75        config=None,76        **kwargs77    ):78        """79        Initialize named entity recognizer.8081        Args:82            methods: Extraction methods to use (e.g., ["spacy", "rule-based"])83            confidence_threshold: Minimum confidence score (0.0-1.0)84            merge_overlapping: Whether to merge overlapping entities85            include_standard_types: Include standard types (Person, Org, Location)86            method: Extraction method(s) - passed to NERExtractor (legacy)87            config: Legacy config dict (deprecated, use kwargs)88            **kwargs: Additional configuration options passed to NERExtractor89        """90        self.logger = get_logger("named_entity_recognizer")91        self.config = config or {}92        self.config.update(kwargs)93        self.progress_tracker = get_progress_tracker()94        # Ensure progress tracker is enabled95        if not self.progress_tracker.enabled:96            self.progress_tracker.enabled = True9798        # Store parameters99        self.methods = methods or ["spacy"]100        self.confidence_threshold = confidence_threshold101        self.merge_overlapping = merge_overlapping102        self.include_standard_types = include_standard_types103104        # Use NERExtractor for actual extraction105        ner_config = self.config.get("ner", {})106        ner_config["confidence_threshold"] = confidence_threshold107        ner_config["min_confidence"] = confidence_threshold108        ner_config["merge_overlapping"] = merge_overlapping109        if method is not None:110            ner_config["method"] = method111        elif methods:112            ner_config["method"] = methods[0] if len(methods) == 1 else methods113        self.ner_extractor = NERExtractor(**ner_config, **self.config)114        self.entity_classifier = EntityClassifier(**self.config.get("classifier", {}))115        self.confidence_scorer = EntityConfidenceScorer(**self.config.get("scorer", {}))

第 113 行把抽取委托给了 NERExtractor,第 114、115 行再挂上分类器和置信度打分器。这个分层的直接好处是:NERExtractor 负责「把实体从文本里捞出来」,NamedEntityRecognizer 负责「捞出来之后怎么分类、怎么打分」,两件事互不污染。注意第 99 行默认方法是 ["spacy"],而 NERExtractor 的默认方法是 "ml",两个默认值并不一致,这本身就是「协调者面向易用、底层面向能力」的一个信号。

分类器:把 spaCy 标签收敛到规范类型

EntityClassifier 是三个完整类里最直白的一个,它做的事只有一件:把不同来源的标签归一化到一套规范名。

semantica/semantic_extract/named_entity_recognizer.py191:277
191class EntityClassifier:192    """Entity classification engine."""193194    def __init__(self, **config):195        """Initialize entity classifier."""196        self.logger = get_logger("entity_classifier")197        self.config = config198199        # Entity type mappings200        self.type_hierarchy = {201            "PERSON": ["PERSON", "PER"],202            "ORG": ["ORG", "ORGANIZATION"],203            "GPE": ["GPE", "LOCATION", "LOC"],204            "DATE": ["DATE", "TIME"],205            "MONEY": ["MONEY", "CURRENCY"],206            "PERCENT": ["PERCENT", "PERCENTAGE"],207        }208209    def classify_entity_type(self, entity: Entity, **context) -> str:210        """211        Classify entity by type.212213        Args:214            entity: Entity to classify215            **context: Context information216217        Returns:218            str: Entity type219        """220        # Normalize entity type221        label = entity.label.upper()222223        # Check type hierarchy224        for canonical_type, variants in self.type_hierarchy.items():225            if label in variants:226                return canonical_type227228        return label229230    def disambiguate_entity(231        self, entity: Entity, candidates: List[Entity], **context232    ) -> Optional[Entity]:233        """234        Disambiguate entity among candidates.235236        Args:237            entity: Entity to disambiguate238            candidates: Candidate entities239            **context: Context information240241        Returns:242            Entity: Best matching entity or None243        """244        if not candidates:245            return None246247        # Simple disambiguation by type match248        entity_type = entity.label249        matching = [c for c in candidates if c.label == entity_type]250251        if matching:252            # Return first match with highest confidence253            return max(matching, key=lambda e: e.confidence)254255        return candidates[0] if candidates else None256257    def classify_entities(258        self, entities: List[Entity], **context259    ) -> Dict[str, List[Entity]]:260        """261        Classify entities by type.262263        Args:264            entities: List of entities265            **context: Context information266267        Returns:268            dict: Entities grouped by type269        """270        classified = {}271        for entity in entities:272            entity_type = self.classify_entity_type(entity, **context)273            if entity_type not in classified:274                classified[entity_type] = []275            classified[entity_type].append(entity)276277        return classified

type_hierarchyPER 收进 PERSON、把 LOC 收进 GPE。不同抽取方法吐出的标签五花八门,spaCy 用 PERSON,有些模型用 PER,LLM 可能直接说 LOCATION,这张表就是收口点。disambiguate_entity 是全书第 5 章「冲突检测与去重」的前置,它现在只做「同类型里取置信度最高」的极简消歧,留了接口但没上真算法。

NERExtractor 的回退链:一个方法失败就换下一个

真正干活的 NERExtractor.extract_entities 里,核心是这段按顺序试方法的循环。

semantica/semantic_extract/ner_extractor.py362:451
362            # Try each method in order (fallback chain)363            all_entities = []364            for method_name in methods:365                try:366                    self.progress_tracker.update_tracking(367                        tracking_id,368                        message=f"Extracting entities using {method_name}...",369                    )370                    method_func = get_entity_method(method_name)371372                    # Prepare method-specific options373                    method_options = all_options.copy()374                    if method_name == "huggingface":375                        # Prioritize runtime options over config/defaults376                        method_options["model"] = (377                            options.get("huggingface_model") 378                            or options.get("model") 379                            or self.huggingface_model380                        )381                        method_options["device"] = all_options.get("device")382                    elif method_name == "llm":383                        method_options["provider"] = all_options.get(384                            "provider", "openai"385                        )386                        method_options["model"] = all_options.get(387                            "llm_model", all_options.get("model")388                        )389                        # Ensure api_key is populated: check explicitly provided or fallback to env390                        current_key = method_options.get("api_key")391                        if not current_key:392                            # Not found or empty/None, try environment393                            import os394                            provider_name = method_options.get("provider", "openai")395                            env_key = f"{provider_name.upper()}_API_KEY"396                            api_key = os.getenv(env_key)397                            if api_key:398                                method_options["api_key"] = api_key399400                    entities = method_func(text, **method_options)401402                    # Apply weighted scoring if entity_types are provided403                    if entity_types:404                        try:405                            from .methods import calculate_weighted_confidence406                            for e in entities:407                                e.confidence = calculate_weighted_confidence(408                                    item_type=e.label,409                                    original_confidence=e.confidence,410                                    valid_types=entity_types,411                                    item_text=e.text412                                )413                        except ImportError:414                            pass415416                    # Filter by confidence417                    filtered = [e for e in entities if e.confidence >= min_confidence]418                    419                    if filtered:420                        all_entities.append((method_name, filtered))421422                        # If not using ensemble, return first successful result423                        if not self.ensemble_voting:424                            # Ensure default metadata425                            for e in filtered:426                                if e.metadata is None: e.metadata = {}427                                if "batch_index" not in e.metadata: e.metadata["batch_index"] = 0428429                            self.progress_tracker.stop_tracking(430                                tracking_id,431                                status="completed",432                                message=f"Extracted {len(filtered)} entities using {method_name}",433                            )434                            return filtered435436                except Exception as e:437                    self.logger.warning(438                        "Method %s failed: %s", method_name, e, exc_info=True439                    )440                    continue441442            # Ensemble voting if enabled443            if self.ensemble_voting and len(all_entities) > 1:444                entities = self._vote_entities(445                    [entities for _, entities in all_entities]446                )447            elif all_entities:448                entities = all_entities[0][1]  # Use first successful method449            else:450                # Fallback to pattern-based extraction if all models fail451                entities = self._extract_fallback(text)

这段循环的语义是:方法按列表顺序试,某个方法抛异常就 continue 试下一个;某个方法返回了「过滤后非空」的结果,且没有开 ensemble,就直接返回,不再往后试。只有全部失败,才落到 _extract_fallback。注意「非空」这个条件:如果一个方法成功调用了 LLM 但抽出了 0 个实体,它会被当成失败继续往下试,这是回退链「保证有结果」的关键。

回退链本身是一台状态机,每个方法只有「非空结果」和「失败或空」两条出路,走到底一定是兜底。

stateDiagram-v2 [*] --> 方法一 方法一 --> 返回结果 : 过滤后非空 方法一 --> 方法二 : 失败或空 方法二 --> 返回结果 : 过滤后非空 方法二 --> 兜底 : 失败或空 兜底 --> 返回结果 : 低置信兜底 返回结果 --> [*]

关系抽取:内置正则当兜底,LLM 当主力

RelationExtractor 的构造器里先备好一组内置的正则关系模式,llmdependency 等方法失败时靠它们兜底。

semantica/semantic_extract/relation_extractor.py81:158
81class RelationExtractor:82    """Relation extractor for entity relationships."""83    def __init__(84        self,85        method: Union[str, List[str]] = "pattern",86        relation_types: Optional[List[str]] = None,87        bidirectional: bool = False,88        confidence_threshold: float = 0.6,89        max_distance: int = 50,90        **config91    ):92        """93        Initialize relation extractor.9495        Args:96            method: Extraction method(s). Can be:97                - "pattern": Pattern-based extraction (default)98                - "regex": Regex-based extraction99                - "cooccurrence": Co-occurrence based100                - "dependency": Dependency parsing based101                - "huggingface": HuggingFace model102                - "llm": LLM-based extraction103                - List of methods for fallback chain104            relation_types: Specific relation types to extract (e.g., ["founded", "works_at"])105            bidirectional: Whether to extract bidirectional relations106            confidence_threshold: Minimum confidence score (0.0-1.0)107            max_distance: Maximum token distance between entities108            **config: Additional configuration options:109                - model: Model name (for dependency/HuggingFace methods)110                - huggingface_model: HuggingFace model name111                - provider: LLM provider (for LLM method)112                - llm_model: LLM model name113                - device: Device for HuggingFace models114                - validate: Enable validation (default: False)115        """116        self.logger = get_logger("relation_extractor")117        self.config = config118        self.progress_tracker = get_progress_tracker()119        # Ensure progress tracker is enabled120        if not self.progress_tracker.enabled:121            self.progress_tracker.enabled = True122123        # Store parameters124        self.relation_types = relation_types125        self.bidirectional = bidirectional126        self.confidence_threshold = confidence_threshold127        self.max_distance = max_distance128        self.verbose = config.get("verbose", False)129130        # Method configuration131        self.method = method if isinstance(method, list) else [method]132        self.min_confidence = config.get("min_confidence", confidence_threshold)133        self.validate = config.get("validate", False)134135        # Common relation patterns136        # Entity pattern allowing for dots and spaces (e.g., "Apple Inc.", "New York")137        ent_pat = r"[\w\.]+(?:\s+[\w\.]+)*"        # ... 空行略 ...139        self.relation_patterns = {140            "founded_by": [141                # Subject founded by Object142                fr"(?P<subject>[\w\.\s]+?)\s+(?:was\s+)?founded\s+by\s+(?P<object>{ent_pat})",143                # Object founded Subject144                fr"(?P<object>{ent_pat})\s+founded\s+(?P<subject>{ent_pat})",145            ],146            "located_in": [147                fr"(?P<subject>[\w\.\s]+?)\s+is\s+located\s+in\s+(?P<object>{ent_pat})",148                fr"(?P<subject>[\w\.\s]+?)\s+in\s+(?P<object>{ent_pat})",149            ],150            "works_for": [151                fr"(?P<subject>[\w\.\s]+?)\s+works?\s+for\s+(?P<object>{ent_pat})",152                fr"(?P<subject>[\w\.\s]+?)\s+is\s+an?\s+employee\s+of\s+(?P<object>{ent_pat})",153            ],154            "born_in": [155                fr"(?P<subject>[\w\.\s]+?)\s+was\s+born\s+in\s+(?P<object>{ent_pat})",156                fr"(?P<subject>[\w\.\s]+?)\s+born\s+in\s+(?P<object>{ent_pat})",157            ],158        }

四个内置关系类型 founded_bylocated_inworks_forborn_in,每个都给正反两种语序的正则。这套模式覆盖的是「显式动词短语」这类确定性最强的表达,置信度固定给 0.7,作为整条回退链的最后几层之一。

关系回退:正则也抽不出时,退到邻接

当所有方法都返回空,extract_relations 会先试内置正则,再退到「相邻实体直接连一条弱关系」。

semantica/semantic_extract/relation_extractor.py477:497
477            # Use first successful method or combine478            if all_relations:479                relations = all_relations[0][1]  # Use first successful method480            else:481                # Fallback to pattern-based extraction if all models fail482                relations = self._extract_with_patterns(text, entities)                # ... 空行略 ...484                # Last resort: if patterns also fail but we have entities, force some relations485                if not relations and entities and len(entities) >= 2:486                     relations = self._extract_last_resort_relations(text, entities)487488            # Validate if enabled489            if validate:490                relations = self.validate_relations(relations)491492            self.progress_tracker.stop_tracking(493                tracking_id,494                status="completed",495                message=f"Extracted {len(relations)} relations",496            )497            return relations

最后一道 _extract_last_resort_relations 把相邻实体两两连成 related_to,置信度压到 0.3,并在 metadata 里标 last_resort_adjacency。这个设计在「保证非空」和「不冒充高质量」之间划了线:宁可给一条明确标注为低置信的弱关系,也不给空列表让下游断链,同时低置信度让下游有机会把它滤掉。

三元组:关系兜底转 RDF,再兜底走规则

TripletExtractor 的回退更彻底:LLM 失败就由关系转三元组,关系也没有就上规则抽取。

semantica/semantic_extract/triplet_extractor.py527:568
527            # Use first successful method or fallback to relation conversion528            if all_triplets:529                triplets = all_triplets[0][1]530            else:531                # Fallback: Convert relations to triplets532                if relations:533                    self.progress_tracker.update_tracking(534                        tracking_id,535                        message=f"Converting {len(relations)} relations to triplets...",536                    )537                    triplets = []538                    for relation in relations:539                        triplet = Triplet(540                            subject=self._format_uri(relation.subject.text),541                            predicate=self._format_uri(relation.predicate),542                            object=self._format_uri(relation.object.text),543                            confidence=relation.confidence,544                            metadata={"context": relation.context, **relation.metadata},545                        )546                        triplets.append(triplet)547                else:548                    # Last resort: Try rule-based extraction if no relations exist549                    self.progress_tracker.update_tracking(550                        tracking_id,551                        message="No relations found. Trying rule-based triplet extraction...",552                    )553                    method_func = get_triplet_method("rules")554                    triplets = method_func(text, entities=entities, relations=[], **all_options)555556            # Validate triplets557            if options.get("validate", self._should_validate):558                self.progress_tracker.update_tracking(559                    tracking_id, message="Validating triplets..."560                )561                triplets = self.triplet_validator.validate_triplets(triplets)562563            self.progress_tracker.stop_tracking(564                tracking_id,565                status="completed",566                message=f"Extracted {len(triplets)} triplets",567            )568            return triplets

关系转三元组时,_format_uri 把实体文本变成 URI(非 http 开头的统一拼成 http://example.org/...),三元组的 subject、object 是字符串,正好承接这个序列化准备。之后无论走哪条路,都统一过一遍 triplet_validator.validate_triplets

三元组校验器:结构 + 置信度两道门槛

semantica/semantic_extract/triplet_extractor.py691:732
691class TripletValidator:692    """Triplet validation engine."""693694    def __init__(self, **config):695        """Initialize triplet validator."""696        self.logger = get_logger("triplet_validator")697        self.config = config698699    def validate_triplet(self, triplet: Triplet, **criteria) -> bool:700        """701        Validate individual triplet.702703        Args:704            triplet: Triplet to validate705            **criteria: Validation criteria706707        Returns:708            bool: True if valid709        """710        # Check structure711        if not triplet.subject or not triplet.predicate or not triplet.object:712            return False713714        # Check confidence715        min_confidence = criteria.get("min_confidence", 0.5)716        if triplet.confidence < min_confidence:717            return False718719        return True720721    def validate_triplets(self, triplets: List[Triplet], **criteria) -> List[Triplet]:722        """723        Validate list of triplets.724725        Args:726            triplets: List of triplets727            **criteria: Validation criteria728729        Returns:730            list: Valid triplets731        """732        return [t for t in triplets if self.validate_triplet(t, **criteria)]

校验器只查两件事:三元组的三个位置是否都非空,置信度是否达到阈值。它不查事实真假,那超出了抽取层的能力范围。这个边界在官方文档里被反复强调,后面设计决策一节会回到这一点。

类型化输出:把「格式漂移」压进 Pydantic schema

LLM 抽取路径的「可信」来自一个机制:不是让模型自由吐 JSON 再猜,而是让模型对着一个 Pydantic schema 生成,生成后由 schema 兜底校验。

semantica/semantic_extract/schemas.py4:47
4class EntityOut(BaseModel):5    """Canonical schema for entity extraction output."""6    model_config = ConfigDict(populate_by_name=True, extra="ignore")    # ... 空行略 ...·    text: str = Field(..., description="The text content of the entity")·    label: str = Field(..., description="The type or label of the entity (e.g., PERSON, ORG)")·    start: int = Field(0, description="Start character index", alias="start_char")·    end: int = Field(0, description="End character index", alias="end_char")·    confidence: float = Field(0.9, description="Confidence score between 0 and 1")·    metadata: dict = Field(default_factory=dict, description="Additional metadata including provenance")··    @field_validator("text", mode="before")·    @classmethod·    def clean_text(cls, v):·        if isinstance(v, str):·            return v.strip()·        return str(v)··    @field_validator("confidence", mode="before")·    @classmethod·    def normalize_confidence(cls, v):·        if isinstance(v, str):·            try:·                v = float(v)·            except ValueError:·                return 0.9·        if isinstance(v, (int, float)):·            return max(0.0, min(1.0, float(v)))·        return 0.9    # ... 空行略 ...34    @model_validator(mode="before")35    @classmethod36    def handle_aliases(cls, data):37        if isinstance(data, dict):38            # Handle 'type' as alias for 'label'39            if "label" not in data and "type" in data:40                data["label"] = data["type"]41            # Handle 'value' or 'span' as alias for 'text'42            if "text" not in data:43                if "value" in data:44                    data["text"] = data["value"]45                elif "span" in data:46                    data["text"] = data["span"]47        return data

handle_aliases 这一段是「对模型宽容、对下游严格」的典型:模型说 type 就映射到 label,说 valuespan 就映射到 text,但通过 schema 之后,下游永远拿到规范字段。normalize_confidence 把模型报出来的字符串或越界数值夹回 0 到 1 区间。extra="ignore" 则把模型多吐的字段直接丢掉,不让垃圾字段污染下游。

类型化生成在运行时是一次「抽取器到后端到模型」的往返,schema 在整个往返里充当校验闸。

sequenceDiagram participant E as 抽取器 participant P as BaseProvider participant I as instructor participant M as LLM E->>P: generate_typed(prompt, schema) P->>I: chat.completions.create I->>M: 请求带 response_model M-->>I: 结构化回复 I-->>P: 校验后的 schema 对象 P-->>E: EntitiesResponse E->>E: 转成内部 Entity

缓存:同一段文本不重复付费

LLM 抽取按 token 计费,同一段文本被重复抽取就是重复花钱。缓存层用文本加参数做稳定哈希键。

semantica/semantic_extract/cache.py72:91
72    def _generate_key(self, text: str, **params) -> str:73        """74        Generate a stable cache key based on text and parameters.75        76        Note: Sensitive parameters like 'api_key' are excluded from the cache key77        to prevent security risks and ensure cache sharing where appropriate.78        """79        # Filter out sensitive keys80        sensitive_keys = {'api_key', 'token', 'password', 'secret', 'auth', 'authorization'}81        filtered_params = {k: v for k, v in params.items() if k.lower() not in sensitive_keys}8283        # Create a stable string representation of params84        # Sort keys to ensure consistent ordering85        param_str = json.dumps(filtered_params, sort_keys=True, default=str)        # ... 空行略 ...·        # Combine text and params·        content = f"{text}|{param_str}"        # ... 空行略 ...90        # Return hash (SHA-256 for better security than MD5)91        return hashlib.sha256(content.encode('utf-8')).hexdigest()

缓存键刻意排除了 api_keytoken 等敏感参数,注释写明这是「防止安全风险、并在适当处共享缓存」。同一个文本、同一种参数组合,换个 API key 不该命中不同缓存,因为结果是同一个模型算出来的。缓存有 TTL 和 LRU 上限,entitiesrelationstriplets 三个命名空间各自独立。

溯源包装:不改原类,只加一层

semantic_extract_provenance.py 里的 ProvenanceMixin 是全书溯源暗线在抽取层的落点。它用一个 mixin 给任意抽取器附加溯源能力,不侵入原类。

semantica/semantic_extract/semantic_extract_provenance.py44:111
44class ProvenanceMixin:45    """46    Mixin to add provenance tracking to any extractor class.47    48    This mixin provides the common provenance infrastructure that can be49    added to any extraction class without modifying its core functionality.50    """    # ... 空行略 ...52    def __init__(53        self,54        provenance: bool = False,55        agent_id: Optional[str] = None,56        is_automated: bool = True,57        **kwargs,58    ):59        """60        Initialize provenance tracking.6162        Args:63            provenance: Enable provenance tracking (default: False)64            agent_id: Agent identifier for accountability (issue #825); defaults65                to the wrapping class name66            is_automated: Whether this agent acted without direct human review67            **kwargs: Additional arguments passed to parent class68        """69        self.provenance = provenance70        self._prov_manager = None71        self._agent_id = agent_id or self.__class__.__name__72        self._is_automated = is_automated7374        if provenance:75            try:76                from semantica.provenance import ProvenanceManager77                self._prov_manager = ProvenanceManager()78            except ImportError:79                # Graceful degradation if provenance module not available80                self.provenance = False8182    def _track_extraction(83        self,84        entity_id: str,85        source: str,86        entity_type: str,87        **metadata88    ) -> None:89        """90        Track extraction with provenance.9192        Args:93            entity_id: Unique identifier for extracted entity94            source: Source document or text95            entity_type: Type of entity (e.g., 'named_entity', 'relation')96            **metadata: Additional metadata to track97        """98        if self.provenance and self._prov_manager:99            activity_started_at_time = metadata.pop("activity_started_at_time", None)100            activity_ended_at_time = metadata.pop("activity_ended_at_time", None)101            self._prov_manager.track_entity(102                entity_id=entity_id,103                source=source,104                entity_type=entity_type,105                agent_id=self._agent_id,106                agent_type="software_agent",107                is_automated=self._is_automated,108                activity_started_at_time=activity_started_at_time,109                activity_ended_at_time=activity_ended_at_time,110                metadata=metadata111            )

两个设计点值得停一下。第一,provenance=True 时才尝试 import ProvenanceManager,import 失败就把 provenance 置回 False,溯源是可选的、降级是优雅的。第二,agent_type 硬编码为 "software_agent"is_automated 默认 True,这写明了「这些抽取是机器自动做的、没有人工复核」,溯源链上能区分人工编辑和自动抽取。

加权置信度:让模型的分服从你的业务类型

场景还原里的第二个问题「置信度是模型自报的」,在 calculate_weighted_confidence 里被改写成「模型分和业务类型匹配度各占一半」。

semantica/semantic_extract/methods.py550:597
550def calculate_weighted_confidence(551    item_type: str, 552    original_confidence: float, 553    valid_types: Optional[List[str]] = None,554    item_text: Optional[str] = None,555    weight_method: float = 0.5,556    weight_similarity: float = 0.5557) -> float:558    """559    Calculate weighted confidence score using both Label and Content similarity.560    Final Score = (weight_method * original_confidence) + (weight_similarity * max(label_sim, content_sim))561    562    Args:563        item_type: The extracted type/label/predicate (e.g., "PERSON", "founded_by")564        original_confidence: The confidence score from the extraction method (0.0-1.0)565        valid_types: List of valid/preferred types provided by user566        item_text: The actual text content extracted (e.g., "Steve Jobs", "acquired")567        weight_method: Weight for the original method confidence (default 0.5)568        weight_similarity: Weight for the similarity score (default 0.5)569        570    Returns:571        float: Weighted confidence score (0.0-1.0)572    """573    if not valid_types:574        return original_confidence575        576    # Similarity 1: Label vs Valid Types (e.g., "PERSON" vs "Artist")577    label_similarity = calculate_similarity(item_type, valid_types)578    579    # Similarity 2: Content vs Valid Types (e.g., "Picasso" vs "Artist")580    content_similarity = 0.0581    if item_text:582        content_similarity = calculate_similarity(item_text, valid_types)583        584    # Take the best similarity match585    best_similarity = max(label_similarity, content_similarity)586    587    # Normalize weights588    total_weight = weight_method + weight_similarity589    if total_weight <= 0:590        return original_confidence591        592    w_m = weight_method / total_weight593    w_s = weight_similarity / total_weight594    595    final_score = (w_m * original_confidence) + (w_s * best_similarity)596    597    return max(0.0, min(1.0, final_score))

函数签名里两个权重默认都是 0.5。当用户没传 valid_types 时直接返回原始分,传了就把「模型的原始置信度」和「类型名或实体文本与用户目标类型的相似度」加权平均。相似度匹配是精确、同义词、子串、语义嵌入四级递进,落在 calculate_similarity 里。这套机制让置信度从「模型说多少是多少」变成「模型说多少、加上你对业务类型的约束再折一下」。

解析兜底:匹配不上的实体不静默丢弃

关系抽取的 LLM 输出里,subject 和 object 是模型自己写的字符串,未必能对回预先抽取的实体表。_parse_relation_result 里有一个针对这个间隙的兜底:匹配不上的端点造一个「合成实体」。

semantica/semantic_extract/methods.py2083:2099
2083        # Find matching entities using hybrid similarity; fall back to a2084        # synthetic entity so multi-value results are never silently dropped.2085        subject_entity = match_entity(subject_text, entities)2086        object_entity = match_entity(object_text, entities)20872088        if not subject_entity:2089            subject_entity = Entity(2090                text=subject_text, label="UNKNOWN",2091                start_char=0, end_char=len(subject_text),2092                confidence=0.8, metadata={"synthetic": True},2093            )2094        if not object_entity:2095            object_entity = Entity(2096                text=object_text, label="UNKNOWN",2097                start_char=0, end_char=len(object_text),2098                confidence=0.8, metadata={"synthetic": True},2099            )

合成实体的标签是 UNKNOWN、metadata 里打 synthetic: True 标记,字符偏移填 0 到文本长度,明示「这个端点不是从原文对齐出来的」。注释里写清了动机:这样多值结果不会静默丢失。宁可让一条关系带着一个明示未知的端点入库,也不把整条关系丢掉,因为丢掉意味着下游连「这里有个东西没对齐」都不知道。

LLM 抽取提示词:两处硬约束

LLM 路径的可信还体现在提示词本身。实体抽取提示词要求平铺列表、只抽给定文本、不照抄示例。

semantica/semantic_extract/methods.py1040:1063
1040        prompt = f"""Extract named entities from the provided text.1041Return the result as a JSON object with an "entities" key containing the list of entities.1042Each entity should have 'text', 'label', and 'confidence' fields.10431044IMPORTANT: 1045- Return a FLAT LIST of entities. 1046- DO NOT group entities by type.1047- The output structure must exactly match: {{ "entities": [ {{ "text": "...", "label": "...", "confidence": ... }}, ... ] }}10481049Example output (JSON format only):1050{{1051  "entities": [1052    {{"text": "Entity Name", "label": "CATEGORY", "confidence": 0.95}},1053    {{"text": "Another Entity", "label": "OTHER_CATEGORY", "confidence": 0.90}}1054  ]1055}}10561057Instructions:10581. Extract entities ONLY from the text provided below.10592. Do not include any entities from the example above.10603. {entity_types_instruction}10611062Text to extract from:1063{text}"""

提示词里 DO NOT group entities by typeExtract entities ONLY from the textDo not include any entities from the example 是三条针对幻觉的硬约束。关系抽取在开了 extract_temporal_bounds 时,还会追加一份时间置信度的校准表。

semantica/semantic_extract/methods.py1879:1893
1879TEMPORAL EXTRACTION RULES:1880- valid_from: ISO 8601 date or exact phrase from the text for when this relation became valid. Set to null if no temporal signal is present.1881- valid_until: ISO 8601 date or exact phrase for when this relation ceased. Set to null if open-ended or absent.1882- temporal_confidence (float 0.0–1.0) — calibrated as follows:1883    1.00 = full ISO date ("2022-03-15", "March 15, 2022")1884    0.90 = explicit year + month ("March 2022", "2022-03")1885    0.85 = explicit year only ("in 2022", "since 2021", "from 2019")1886    0.75 = quarter ("Q3 2023", "Q2 2021")1887    0.65 = named season or approximate range ("summer 2022", "early 2020s", "mid-2022")1888    0.50 = vague relative with computable anchor ("last year", "three months ago")1889    0.35 = highly vague relative ("recently", "years ago", "in the past")1890    0.00 = no temporal signal present for this relation1891- temporal_source_text: the EXACT verbatim substring from the source text that contains the temporal signal. Set to null when temporal_confidence is 0.0.18921893IMPORTANT: Do NOT invent or guess dates. If the text contains no temporal signal for a relation, set valid_from and valid_until to null and temporal_confidence to 0.0.

这份校准表是「可解释性」的活样本:置信度不再是一个黑箱数字,而是每档都有对应的时间表达强度,temporal_source_text 还强制模型回填原文子串,让「这个时间哪来的」可查。Do NOT invent or guess dates 直接封死编造日期这条路。

设计决策分析

抽取层可以写成一个 1500 行的大类,把所有方法、所有后端、所有兜底塞进去。Semantica 没有这么做,它把「抽取器、方法、后端、溯源」四层拆开,每一层一个可替换点。官方文档把这条链画成「entities → relationships → triplets」的流水线,参考文档 docs/reference/semantic_extract.md 的开头把三个抽取器与三种模式一次列清。

docs/reference/semantic_extract.md7:13
7`semantica.semantic_extract` extracts structured information from unstructured text: the foundation of every knowledge graph in Semantica:89- `NERExtractor`: named entity recognition with confidence scores and source attribution10- `RelationExtractor`: typed relationship extraction (`founded_by`, `located_in`, and custom types)11- `TripletExtractor`: direct `(subject, predicate, object)` triplet generation for RDF output12- `EventDetector`: event detection with participants, temporal context, and confidence13- Three extraction modes on every extractor: `"pattern"` (no API key), `"huggingface"`, `"llm"`

第一层拆「协调者」与「实现」:NamedEntityRecognizer 面向易用默认值,NERExtractor 面向能力清单。第二层拆「方法」与「后端」:方法用字符串 "llm""pattern" 声明,get_entity_method 把字符串映射到函数,函数内部再调 create_provider 拿后端。这个设计让新增一个抽取方法只需注册一个函数,新增一个 LLM 后端只需实现 BaseProvidergenerategenerate_typed,抽取器本体一行不改。文档 docs/reference/semantic_extract.md 的「Method Fallback Chains」一节把回退链写成公开承诺:methods=["llm", "pattern"] 保证「第一个方法先试,失败自动往下走」。

第三层是 ProvenanceMixin 包装模式。溯源如果写进每个抽取器的每个方法,代码会变成「抽实体顺便记一条溯源」的耦合泥潭。mixin 的方式把溯源做成一个可叠加的壳:原类零改动,NERExtractorWithProvenance 包装原类、在 extract 返回前给每个实体补 id 并调 _track_extraction。第 148 到 188 行的 extract 方法记录的是「活动开始和结束时间」,这两个时间戳跨过了真正的抽取调用,溯源链能还原「这一次抽取花了多久、在什么时间窗内发生」。

第四层是缓存。抽取结果缓存不是简单的 memoization,它把「文本 + 参数」哈希成键,且刻意剔除 api_key。这个细节的推理在缓存代码注释里写得很直白:排除敏感键是为了「防止安全风险、并在适当处共享缓存」。同一段文本、同一个模型、同一套类型约束,无论谁拿哪个 key 调用,都该命中同一份结果,缓存才有复用价值。

这套设计做对了一件事:把「可信」落成机械约束,不落成提示词请求。类型化输出用 schema 校验,回退链用代码兜底,缓存用哈希键,溯源用包装层,四件事都不依赖模型配合。官方指南 docs/guides/semantic-extraction.md 在「Common Pitfalls」一节把这一点说得很重:第一条就是「别把抽取当成保证的真相」,置信度存在的用途是让下游凭它过滤取舍。

docs/guides/semantic-extraction.md638:646
638**Treating extraction as guaranteed truth.** Semantic extraction produces confidence scores for a reason — even high-confidence extractions can be incorrect. Always validate critical extractions, especially for high-stakes decisions in security, clinical, or financial contexts.639640**Ignoring confidence thresholds.** Low-confidence extractions often indicate ambiguous text, poor model fit, or noisy input. Setting appropriate thresholds (typically 0.65-0.85) filters unreliable results before they pollute downstream processing.641642**Skipping entity resolution.** Different mentions of the same entity ("NATO", "North Atlantic Treaty Organization", "the alliance") will create duplicate nodes in your knowledge graph. Always run coreference resolution and entity deduplication.643644**Poor OCR or poor input quality.** Semantic extraction depends on readable text. Documents with OCR errors, encoding issues, or heavy redaction will produce unreliable extractions. Clean and validate input text before extraction.645646**Using LLM extraction where regex is sufficient.** For highly structured patterns like CVE identifiers (CVE-YYYY-NNNN), IP addresses, email addresses, or UUIDs, regular expressions are faster, cheaper, and more reliable than semantic extraction.

边界条件剖析

如果 LLM 返回了 0 个关系,会怎样。 关系抽取的循环里,filtered 非空才会被 append 进 all_relations,所以 LLM 返回空列表等于「这个方法和失败等价」。最终落到 extract_relations 第 481 行 relations = self._extract_with_patterns(text, entities),正则也抽不出时第 485 行再判一次 if not relations and entities and len(entities) >= 2,满足就进 _extract_last_resort_relations,给相邻实体连 related_to、置信度 0.3。也就是说「LLM 没抽出来」永远到不了空结果,代价是最坏情况拿到一批明确标注低置信的弱关系。

如果文本超长,会怎样。 extract_entities_llmextract_relations_llmextract_triplets_llm 三处都有长度检查,超过各 provider 的默认上限就转分块。分块用 TextSplitter(method="recursive", chunk_overlap=10%),块结果再按 chunk.start_index 把实体的字符偏移加回去。如果 LLM 报长度错误,代码把 max_text_length 折半再试一次,直到 new_max > 100 这个最小块大小才停止。这条路径保证「超长文本」要么分块抽完,要么明确失败,中间态不存在。

如果文本里的实体超过 80 个,会怎样。 extract_relations_llm 内部把提示词里的实体数硬封在 80,超了就调 filter_entities_for_text,按「实体是否出现在文本里、是否有非停用词 token 命中」排序,取前 80 个。这个上限写死为 max_entities_prompt = 80 且注释注明「不接受 kwargs 覆盖」。为什么是 80:关系提示词要把实体清单塞进上下文,实体太多既挤占 token 又稀释模型注意力,宁可少喂也要喂准。

如果 api_key 没传,会怎样。 三个 LLM 方法在调 provider 前都有一段兜底:先查显式传入的 api_key,查不到就按 f"{provider.upper()}_API_KEY" 拼环境变量名去 os.getenv,再查不到就删掉空 key 避免 provider 报错。这条路径落在 ner_extractor.py 第 389 到 396 行附近,以及 methods.py 各 LLM 函数的「provider 校验」段。结论是:密钥要么显式给、要么靠环境变量,两者都没有时 provider 报「not available」,走回退链或抛异常,带空 key 硬发请求这条路被代码挡掉了。

横向对比

对比对象是 GraphRAG 的实体关系抽取,路径在 packages/graphrag/graphrag/index/operations/extract_graph/。两边都要「让 LLM 从文本抽实体和关系」,但投入的形态完全不同:GraphRAG 把几乎全部投入押在提示词上,Semantica 把投入拆到提示词、schema、回退链、缓存、溯源五处。

GraphRAG 的 GraphExtractor 用一个提示词同时抽实体和关系,输出是带分隔符的纯文本表格,再用 splitre.sub 手工解析。

packages/graphrag/graphrag/index/operations/extract_graph/graph_extractor.py85:122
85    async def _process_document(self, text: str, entity_types: list[str]) -> str:86        messages_builder = CompletionMessagesBuilder().add_user_message(87            self._extraction_prompt.format(**{88                INPUT_TEXT_KEY: text,89                ENTITY_TYPES_KEY: ",".join(entity_types),90            })91        )9293        response: LLMCompletionResponse = await self._model.completion_async(94            messages=messages_builder.build(),95        )  # type: ignore96        results = response.content97        messages_builder.add_assistant_message(results)9899        # if gleanings are specified, enter a loop to extract more entities100        # there are two exit criteria: (a) we hit the configured max, (b) the model says there are no more entities101        if self._max_gleanings > 0:102            for i in range(self._max_gleanings):103                messages_builder.add_user_message(CONTINUE_PROMPT)104                response: LLMCompletionResponse = await self._model.completion_async(105                    messages=messages_builder.build(),106                )  # type: ignore107                response_text = response.content108                messages_builder.add_assistant_message(response_text)109                results += response_text110111                # if this is the final glean, don't bother updating the continuation flag112                if i >= self._max_gleanings - 1:113                    break114115                messages_builder.add_user_message(LOOP_PROMPT)116                response: LLMCompletionResponse = await self._model.completion_async(117                    messages=messages_builder.build(),118                )  # type: ignore119                if response.content != "Y":120                    break121122        return results

它解决「抽不全」靠的是 gleanings 循环:抽出第一批后追加 CONTINUE_PROMPT 让模型补漏,再用 LOOP_PROMPT 问模型「还有没有」,模型答 N 才停。

packages/graphrag/graphrag/prompts/index/extract_graph.py128:129
128CONTINUE_PROMPT = "MANY entities and relationships were missed in the last extraction. Remember to ONLY emit entities that match any of the previously extracted types. Add them below using the same format:\n"129LOOP_PROMPT = "It appears some entities and relationships may have still been missed. Answer Y if there are still entities or relationships that need to be added, or N if there are none. Please answer with a single letter Y or N.\n"

解析层是纯字符串切分。

packages/graphrag/graphrag/index/operations/extract_graph/graph_extractor.py124:178
124    def _process_result(125        self,126        result: str,127        source_id: str,128        tuple_delimiter: str,129        record_delimiter: str,130    ) -> tuple[pd.DataFrame, pd.DataFrame]:131        """Parse the result string into entity and relationship data frames."""132        entities: list[dict[str, Any]] = []133        relationships: list[dict[str, Any]] = []134135        records = [r.strip() for r in result.split(record_delimiter)]136137        for raw_record in records:138            record = re.sub(r"^\(|\)$", "", raw_record.strip())139            if not record or record == COMPLETION_DELIMITER:140                continue141142            record_attributes = record.split(tuple_delimiter)143            record_type = record_attributes[0]144145            if record_type == '"entity"' and len(record_attributes) >= 4:146                entity_name = clean_str(record_attributes[1].upper())147                entity_type = clean_str(record_attributes[2].upper())148                entity_description = clean_str(record_attributes[3])149                entities.append({150                    "title": entity_name,151                    "type": entity_type,152                    "description": entity_description,153                    "source_id": source_id,154                })155156            if record_type == '"relationship"' and len(record_attributes) >= 5:157                source = clean_str(record_attributes[1].upper())158                target = clean_str(record_attributes[2].upper())159                edge_description = clean_str(record_attributes[3])160                try:161                    weight = float(record_attributes[-1])162                except ValueError:163                    weight = 1.0164165                relationships.append({166                    "source": source,167                    "target": target,168                    "description": edge_description,169                    "source_id": source_id,170                    "weight": weight,171                })172173        entities_df = pd.DataFrame(entities) if entities else _empty_entities_df()174        relationships_df = (175            pd.DataFrame(relationships) if relationships else _empty_relationships_df()176        )177178        return entities_df, relationships_df

两边的差异可以归纳成一句:GraphRAG 用「提示词 + 分隔符协议」换「代码简单」,Semantica 用「类型化输出 + 回退链 + 溯源」换「结果可靠」。GraphRAG 一侧没有 EntityOut 这样的 Pydantic schema,没有置信度加权的 calculate_weighted_confidence,也没有 semantic_extract_provenance.py 这种溯源包装层。它之所以可以没有,是因为它的下游紧接着有一步独立的描述摘要(summarize_descriptions),而且它的语料是单源文档、实体类型由配置统一下发,不存在「多来源打架」和「每句话指回来源」的强需求。检索关键词 pydanticprovenanceconfidencefallback 在 GraphRAG 的 extract_graph 目录下均无对应实现;它把「漏抽」交给 gleanings 循环,把「描述合并」交给 pandas 的 groupby

互动演示设计

形态是决策沙盘。读者扮演一个抽取流水线的设计者,面对同一段合同文本,在「格式漂移」「LLM 空结果」「长文本」「无 API key」四个岔口做选择,沙盘把每个选择翻译成 Semantica 里对应的代码路径。

一句话结论:抽取结果可信,是把四个岔口都接上兜底代码,让任何一条路都走不到「脏数据静默入库」。

舞台元素与比喻:沙盘是一条四岔流水线,每个岔口一个闸门。闸门 A「格式闸」是 Pydantic schema,闸门 B「空结果闸」是回退链,闸门 C「长度闸」是分块,闸门 D「密钥闸」是环境变量兜底。流过闸门的数据最后都盖上「来源戳」,就是溯源包装层。

分步动画与字幕

第一步,文本进入,格式闸先亮。字幕:「LLM 输出被 generate_typed 对着 schema 校验,type 被映射成 label,越界置信度被夹回 0 到 1。」读者操作项:把 schemas.py 里的 handle_aliases 改掉一条映射,观察下游字段变化。

第二步,空结果闸。字幕:「LLM 抽到 0 个关系时,extract_relations 先走正则,再走邻接兜底,最后给 related_to、置信度 0.3。」读者操作项:把 _extract_last_resort_relations 里的 0.3 改成 0.1,看哪些下游过滤器会把它滤掉。

第三步,长度闸。字幕:「文本超过 64000 字符触发 TextSplitter 分块,实体偏移按 chunk.start_index 加回。」读者操作项:传一段超长文本,观察 _extract_entities_chunked 的日志。

第四步,密钥闸。字幕:「显式 api_key 缺失时,代码按 OPENAI_API_KEY 这类环境变量名兜底,再没有就删空 key 避免报错。」读者操作项:清空环境变量后调用,观察 provider 报「not available」走回退链。

逻辑轨迹面板伪代码(右侧标真实行号):

text
if not text or not entities:            # relation_extractor.py 第 396 行附近
    return []
for method_name in methods:             # 第 405 行附近
    relations = method_func(...)        # 第 451 行附近
    filtered = [r for r in relations if r.confidence >= min_confidence]  # 第 463 行附近
    if filtered:
        return filtered                 # 第 471 行附近
relations = self._extract_with_patterns(text, entities)  # 第 482 行
if not relations and entities and len(entities) >= 2:    # 第 485 行
    relations = self._extract_last_resort_relations(...)  # 第 486 行

可迁移结论

值得抄的第一样是「类型化输出 + alias 宽容层」。让你的 LLM 输出对着一个显式 schema 生成,生成后再用一个「别名映射 + 数值夹取」的宽容层收口,下游就永远拿规范字段。这条不依赖 Python:任何语言里定义一个 schema 结构、写一个 type→label 的映射函数,都能达到同样效果。

第二样是「回退链 + 最低置信兜底」。把方法排成优先级列表,非空才算成功,全失败就给明确标注低置信的兜底结果。关键不是「兜底结果有多准」,而是「兜底结果必须自我标识为低置信」,让下游有机会过滤。最小成本形态:一个 try: return primary() except: return fallback() 的函数,加一个 confidence=0.3 的元数据标记,三行就够。

第三样是「缓存键剔除敏感参数」。把 api_key 这类身份信息从缓存键里剥掉,缓存才有跨调用复用价值。这一条的迁移成本接近零,但很容易漏:很多团队第一次写 LLM 缓存时把整个 kwargs 序列化进键,导致同一个请求换把 key 就缓存穿透。

哪些是过度设计。calculate_weighted_confidence 的「同义词、子串、语义嵌入」四级相似度匹配,对只有少量固定关系类型的场景是过度设计,简单字符串相等就够。NamedEntityRecognizerEntityConfidenceScorer._calculate_confidence 里对文本长度、首字母大小写、日期数字做的一堆启发式加减分,与 LLM 路径的置信度并不共享同一套刻度,混用会得到不可比的数字,这套打分器在小项目里可以直接删掉。

思考题

  1. 为什么 Relation 的 subject 和 object 存 Entity 对象,而 Triplet 的三个位置存字符串?这个差别在「关系转三元组」那一步(triplet_extractor.py 第 540 到 543 行)是如何被弥合的?

  2. calculate_weighted_confidencevalid_types 为空时直接返回原始分。如果你的业务类型约束是「必须且只能是这 5 种关系」,而模型抽出了第 6 种关系,当前代码会把它过滤掉还是保留?结合 extract_relations 的过滤条件(r.confidence >= min_confidence)说明。

  3. 动手验证:把 semantica/semantic_extract/relation_extractor.py_extract_last_resort_relationsconfidence=0.3 改成 0.05,然后在 semantica/semantic_extract/triplet_extractor.py 里构造一个只传实体、不传关系、method 用 "pattern"TripletExtractor 调用,观察最终三元组是否被 TripletValidator 的默认阈值 0.5 过滤掉,并解释你在哪个文件的哪一行看到了这个结果。