refactor: 移除 spaCy 图谱抽取器,前端抽取方案收敛为仅 LLM
- 删除 backend/package/yuxi/knowledge/graphs/extractors/spacy.py - 清理 __init__.py 和 factory.py 中的 spacy 引用 - KnowledgeGraphSection.vue 抽取器类型仅保留 LLM + "更多拓展中"占位 - 补充测试:factory 仅支持 llm、reject spacy、service configure 拒绝 spacy - 更新 docs/intro/knowledge-base.md 文档描述
This commit is contained in:
parent
e7914b1dee
commit
5b1fd51b27
@ -1,12 +1,10 @@
|
||||
from .base import GraphExtractor, normalize_extraction_result
|
||||
from .factory import GraphExtractorFactory
|
||||
from .llm import LLMGraphExtractor
|
||||
from .spacy import SpacyGraphExtractor
|
||||
|
||||
__all__ = [
|
||||
"GraphExtractor",
|
||||
"GraphExtractorFactory",
|
||||
"LLMGraphExtractor",
|
||||
"SpacyGraphExtractor",
|
||||
"normalize_extraction_result",
|
||||
]
|
||||
|
||||
@ -4,13 +4,11 @@ from typing import Any
|
||||
|
||||
from .base import GraphExtractor
|
||||
from .llm import LLMGraphExtractor
|
||||
from .spacy import SpacyGraphExtractor
|
||||
|
||||
|
||||
class GraphExtractorFactory:
|
||||
_registry: dict[str, type[GraphExtractor]] = {
|
||||
"llm": LLMGraphExtractor,
|
||||
"spacy": SpacyGraphExtractor,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@ -1,64 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from .base import GraphExtractor
|
||||
|
||||
|
||||
_spacy_models: dict[str, Any] = {}
|
||||
_spacy_model_lock = threading.Lock()
|
||||
|
||||
|
||||
def _load_spacy_model(model_name: str) -> Any:
|
||||
cached = _spacy_models.get(model_name)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
import spacy
|
||||
except ImportError as exc:
|
||||
raise ValueError("spaCy 未安装,无法使用 spacy 抽取器") from exc
|
||||
|
||||
with _spacy_model_lock:
|
||||
cached = _spacy_models.get(model_name)
|
||||
if cached is not None:
|
||||
return cached
|
||||
model = spacy.load(model_name)
|
||||
_spacy_models[model_name] = model
|
||||
return model
|
||||
|
||||
|
||||
class SpacyGraphExtractor(GraphExtractor):
|
||||
extractor_type = "spacy"
|
||||
|
||||
def _model_name(self) -> str:
|
||||
return str(self.options.get("model") or self.options.get("model_name") or "").strip()
|
||||
|
||||
def validate_options(self) -> None:
|
||||
if not self._model_name():
|
||||
raise ValueError("spaCy 抽取器需要 model 或 model_name")
|
||||
|
||||
async def extract(self, text: str, *, chunk_metadata: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
self.validate_options()
|
||||
model_name = self._model_name()
|
||||
allowed_labels = set(self.options.get("entity_labels") or [])
|
||||
doc = _load_spacy_model(model_name)(text)
|
||||
entities = []
|
||||
seen = set()
|
||||
for ent in doc.ents:
|
||||
entity_text = ent.text.strip()
|
||||
if not entity_text or entity_text in seen:
|
||||
continue
|
||||
if allowed_labels and ent.label_ not in allowed_labels:
|
||||
continue
|
||||
seen.add(entity_text)
|
||||
entities.append(
|
||||
{
|
||||
"text": entity_text,
|
||||
"label": ent.label_ or "Entity",
|
||||
"attributes": [],
|
||||
}
|
||||
)
|
||||
|
||||
return {"entities": entities, "relations": [], "metadata": {"model": model_name}}
|
||||
@ -5,7 +5,11 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.knowledge.graphs.extractors import LLMGraphExtractor, normalize_extraction_result
|
||||
from yuxi.knowledge.graphs.extractors import (
|
||||
GraphExtractorFactory,
|
||||
LLMGraphExtractor,
|
||||
normalize_extraction_result,
|
||||
)
|
||||
from yuxi.knowledge.graphs.milvus_graph_service import MilvusGraphService
|
||||
|
||||
|
||||
@ -88,6 +92,37 @@ def test_llm_graph_extractor_appends_schema_to_fixed_prompt():
|
||||
assert "文本:\n张三任职于公司" in prompt
|
||||
|
||||
|
||||
def test_graph_extractor_factory_supports_only_llm():
|
||||
assert GraphExtractorFactory.supported_types() == ["llm"]
|
||||
|
||||
|
||||
def test_graph_extractor_factory_rejects_spacy():
|
||||
with pytest.raises(ValueError, match="spacy"):
|
||||
GraphExtractorFactory.create("spacy", {"model": "zh_core_web_sm"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_milvus_graph_service_configure_rejects_spacy():
|
||||
kb = SimpleNamespace(kb_type="milvus", additional_params={})
|
||||
|
||||
class Repo:
|
||||
async def get_by_kb_id(self, kb_id):
|
||||
return kb
|
||||
|
||||
async def update(self, kb_id, data):
|
||||
raise AssertionError("unsupported extractor should not be persisted")
|
||||
|
||||
service = MilvusGraphService(kb_repo=Repo())
|
||||
|
||||
with pytest.raises(ValueError, match="不支持的图谱抽取器类型"):
|
||||
await service.configure(
|
||||
"kb_test",
|
||||
extractor_type="spacy",
|
||||
extractor_options={"model": "zh_core_web_sm"},
|
||||
created_by="user_1",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_milvus_graph_service_configure_persists_updated_concurrency():
|
||||
kb = SimpleNamespace(
|
||||
|
||||
@ -75,7 +75,7 @@ Milvus 知识库详情页提供「知识图谱」Tab。图谱构建流程会从
|
||||
|
||||
主要能力:
|
||||
|
||||
- 配置 LLM 或 spaCy 抽取器
|
||||
- 配置 LLM 抽取器,更多抽取方式拓展中
|
||||
- 构建待索引 chunks 的图谱实体与关系
|
||||
- 查看构建状态、标签和统计信息
|
||||
- 在知识库详情页搜索和展示子图
|
||||
|
||||
@ -245,69 +245,65 @@
|
||||
message="修改配置仅影响后续构建;已构建的图谱不会自动重算,如需一致请重置后重新抽取。抽取器类型创建后不可修改。"
|
||||
/>
|
||||
<a-form-item label="抽取器类型">
|
||||
<div class="extractor-type-cards">
|
||||
<div class="extractor-type-cards" role="radiogroup" aria-label="抽取器类型">
|
||||
<div
|
||||
v-for="option in extractorTypeOptions"
|
||||
:key="option.value"
|
||||
class="extractor-type-card"
|
||||
:class="{
|
||||
active: graphConfigForm.extractor_type === option.value,
|
||||
disabled: isEditingGraphConfig
|
||||
disabled: isEditingGraphConfig || option.disabled
|
||||
}"
|
||||
@click="selectExtractorType(option.value)"
|
||||
role="radio"
|
||||
:aria-checked="graphConfigForm.extractor_type === option.value"
|
||||
:aria-disabled="isEditingGraphConfig || option.disabled"
|
||||
:tabindex="isEditingGraphConfig || option.disabled ? -1 : 0"
|
||||
@click="selectExtractorType(option)"
|
||||
@keydown.enter.prevent="selectExtractorType(option)"
|
||||
@keydown.space.prevent="selectExtractorType(option)"
|
||||
>
|
||||
<div class="card-header">
|
||||
<component :is="option.icon" class="type-icon" />
|
||||
<span class="type-title">{{ option.label }}</span>
|
||||
</div>
|
||||
<div class="card-description">{{ option.description }}</div>
|
||||
<div v-if="option.helper" class="card-helper" :class="{ warning: option.disabled }">
|
||||
{{ option.helper }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<template v-if="graphConfigForm.extractor_type === 'llm'">
|
||||
<a-form-item label="模型">
|
||||
<ModelSelectorComponent
|
||||
:model_spec="graphConfigForm.model_spec"
|
||||
placeholder="选择抽取模型"
|
||||
@select-model="(spec) => (graphConfigForm.model_spec = spec)"
|
||||
<a-form-item label="模型">
|
||||
<ModelSelectorComponent
|
||||
:model_spec="graphConfigForm.model_spec"
|
||||
placeholder="选择抽取模型"
|
||||
@select-model="(spec) => (graphConfigForm.model_spec = spec)"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="Schema">
|
||||
<a-textarea
|
||||
v-model:value="graphConfigForm.schema"
|
||||
:rows="6"
|
||||
placeholder="描述实体类型、关系类型和属性约束。后端会把 Schema 拼接到固定抽取 Prompt 中。"
|
||||
/>
|
||||
</a-form-item>
|
||||
<div class="form-grid two-columns">
|
||||
<a-form-item label="并发队列数">
|
||||
<a-input-number
|
||||
v-model:value="graphConfigForm.concurrency_count"
|
||||
:min="1"
|
||||
:max="1000"
|
||||
:step="1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="Schema">
|
||||
<a-textarea
|
||||
v-model:value="graphConfigForm.schema"
|
||||
:rows="6"
|
||||
placeholder="描述实体类型、关系类型和属性约束。后端会把 Schema 拼接到固定抽取 Prompt 中。"
|
||||
/>
|
||||
</a-form-item>
|
||||
<div class="form-grid two-columns">
|
||||
<a-form-item label="并发队列数">
|
||||
<a-input-number
|
||||
v-model:value="graphConfigForm.concurrency_count"
|
||||
:min="1"
|
||||
:max="1000"
|
||||
:step="1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="模型参数 JSON">
|
||||
<a-input
|
||||
v-model:value="graphConfigForm.model_params_text"
|
||||
placeholder='例如 {"temperature":0.1}'
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-form-item label="spaCy 模型">
|
||||
<a-input v-model:value="graphConfigForm.spacy_model" placeholder="zh_core_web_sm" />
|
||||
</a-form-item>
|
||||
<a-form-item label="实体类型过滤">
|
||||
<a-form-item label="模型参数 JSON">
|
||||
<a-input
|
||||
v-model:value="graphConfigForm.entity_labels_text"
|
||||
placeholder="可选,逗号分隔"
|
||||
v-model:value="graphConfigForm.model_params_text"
|
||||
placeholder='例如 {"temperature":0.1}'
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
@ -317,6 +313,7 @@
|
||||
import { ref, computed, watch, nextTick, onUnmounted, reactive } from 'vue'
|
||||
import { useDatabaseStore } from '@/stores/database'
|
||||
import { useTaskerStore } from '@/stores/tasker'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import {
|
||||
RefreshCw,
|
||||
Settings,
|
||||
@ -348,6 +345,7 @@ const props = defineProps({
|
||||
|
||||
const store = useDatabaseStore()
|
||||
const taskerStore = useTaskerStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const kbId = computed(() => store.kbId)
|
||||
const kbType = computed(() => store.database.kb_type)
|
||||
@ -373,13 +371,17 @@ const extractorTypeOptions = [
|
||||
value: 'llm',
|
||||
label: 'LLM',
|
||||
description: '使用大模型按 Schema 抽取实体和关系',
|
||||
icon: BrainCircuit
|
||||
helper: '当前唯一支持的图谱抽取方式',
|
||||
icon: BrainCircuit,
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
value: 'spacy',
|
||||
label: 'spaCy',
|
||||
description: '【Beta】使用本地 NER 模型抽取实体',
|
||||
icon: ScanText
|
||||
value: 'more',
|
||||
label: '更多',
|
||||
description: '更多抽取方式正在拓展中',
|
||||
helper: '拓展中',
|
||||
icon: ScanText,
|
||||
disabled: true
|
||||
}
|
||||
]
|
||||
|
||||
@ -466,9 +468,7 @@ const graphConfigForm = reactive({
|
||||
model_spec: '',
|
||||
schema: '',
|
||||
concurrency_count: 50,
|
||||
model_params_text: '',
|
||||
spacy_model: 'zh_core_web_sm',
|
||||
entity_labels_text: ''
|
||||
model_params_text: ''
|
||||
})
|
||||
|
||||
const graph = reactive(useGraph(graphRef))
|
||||
@ -504,13 +504,6 @@ const loadGraphBuildStatus = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const parseCommaSeparatedValues = (value) => {
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const parseModelParams = () => {
|
||||
const text = graphConfigForm.model_params_text.trim()
|
||||
if (!text) return {}
|
||||
@ -528,19 +521,14 @@ const parseModelParams = () => {
|
||||
|
||||
const fillGraphConfigForm = () => {
|
||||
const config = graphBuildStatus.value?.config
|
||||
if (!config) return
|
||||
const options = config.extractor_options || {}
|
||||
graphConfigForm.extractor_type = config.extractor_type || 'llm'
|
||||
graphConfigForm.model_spec = options.model_spec || ''
|
||||
const options = config?.extractor_options || {}
|
||||
graphConfigForm.extractor_type = 'llm'
|
||||
graphConfigForm.model_spec = options.model_spec || configStore.config?.default_model || ''
|
||||
graphConfigForm.schema = options.schema || ''
|
||||
graphConfigForm.concurrency_count = Number(options.concurrency_count || 5)
|
||||
graphConfigForm.concurrency_count = Number(options.concurrency_count || 50)
|
||||
graphConfigForm.model_params_text = options.model_params
|
||||
? JSON.stringify(options.model_params)
|
||||
: ''
|
||||
graphConfigForm.spacy_model = options.model || 'zh_core_web_sm'
|
||||
graphConfigForm.entity_labels_text = Array.isArray(options.entity_labels)
|
||||
? options.entity_labels.join(', ')
|
||||
: ''
|
||||
}
|
||||
|
||||
const openGraphConfig = () => {
|
||||
@ -548,26 +536,18 @@ const openGraphConfig = () => {
|
||||
showGraphConfig.value = true
|
||||
}
|
||||
|
||||
const selectExtractorType = (type) => {
|
||||
if (isEditingGraphConfig.value) return
|
||||
graphConfigForm.extractor_type = type
|
||||
const selectExtractorType = (option) => {
|
||||
if (isEditingGraphConfig.value || option.disabled) return
|
||||
graphConfigForm.extractor_type = option.value
|
||||
}
|
||||
|
||||
const buildExtractorOptions = () => {
|
||||
if (graphConfigForm.extractor_type === 'spacy') {
|
||||
return {
|
||||
model: graphConfigForm.spacy_model,
|
||||
entity_labels: parseCommaSeparatedValues(graphConfigForm.entity_labels_text)
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
return {
|
||||
model_spec: graphConfigForm.model_spec,
|
||||
schema: graphConfigForm.schema.trim(),
|
||||
concurrency_count: graphConfigForm.concurrency_count || 1,
|
||||
concurrency_count: graphConfigForm.concurrency_count || 50,
|
||||
model_params: parseModelParams()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const configureGraphBuild = async () => {
|
||||
@ -575,7 +555,7 @@ const configureGraphBuild = async () => {
|
||||
document.activeElement?.blur()
|
||||
await nextTick()
|
||||
await graphBuildApi.configure(kbId.value, {
|
||||
extractor_type: graphConfigForm.extractor_type,
|
||||
extractor_type: 'llm',
|
||||
extractor_options: buildExtractorOptions()
|
||||
})
|
||||
message.success(isEditingGraphConfig.value ? '图谱抽取配置已更新' : '图谱抽取配置已保存')
|
||||
@ -1055,7 +1035,12 @@ onUnmounted(() => {
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.78;
|
||||
opacity: 0.72;
|
||||
background: var(--gray-50);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--gray-150);
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@ -1083,6 +1068,16 @@ onUnmounted(() => {
|
||||
color: var(--gray-600);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card-helper {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
|
||||
&.warning {
|
||||
color: var(--color-warning-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user