13 KiB
RFC-0002: Natural Language Entity Extraction for Search Enhancement
- Status: Draft
- Authors: @trevon
- Tracking: yams-3c1k
Summary
Extend YAMS entity extraction to support natural language documents (prose, scientific papers, PDFs) alongside existing code symbol extraction. This will populate the entity_vectors table for non-code content, enabling the new entity vector search component to improve retrieval quality on document corpora like SciFact.
Motivation
Current State
- EntityGraphService orchestrates symbol extraction via TreeSitter-based plugins
- symbol_extractor_v1 plugin interface extracts code symbols (functions, classes, methods)
- entity_vectors table stores entity embeddings for semantic entity search
- SearchEngine has a new
entity_vectorsearch component (weight 0.05 default)
Problem
The entity_vectors table remains empty for natural language documents because:
- TreeSitter only parses programming languages with formal grammars
- No equivalent extraction exists for prose, scientific documents, or PDFs
- SciFact benchmark (scientific claims) shows no improvement from entity vector search
Benchmark Evidence
SciFact Results (300 queries):
MRR: 0.6303
R@K: 0.7988
nDCG: 0.6687
Entity vector component contributes 0.0 because entity_vectors is empty for SciFact.
Opportunity
Natural language documents contain rich extractable entities:
- Named Entities: People, organizations, locations, dates
- Scientific Concepts: Diseases, chemicals, genes, proteins
- Key Phrases: Important n-grams, technical terms
- Relations: Subject-predicate-object triples (knowledge graph edges)
- Claims/Propositions: Atomic facts that can be verified
Proposal
Architecture Overview
EntityGraphService
├── Code Extractors (existing)
│ └── symbol_extractor_treesitter (functions, classes, imports)
│
└── NL Extractors (NEW)
├── NER Extractor (GLiNER/ONNX)
│ └── Named entities with configurable types
├── Keyphrase Extractor (KeyBERT-style/ONNX)
│ └── Document-representative phrases
└── Relation Extractor (REBEL/ONNX) [future]
└── Subject-predicate-object triples
Plugin Interface: nl_entity_extractor_v1
New plugin interface parallel to symbol_extractor_v1:
// nl_entity_extractor_v1.h
#define YAMS_IFACE_NL_ENTITY_EXTRACTOR_V1 "nl_entity_extractor_v1"
#define YAMS_IFACE_NL_ENTITY_EXTRACTOR_V1_VERSION 1u
// Entity types this extractor can produce
typedef enum yams_nl_entity_type_e {
YAMS_NL_ENTITY_NAMED = 0, // Named entities (PER, ORG, LOC, etc.)
YAMS_NL_ENTITY_KEYPHRASE = 1, // Key phrases/concepts
YAMS_NL_ENTITY_RELATION = 2, // Subject-predicate-object triple
YAMS_NL_ENTITY_CLAIM = 3 // Atomic proposition/claim
} yams_nl_entity_type_t;
typedef struct yams_nl_entity_v1 {
char* text; // Entity surface form ("Albert Einstein")
char* normalized; // Normalized/canonical form (optional)
char* entity_type; // Type label ("PERSON", "DISEASE", etc.)
yams_nl_entity_type_t kind;
float confidence; // Model confidence [0.0, 1.0]
uint32_t start_offset;
uint32_t end_offset;
// For relations only:
char* subject; // Subject entity (nullable)
char* predicate; // Relation type (nullable)
char* object; // Object entity (nullable)
} yams_nl_entity_v1;
typedef struct yams_nl_extraction_result_v1 {
yams_nl_entity_v1* entities;
size_t entity_count;
char* error;
} yams_nl_extraction_result_v1;
typedef struct yams_nl_entity_extractor_v1 {
uint32_t abi_version;
void* self;
// Check if extractor supports mime type
bool (*supports_mime)(void* self, const char* mime_type);
// Get extractable entity types
int (*get_entity_types)(void* self, const char** types, size_t* count);
// Extract entities from text content
int (*extract_entities)(
void* self,
const char* content,
size_t content_len,
const char* mime_type,
const char* entity_types_json, // Filter: ["PERSON", "ORG"] or null for all
float threshold, // Confidence threshold
yams_nl_extraction_result_v1** result
);
void (*free_result)(void* self, yams_nl_extraction_result_v1* result);
int (*get_capabilities_json)(void* self, char** out_json);
void (*free_string)(void* self, char* s);
} yams_nl_entity_extractor_v1;
Model Selection Analysis
| Model | Task | ONNX Support | Size | Latency | License |
|---|---|---|---|---|---|
| GLiNER-medium-v2.1 | Zero-shot NER | ✅ Native | ~400MB | ~50ms/doc | Apache-2.0 |
| GLiNER-small | Zero-shot NER | ✅ Native | ~150MB | ~20ms/doc | Apache-2.0 |
| KeyBERT | Keyphrases | ⚠️ Via embeddings | N/A* | ~30ms/doc | MIT |
| REBEL-large | Relations | ⚠️ BART-based | ~1.6GB | ~200ms/doc | CC-BY-NC-SA |
| gemma-scope-2b-pt-res | Interpretability SAE | ❌ Not applicable | N/A | N/A | CC-BY-4.0 |
* KeyBERT uses existing embedding model + cosine similarity, no separate ONNX model needed.
Recommendation: GLiNER for Phase 1
GLiNER is the best fit for initial implementation:
- Native ONNX support with
convert_to_onnx.pyscript - Zero-shot capability - extract arbitrary entity types without retraining
- Compact size - fits YAMS's local-first philosophy
- Production-ready - quantization-aware training, NAACL 2024 paper
- Apache-2.0 license - compatible with YAMS
Note on gemma-scope-2b-pt-res
The user-suggested google/gemma-scope-2b-pt-res is not suitable for entity extraction:
- It's a Sparse Autoencoder (SAE) for model interpretability research
- Designed to decompose Gemma 2B's internal representations into interpretable features
- Used with SAELens library for mechanistic interpretability
- Does not perform NER, keyphrase extraction, or relation extraction
Design Details
Phase 1: GLiNER NER Integration
1.1 ONNX Model Management
Extend existing IModelProvider infrastructure:
// New model type in model_registry.h
enum class ModelPurpose {
Embedding, // existing
EntityNER, // NEW: NER models like GLiNER
EntityRelation // future: relation extraction
};
// Model download/discovery
struct NLModelInfo : ModelInfo {
std::vector<std::string> supported_entity_types;
std::string default_threshold;
};
1.2 GLiNER ONNX Plugin
plugins/
└── nl_entity_gliner/
├── meson.build
├── gliner_plugin.cpp # Plugin entry point
├── gliner_extractor.cpp # ONNX inference wrapper
├── gliner_extractor.h
└── models/
└── README.md # Model download instructions
GLiNER ONNX export (from upstream):
from gliner import GLiNER
model = GLiNER.from_pretrained("urchade/gliner_medium-v2.1")
model.to_onnx("gliner_medium.onnx", quantize=True)
1.3 EntityGraphService Extension
// In EntityGraphService::process()
bool EntityGraphService::process(Job& job) {
// Existing code symbol extraction
if (isCodeLanguage(job.language)) {
return processCodeSymbols(job);
}
// NEW: Natural language entity extraction
if (isNaturalLanguage(job.mimeType)) {
return processNLEntities(job);
}
return true;
}
bool EntityGraphService::processNLEntities(Job& job) {
auto* extractor = findNLExtractor(job.mimeType);
if (!extractor) return true; // no-op if no extractor
yams_nl_extraction_result_v1* result = nullptr;
int rc = extractor->extract_entities(
extractor->self,
job.contentUtf8.data(),
job.contentUtf8.size(),
job.mimeType.c_str(),
nullptr, // all entity types
0.5f, // confidence threshold
&result
);
if (rc != 0 || !result) return false;
// Populate KG and entity_vectors
populateNLEntities(job, result);
generateNLEntityEmbeddings(job, result);
extractor->free_result(extractor->self, result);
return true;
}
1.4 Entity Storage
Entities stored in two places:
Knowledge Graph (kg_nodes table):
INSERT INTO kg_nodes (node_key, label, type, properties)
VALUES (
'entity:scifact:12345:DISEASE:diabetes', -- unique key
'diabetes', -- label
'nl_entity', -- type
'{"kind":"DISEASE","confidence":0.92,"source_doc":"abc123"}'
);
Entity Vectors (entity_vectors table):
INSERT INTO entity_vectors (entity_key, entity_type, embedding)
VALUES (
'diabetes',
'DISEASE',
<768-dim vector from embedding model>
);
Phase 2: Keyphrase Extraction (Future)
KeyBERT-style extraction using existing embedding infrastructure:
class KeyphraseExtractor {
// Reuses EmbeddingService for document/phrase embeddings
std::vector<Keyphrase> extract(
const std::string& document,
size_t top_n = 10,
std::pair<size_t, size_t> ngram_range = {1, 3}
) {
// 1. Generate document embedding
auto doc_emb = embeddingService_->embed(document);
// 2. Extract candidate n-grams
auto candidates = extractCandidates(document, ngram_range);
// 3. Embed candidates (batch)
auto cand_embs = embeddingService_->embedBatch(candidates);
// 4. Rank by cosine similarity to document
return rankBySimilarity(candidates, cand_embs, doc_emb, top_n);
}
};
Phase 3: Relation Extraction (Future)
REBEL for knowledge graph population:
- Extracts (subject, predicate, object) triples
- Directly populates
kg_edgestable - Enables graph-based retrieval enhancements
Configuration
# config.toml
[entity_extraction]
enabled = true
[entity_extraction.ner]
enabled = true
model = "gliner-medium-v2.1"
threshold = 0.5
entity_types = ["PERSON", "ORG", "LOC", "DISEASE", "CHEMICAL", "GENE"]
[entity_extraction.keyphrases]
enabled = false # Phase 2
top_n = 10
ngram_range = [1, 3]
[entity_extraction.relations]
enabled = false # Phase 3
Search Integration
Entity vector search already integrated (yams-y839). This RFC enables it for NL:
// SearchEngine already has:
SearchEngineConfig config;
config.entityVectorWeight = 0.05; // 5% of final score
config.entityVectorMaxResults = 50;
// For CODE corpus: uses code symbols
// For PROSE corpus: will now use NL entities
config.corpusProfile = CorpusProfile::PROSE;
// Sets entityVectorWeight = 0.0 currently (no NL entities)
// After this RFC: entityVectorWeight = 0.10 for PROSE
Alternatives Considered
1. LLM-based Extraction
- Pros: Highest quality, flexible
- Cons: High latency, API costs, no offline support
- Verdict: Rejected for core pipeline, may add as optional
2. SpaCy NER
- Pros: Fast, well-tested
- Cons: Fixed entity types, requires Python runtime
- Verdict: Rejected, want ONNX-native C++ integration
3. Fine-tuned BERT NER
- Pros: High accuracy on target domain
- Cons: Requires training data, not zero-shot
- Verdict: Future consideration for domain-specific models
4. Gemma Scope SAE Features
- Pros: Interpretable features, Google-backed
- Cons: Not designed for NER, requires full Gemma model
- Verdict: Not applicable for this use case
Migration / Compatibility
- Backward compatible: New plugin interface, existing code unchanged
- Schema: No changes to existing tables
- Config: New optional
[entity_extraction]section
Security Considerations
- ONNX models run in sandboxed ONNX Runtime
- No network calls during inference
- Model files verified by hash on download
Rollout Plan
-
Phase 1 (2 weeks): GLiNER ONNX plugin
- Plugin skeleton with
nl_entity_extractor_v1interface - GLiNER ONNX model loading and inference
- EntityGraphService integration
- Basic tests
- Plugin skeleton with
-
Phase 2 (1 week): SciFact validation
- Ingest SciFact with NL extraction enabled
- Benchmark retrieval quality improvement
- Tune entity vector weight for PROSE corpus
-
Phase 3 (future): KeyBERT + REBEL
- Keyphrase extraction using existing embeddings
- Relation extraction for KG population
Drawbacks / Risks
-
Model size: GLiNER-medium is ~400MB, increases distribution size
- Mitigation: On-demand download like embedding models
-
Latency: NER adds ~50ms per document
- Mitigation: Async processing in existing pipeline
-
Entity type mismatch: Different domains need different types
- Mitigation: Configurable entity types, zero-shot flexibility
Unresolved Questions
- How to handle entity deduplication across documents?
- Should entity confidence affect embedding weight in search?
- Optimal entity types for different corpus profiles (CODE, PROSE, DOCS)?
- Should we link entities to external KBs (Wikidata, UMLS)?
References
- GLiNER Paper (NAACL 2024)
- GLiNER GitHub
- REBEL Paper (EMNLP 2021)
- KeyBERT
- YAMS plugin interfaces: see
../spec/README.md