mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(wiki): closed relation schema for entity extraction
Entity extraction previously constrained entity types but let the model freely invent any relation between entities, producing noise that diluted the entities a knowledge base actually cares about. Adds an optional per-KB relation schema (subjectType/predicate/ objectType triples): when set, the extraction prompt is scoped to only those relations, and a hard filter drops anything that slips through before it is persisted. Empty/unset keeps the existing open-vocabulary behaviour.
This commit is contained in:
parent
7a1d237de4
commit
22a61e6e78
@ -84,4 +84,23 @@ public class WikiKbConfig {
|
||||
* lets the extractor use its built-in default type set.
|
||||
*/
|
||||
private List<String> entityTypes;
|
||||
|
||||
/**
|
||||
* Optional closed relation schema: a whitelist of
|
||||
* (subjectType, predicate, objectType) triples. When non-empty,
|
||||
* extraction is constrained to only these relations instead of freely
|
||||
* inferring arbitrary ones, which keeps the resulting graph focused on
|
||||
* the handful of relationships a KB actually cares about instead of
|
||||
* diluting it with incidental entities. {@code null} or empty keeps the
|
||||
* legacy open-vocabulary behaviour.
|
||||
*/
|
||||
private List<RelationSchemaEntry> relationSchema;
|
||||
|
||||
/** One allowed relation triple in {@link #relationSchema}. */
|
||||
@Data
|
||||
public static class RelationSchemaEntry {
|
||||
private String subjectType;
|
||||
private String predicate;
|
||||
private String objectType;
|
||||
}
|
||||
}
|
||||
|
||||
@ -115,9 +115,10 @@ public class WikiEntityExtractionService {
|
||||
}
|
||||
|
||||
List<String> types = resolveEntityTypes(kb);
|
||||
List<WikiKbConfig.RelationSchemaEntry> relationSchema = resolveRelationSchema(kb);
|
||||
BeanOutputConverter<EntityExtractionResult> converter =
|
||||
new BeanOutputConverter<>(EntityExtractionResult.class);
|
||||
String systemPrompt = buildSystemPrompt(types);
|
||||
String systemPrompt = buildSystemPrompt(types, relationSchema);
|
||||
|
||||
// Per-run resolution cache: type+normalizedKey → entityId. Seeded lazily
|
||||
// from the DB so entities resolve consistently within and across chunks.
|
||||
@ -144,7 +145,7 @@ public class WikiEntityExtractionService {
|
||||
if (alreadyProcessed) {
|
||||
clearChunkArtifacts(chunk.getId());
|
||||
}
|
||||
persistChunk(kbId, chunk, result, resolved, index);
|
||||
persistChunk(kbId, chunk, result, resolved, index, relationSchema);
|
||||
} catch (Exception e) {
|
||||
log.warn("[WikiEntity] Extraction failed for chunkId={} kbId={}: {}",
|
||||
chunk.getId(), kbId, e.getMessage());
|
||||
@ -180,27 +181,44 @@ public class WikiEntityExtractionService {
|
||||
}
|
||||
}
|
||||
|
||||
private String buildSystemPrompt(List<String> types) {
|
||||
return "You are a knowledge-graph entity extractor. From the given source text, "
|
||||
+ "extract named entities and the factual relations between them.\n"
|
||||
+ "Entity types to use: " + String.join(", ", types) + ".\n"
|
||||
+ "Rules:\n"
|
||||
+ "- Only extract entities explicitly named in the text; do not invent any.\n"
|
||||
+ "- Use the most complete surface form as the name; list shorter forms as aliases.\n"
|
||||
+ "- For each relation, subject and object must both appear in the entities list.\n"
|
||||
+ "- Keep predicates short and snake_case (e.g. works_for, located_in, founded).\n"
|
||||
+ "- Provide a short verbatim evidence quote for each entity and relation.\n"
|
||||
+ "- If nothing relevant is present, return empty lists.";
|
||||
private String buildSystemPrompt(List<String> types, List<WikiKbConfig.RelationSchemaEntry> relationSchema) {
|
||||
StringBuilder sb = new StringBuilder()
|
||||
.append("You are a knowledge-graph entity extractor. From the given source text, ")
|
||||
.append("extract named entities and the factual relations between them.\n")
|
||||
.append("Entity types to use: ").append(String.join(", ", types)).append(".\n")
|
||||
.append("Rules:\n")
|
||||
.append("- Only extract entities explicitly named in the text; do not invent any.\n")
|
||||
.append("- Use the most complete surface form as the name; list shorter forms as aliases.\n")
|
||||
.append("- For each relation, subject and object must both appear in the entities list.\n")
|
||||
.append("- Keep predicates short and snake_case (e.g. works_for, located_in, founded).\n")
|
||||
.append("- Provide a short verbatim evidence quote for each entity and relation.\n")
|
||||
.append("- If nothing relevant is present, return empty lists.");
|
||||
if (relationSchema != null && !relationSchema.isEmpty()) {
|
||||
sb.append("\n\nAllowed relations (ONLY extract these — ignore everything else):\n");
|
||||
for (WikiKbConfig.RelationSchemaEntry rule : relationSchema) {
|
||||
if (rule == null) {
|
||||
continue;
|
||||
}
|
||||
sb.append("- ").append(rule.getSubjectType()).append(' ')
|
||||
.append(rule.getPredicate()).append(' ')
|
||||
.append(rule.getObjectType()).append('\n');
|
||||
}
|
||||
sb.append("Only extract named entities that participate in at least one relation above. ")
|
||||
.append("Ignore all other named entities and relations, even if they fit one of the entity types.");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
// ---- persistence ------------------------------------------------------
|
||||
|
||||
private void persistChunk(Long kbId, WikiChunkEntity chunk, EntityExtractionResult result,
|
||||
Map<String, Long> resolved, EntityIndex index) {
|
||||
Map<String, Long> resolved, EntityIndex index,
|
||||
List<WikiKbConfig.RelationSchemaEntry> relationSchema) {
|
||||
Long pageId = firstCitingPage(chunk.getId());
|
||||
|
||||
// Resolve each entity to a canonical id, persist its mention for this chunk.
|
||||
Map<String, Long> localByName = new HashMap<>();
|
||||
Map<String, String> localTypeByName = new HashMap<>();
|
||||
if (result.getEntities() != null) {
|
||||
for (EntityExtractionResult.ExtractedEntity e : result.getEntities()) {
|
||||
if (e == null || e.getName() == null || e.getName().isBlank()) {
|
||||
@ -212,10 +230,12 @@ public class WikiEntityExtractionService {
|
||||
continue;
|
||||
}
|
||||
localByName.put(normalize(e.getName()), entityId);
|
||||
localTypeByName.put(normalize(e.getName()), type);
|
||||
if (e.getAliases() != null) {
|
||||
for (String alias : e.getAliases()) {
|
||||
if (alias != null && !alias.isBlank()) {
|
||||
localByName.put(normalize(alias), entityId);
|
||||
localTypeByName.put(normalize(alias), type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -224,7 +244,10 @@ public class WikiEntityExtractionService {
|
||||
}
|
||||
}
|
||||
|
||||
// Persist relations whose endpoints both resolved.
|
||||
// Persist relations whose endpoints both resolved and, when a relation
|
||||
// schema is configured, whose (subjectType, predicate, objectType)
|
||||
// matches an allowed triple — a hard backstop in case the model
|
||||
// doesn't fully follow the prompt-level restriction.
|
||||
if (result.getRelations() != null) {
|
||||
for (EntityExtractionResult.ExtractedRelation r : result.getRelations()) {
|
||||
if (r == null || r.getSubject() == null || r.getObject() == null
|
||||
@ -236,12 +259,49 @@ public class WikiEntityExtractionService {
|
||||
if (subjectId == null || objectId == null || subjectId.equals(objectId)) {
|
||||
continue;
|
||||
}
|
||||
upsertRelation(kbId, subjectId, objectId, normalizePredicate(r.getPredicate()),
|
||||
r.getEvidence(), chunk.getId());
|
||||
String predicate = normalizePredicate(r.getPredicate());
|
||||
String subjectType = localTypeByName.get(normalize(r.getSubject()));
|
||||
String objectType = localTypeByName.get(normalize(r.getObject()));
|
||||
if (!allowedBySchema(relationSchema, subjectType, predicate, objectType)) {
|
||||
continue;
|
||||
}
|
||||
upsertRelation(kbId, subjectId, objectId, predicate, r.getEvidence(), chunk.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when {@code schema} is empty (legacy open behaviour) or contains a
|
||||
* triple matching {@code subjectType}/{@code predicate}/{@code objectType}.
|
||||
* {@code predicate} is expected to already be {@link #normalizePredicate}d;
|
||||
* the rule's predicate is normalized the same way before comparing so
|
||||
* e.g. a user-entered "works for" matches an extracted "works_for".
|
||||
*/
|
||||
private boolean allowedBySchema(List<WikiKbConfig.RelationSchemaEntry> schema,
|
||||
String subjectType, String predicate, String objectType) {
|
||||
if (schema == null || schema.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
for (WikiKbConfig.RelationSchemaEntry rule : schema) {
|
||||
if (rule == null || rule.getPredicate() == null || rule.getPredicate().isBlank()) {
|
||||
continue;
|
||||
}
|
||||
if (equalsNormalized(rule.getSubjectType(), subjectType)
|
||||
&& normalizePredicate(rule.getPredicate()).equals(predicate)
|
||||
&& equalsNormalized(rule.getObjectType(), objectType)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean equalsNormalized(String a, String b) {
|
||||
if (a == null || b == null) {
|
||||
return false;
|
||||
}
|
||||
return a.trim().equalsIgnoreCase(b.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an extracted entity to a canonical node id: run cache → exact
|
||||
* key match in DB → embedding near-match → create new.
|
||||
@ -457,6 +517,16 @@ public class WikiEntityExtractionService {
|
||||
return DEFAULT_ENTITY_TYPES;
|
||||
}
|
||||
|
||||
private List<WikiKbConfig.RelationSchemaEntry> resolveRelationSchema(WikiKnowledgeBaseEntity kb) {
|
||||
if (kb.getConfigContent() != null) {
|
||||
WikiKbConfig config = WikiKbConfigParser.parse(objectMapper, kb.getConfigContent());
|
||||
if (config != null && config.getRelationSchema() != null && !config.getRelationSchema().isEmpty()) {
|
||||
return config.getRelationSchema();
|
||||
}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private String normalize(String s) {
|
||||
return s == null ? "" : s.trim().toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
@ -172,6 +172,46 @@ class WikiEntityExtractionServiceTest {
|
||||
verify(mentionMapper, times(2)).insert(any(WikiEntityMentionEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("relation schema: filters out a relation whose triple isn't in the whitelist, entities still persist")
|
||||
void extractForRaw_relationSchema_filtersNonMatchingRelation() {
|
||||
WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity();
|
||||
kb.setId(KB_ID);
|
||||
// Schema only allows organization→employs→person; the canned LLM output
|
||||
// is person→works_for→organization, so it must not match.
|
||||
kb.setConfigContent("""
|
||||
{"relationSchema": [{"subjectType": "organization", "predicate": "employs", "objectType": "person"}]}
|
||||
""");
|
||||
when(kbService.getById(KB_ID)).thenReturn(kb);
|
||||
when(chunkService.listByRawId(RAW_ID)).thenReturn(List.of(chunk(1L, "Alice works at Acme.")));
|
||||
|
||||
int touched = service.extractForRaw(KB_ID, RAW_ID);
|
||||
|
||||
assertEquals(2, touched, "entities still resolve even when their relation is filtered out");
|
||||
verify(entityMapper, times(2)).insert(any(WikiEntityEntity.class));
|
||||
verify(mentionMapper, times(2)).insert(any(WikiEntityMentionEntity.class));
|
||||
verify(relationMapper, times(0)).insert(any(WikiEntityRelationEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("relation schema: persists a relation whose triple matches the whitelist")
|
||||
void extractForRaw_relationSchema_allowsMatchingRelation() {
|
||||
WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity();
|
||||
kb.setId(KB_ID);
|
||||
// "works for" (with a space) must normalize the same way as the
|
||||
// extracted "works for" predicate for the match to succeed.
|
||||
kb.setConfigContent("""
|
||||
{"relationSchema": [{"subjectType": "person", "predicate": "works for", "objectType": "organization"}]}
|
||||
""");
|
||||
when(kbService.getById(KB_ID)).thenReturn(kb);
|
||||
when(chunkService.listByRawId(RAW_ID)).thenReturn(List.of(chunk(1L, "Alice works at Acme.")));
|
||||
|
||||
int touched = service.extractForRaw(KB_ID, RAW_ID);
|
||||
|
||||
assertEquals(2, touched);
|
||||
verify(relationMapper, times(1)).insert(any(WikiEntityRelationEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extractForRaw: skips chunks that already have mentions")
|
||||
void extractForRaw_skipsProcessedChunks() {
|
||||
|
||||
@ -2739,6 +2739,12 @@ export default {
|
||||
entityTypesLabel: 'Entity types to extract',
|
||||
entityTypesPlaceholder: 'Select or type an entity type, Enter to add',
|
||||
entityTypesHint: 'Restrict which entity types are extracted; leave empty to use the built-in defaults (person, organization, location, event, product, concept).',
|
||||
relationSchemaLabel: 'Relation schema',
|
||||
relationSchemaHint: 'Restrict extraction to only these relation triples; other relations, and entities that don\'t participate in any of them, are ignored. Leave empty to keep open-ended relation extraction.',
|
||||
relationSchemaSubject: 'Subject type',
|
||||
relationSchemaPredicate: 'Relation (e.g. works_for)',
|
||||
relationSchemaObject: 'Object type',
|
||||
relationSchemaAdd: '+ Add relation',
|
||||
modelStrategy: 'Model Strategy',
|
||||
globalDefault: 'Global default',
|
||||
selectModel: 'Select a model…',
|
||||
|
||||
@ -2751,6 +2751,12 @@ export default {
|
||||
entityTypesLabel: '抽取的实体类型',
|
||||
entityTypesPlaceholder: '选择或输入实体类型,回车添加',
|
||||
entityTypesHint: '限定要抽取的实体类型;留空则使用内置默认类型(人物、组织、地点、事件、产品、概念)。',
|
||||
relationSchemaLabel: '关系模式',
|
||||
relationSchemaHint: '限定只抽取以下几条确定的关系三元组,其余关系与不参与这些关系的实体一律忽略;留空则保持开放抽取(模型自行判断关系)。',
|
||||
relationSchemaSubject: '主体类型',
|
||||
relationSchemaPredicate: '关系(如 works_for)',
|
||||
relationSchemaObject: '客体类型',
|
||||
relationSchemaAdd: '+ 添加一条关系',
|
||||
modelStrategy: '模型策略',
|
||||
globalDefault: '跟随全局默认',
|
||||
selectModel: '选择可用模型…',
|
||||
|
||||
@ -90,6 +90,54 @@
|
||||
</el-select>
|
||||
<div class="entity-types__hint">{{ t('wiki.configPanel.entityTypesHint') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Relation schema: optional closed whitelist of (subjectType, predicate, objectType)
|
||||
triples. Empty = open-vocabulary relations (legacy behavior). -->
|
||||
<div v-if="entityExtractionEnabled" class="relation-schema">
|
||||
<div class="relation-schema__label">{{ t('wiki.configPanel.relationSchemaLabel') }}</div>
|
||||
<div class="relation-schema__hint">{{ t('wiki.configPanel.relationSchemaHint') }}</div>
|
||||
|
||||
<div v-for="(row, idx) in relationSchema" :key="idx" class="relation-schema__row">
|
||||
<el-select
|
||||
v-model="row.subjectType"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
size="small"
|
||||
class="relation-schema__type"
|
||||
:placeholder="t('wiki.configPanel.relationSchemaSubject')"
|
||||
>
|
||||
<el-option v-for="opt in relationSchemaTypeOptions" :key="opt" :label="formatEntityType(opt)" :value="opt" />
|
||||
</el-select>
|
||||
<input
|
||||
type="text"
|
||||
v-model.trim="row.predicate"
|
||||
class="relation-schema__predicate"
|
||||
:placeholder="t('wiki.configPanel.relationSchemaPredicate')"
|
||||
/>
|
||||
<el-select
|
||||
v-model="row.objectType"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
size="small"
|
||||
class="relation-schema__type"
|
||||
:placeholder="t('wiki.configPanel.relationSchemaObject')"
|
||||
>
|
||||
<el-option v-for="opt in relationSchemaTypeOptions" :key="opt" :label="formatEntityType(opt)" :value="opt" />
|
||||
</el-select>
|
||||
<button
|
||||
class="relation-schema__remove"
|
||||
@click="removeRelationSchemaRow(idx)"
|
||||
:title="t('common.delete')"
|
||||
:aria-label="t('common.delete')"
|
||||
>×</button>
|
||||
</div>
|
||||
|
||||
<button class="btn-save btn-save--ghost relation-schema__add" @click="addRelationSchemaRow">
|
||||
{{ t('wiki.configPanel.relationSchemaAdd') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ② Model strategy -->
|
||||
@ -297,6 +345,24 @@ const entityTypes = ref<string[]>([])
|
||||
const savingEntityExtraction = ref(false)
|
||||
const extracting = ref(false)
|
||||
|
||||
// Optional closed relation schema: a whitelist of (subjectType, predicate,
|
||||
// objectType) triples. Empty = open-vocabulary relations (legacy behavior).
|
||||
interface RelationSchemaRow { subjectType: string; predicate: string; objectType: string }
|
||||
const relationSchema = ref<RelationSchemaRow[]>([])
|
||||
|
||||
// Type dropdown suggestions: whatever entity types are currently configured,
|
||||
// plus the built-in defaults, deduped.
|
||||
const relationSchemaTypeOptions = computed(() =>
|
||||
[...new Set([...entityTypes.value, ...DEFAULT_ENTITY_TYPES])])
|
||||
|
||||
function addRelationSchemaRow() {
|
||||
relationSchema.value.push({ subjectType: '', predicate: '', objectType: '' })
|
||||
}
|
||||
|
||||
function removeRelationSchemaRow(idx: number) {
|
||||
relationSchema.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
async function saveEntityExtraction() {
|
||||
if (!store.currentKB) return
|
||||
savingEntityExtraction.value = true
|
||||
@ -312,6 +378,18 @@ async function saveEntityExtraction() {
|
||||
? [...new Set(entityTypes.value.map(s => s.trim().toLowerCase()).filter(Boolean))]
|
||||
: []
|
||||
existingConfig.entityTypes = cleanedTypes.length > 0 ? cleanedTypes : undefined
|
||||
// Only keep fully-filled rows; a row with any blank field can't match
|
||||
// anything on the backend and would silently do nothing.
|
||||
const cleanedRelationSchema = entityExtractionEnabled.value
|
||||
? relationSchema.value
|
||||
.map(row => ({
|
||||
subjectType: row.subjectType.trim().toLowerCase(),
|
||||
predicate: row.predicate.trim().toLowerCase(),
|
||||
objectType: row.objectType.trim().toLowerCase(),
|
||||
}))
|
||||
.filter(row => row.subjectType && row.predicate && row.objectType)
|
||||
: []
|
||||
existingConfig.relationSchema = cleanedRelationSchema.length > 0 ? cleanedRelationSchema : undefined
|
||||
await wikiApi.updateConfig(store.currentKB.id, JSON.stringify(existingConfig, null, 2))
|
||||
} catch (e) {
|
||||
console.error('[WikiConfig] Failed to save entity extraction toggle', e)
|
||||
@ -352,6 +430,7 @@ function loadStepModels() {
|
||||
ingestMode.value = 'eager'
|
||||
entityExtractionEnabled.value = false
|
||||
entityTypes.value = []
|
||||
relationSchema.value = []
|
||||
if (!store.currentKB) return
|
||||
try {
|
||||
const cfg = store.currentKB.configContent ? JSON.parse(store.currentKB.configContent) : null
|
||||
@ -366,6 +445,13 @@ function loadStepModels() {
|
||||
if (cfg?.ingestMode === 'lazy') ingestMode.value = 'lazy'
|
||||
if (cfg?.entityExtractionEnabled) entityExtractionEnabled.value = true
|
||||
if (Array.isArray(cfg?.entityTypes)) entityTypes.value = cfg.entityTypes.map(String)
|
||||
if (Array.isArray(cfg?.relationSchema)) {
|
||||
relationSchema.value = cfg.relationSchema.map((row: any) => ({
|
||||
subjectType: String(row?.subjectType ?? ''),
|
||||
predicate: String(row?.predicate ?? ''),
|
||||
objectType: String(row?.objectType ?? ''),
|
||||
}))
|
||||
}
|
||||
} catch { /* not JSON */ }
|
||||
}
|
||||
|
||||
@ -596,6 +682,38 @@ loadProviderNames().then(() => {
|
||||
.entity-types__select { width: 100%; }
|
||||
.entity-types__hint { font-size: 11px; color: var(--mc-text-tertiary); line-height: 1.4; }
|
||||
|
||||
/* Relation schema editor */
|
||||
.relation-schema { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
|
||||
.relation-schema__label { font-size: 12px; font-weight: 600; color: var(--mc-text-secondary); }
|
||||
.relation-schema__hint { font-size: 11px; color: var(--mc-text-tertiary); line-height: 1.4; }
|
||||
.relation-schema__row { display: flex; align-items: center; gap: 6px; }
|
||||
.relation-schema__type { flex: 1; min-width: 0; }
|
||||
.relation-schema__predicate {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-primary);
|
||||
background: var(--mc-bg-sunken);
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.relation-schema__predicate:focus { outline: none; border-color: var(--mc-primary); }
|
||||
.relation-schema__remove {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--mc-text-tertiary);
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.relation-schema__remove:hover { color: var(--mc-text-primary); border-color: var(--mc-text-tertiary); }
|
||||
.relation-schema__add { align-self: flex-start; margin-top: 2px; }
|
||||
|
||||
/* Ingest mode radio group */
|
||||
.ingest-mode-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.ingest-mode-option {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user