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:
Wenjie Zhang 2026-05-26 17:23:53 +08:00
parent e7914b1dee
commit 5b1fd51b27
6 changed files with 113 additions and 151 deletions

View File

@ -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",
]

View File

@ -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

View File

@ -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}}

View File

@ -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(

View File

@ -75,7 +75,7 @@ Milvus 知识库详情页提供「知识图谱」Tab。图谱构建流程会从
主要能力:
- 配置 LLM 或 spaCy 抽取器
- 配置 LLM 抽取器,更多抽取方式拓展中
- 构建待索引 chunks 的图谱实体与关系
- 查看构建状态、标签和统计信息
- 在知识库详情页搜索和展示子图

View File

@ -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);
}
}
}
}