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:
matevip 2026-07-16 17:39:41 +08:00
parent 7a1d237de4
commit 22a61e6e78
6 changed files with 276 additions and 17 deletions

View File

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

View File

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

View File

@ -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 organizationemploysperson; the canned LLM output
// is personworks_fororganization, 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() {

View File

@ -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…',

View File

@ -2751,6 +2751,12 @@ export default {
entityTypesLabel: '抽取的实体类型',
entityTypesPlaceholder: '选择或输入实体类型,回车添加',
entityTypesHint: '限定要抽取的实体类型;留空则使用内置默认类型(人物、组织、地点、事件、产品、概念)。',
relationSchemaLabel: '关系模式',
relationSchemaHint: '限定只抽取以下几条确定的关系三元组,其余关系与不参与这些关系的实体一律忽略;留空则保持开放抽取(模型自行判断关系)。',
relationSchemaSubject: '主体类型',
relationSchemaPredicate: '关系(如 works_for',
relationSchemaObject: '客体类型',
relationSchemaAdd: '+ 添加一条关系',
modelStrategy: '模型策略',
globalDefault: '跟随全局默认',
selectModel: '选择可用模型…',

View File

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