mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-12 14:45:42 +08:00
415 lines
12 KiB
Go
415 lines
12 KiB
Go
//
|
|
// Copyright 2025 The InfiniFlow Authors. All Rights Reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//
|
|
|
|
// Package extractor provides NER and relation extraction for the ingestion
|
|
// pipeline. It wraps the C++ ThincNER engine via cgo and supplements it with
|
|
// pure-Go regex-based relation extraction.
|
|
//
|
|
// The architecture mirrors the Python rag/graphrag/ner package so that both
|
|
// code paths produce identical output (verified by test).
|
|
package extractor
|
|
|
|
// #cgo CXXFLAGS: -std=c++20 -I${SRCDIR}/../../..
|
|
// #cgo linux LDFLAGS: ${SRCDIR}/../../../cpp/cmake-build-release/librag_tokenizer_c_api.a -lstdc++ -lm -lpthread -lpcre2-8
|
|
// #cgo darwin LDFLAGS: ${SRCDIR}/../../../cpp/cmake-build-release/librag_tokenizer_c_api.a -lstdc++ -lm -lpthread -lpcre2-8
|
|
//
|
|
// #include <stdlib.h>
|
|
// #include "../../../cpp/rag_analyzer_c_api.h"
|
|
// #include "../../../cpp/thinc_parser.h"
|
|
import "C"
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"unsafe"
|
|
)
|
|
|
|
// Entity represents an extracted named entity.
|
|
type Entity struct {
|
|
Text string `json:"text"`
|
|
Label string `json:"label"`
|
|
StartChar int `json:"start_char"`
|
|
EndChar int `json:"end_char"`
|
|
Confidence float64 `json:"confidence"`
|
|
AppType string `json:"app_type,omitempty"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
}
|
|
|
|
// Relation represents a typed relation between two entities.
|
|
type Relation struct {
|
|
Subject Entity `json:"subject"`
|
|
Predicate string `json:"predicate"`
|
|
Object Entity `json:"object"`
|
|
Confidence float64 `json:"confidence"`
|
|
Context string `json:"context,omitempty"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
}
|
|
|
|
// ExtractionResult holds the output of a full extraction pass.
|
|
type ExtractionResult struct {
|
|
Entities []Entity `json:"entities"`
|
|
Relations []Relation `json:"relations"`
|
|
Language string `json:"language,omitempty"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
}
|
|
|
|
// Extractor provides NER + relation extraction
|
|
type Extractor struct {
|
|
mu sync.Mutex
|
|
// Language code (en/zh/de/fr/es/pt/ja)
|
|
Lang string
|
|
// Minimum confidence to include an entity (default 0.0 = all)
|
|
ConfidenceThreshold float64
|
|
// Include token-level info (POS, dep) in ExtractionResult metadata
|
|
IncludeTokens bool
|
|
// Max character distance for co-occurrence relations (default 100)
|
|
MaxDistance int
|
|
}
|
|
|
|
// spaCy NER label → application entity type mapping
|
|
var spacyToAppType = map[string]string{
|
|
"PERSON": "person",
|
|
"ORG": "organization",
|
|
"GPE": "geo",
|
|
"LOC": "geo",
|
|
"FAC": "geo",
|
|
"EVENT": "event",
|
|
"PRODUCT": "category",
|
|
"DATE": "event",
|
|
"TIME": "event",
|
|
"MONEY": "category",
|
|
"QUANTITY": "category",
|
|
"PERCENT": "category",
|
|
"LAW": "category",
|
|
"NORP": "category",
|
|
"LANGUAGE": "category",
|
|
"WORK_OF_ART": "category",
|
|
}
|
|
|
|
var skipLabels = map[string]bool{
|
|
"ORDINAL": true,
|
|
"CARDINAL": true,
|
|
}
|
|
|
|
// ModelPredictor is a cached predict function for a model path.
|
|
// Closure captures the C handle to avoid unsafe.Pointer type issues.
|
|
type ModelPredictor func(tokensJSON string) (string, error)
|
|
|
|
var (
|
|
modelCacheMu sync.Mutex
|
|
modelCache = map[string]ModelPredictor{}
|
|
)
|
|
|
|
// langModel maps language codes to spaCy model names.
|
|
var langModel = map[string]string{
|
|
"en": "en_core_web_sm",
|
|
"zh": "zh_core_web_sm",
|
|
"de": "de_core_news_sm",
|
|
"fr": "fr_core_news_sm",
|
|
"es": "es_core_news_sm",
|
|
"pt": "pt_core_news_sm",
|
|
"ja": "ja_core_news_sm",
|
|
}
|
|
|
|
// langFallback maps languages without dedicated relation patterns to a fallback.
|
|
var langFallback = map[string]string{
|
|
"de": "en",
|
|
"fr": "en",
|
|
"es": "en",
|
|
"pt": "en",
|
|
"ja": "zh",
|
|
}
|
|
|
|
// NewExtractor creates a new extractor.
|
|
// Supported langs: en, zh, de, fr, es, pt, ja.
|
|
func NewExtractor(lang string) *Extractor {
|
|
if lang == "" {
|
|
lang = "en"
|
|
}
|
|
if _, ok := langModel[lang]; !ok {
|
|
lang = "en"
|
|
}
|
|
return &Extractor{
|
|
Lang: lang,
|
|
ConfidenceThreshold: 0.0, // include all by default
|
|
MaxDistance: 100,
|
|
}
|
|
}
|
|
|
|
// Extract runs NER and optionally relation extraction (dep-based via C++ parser, or regex fallback).
|
|
func (e *Extractor) Extract(text string, extractRelations bool) (*ExtractionResult, error) {
|
|
entities, err := e.ExtractEntities(text)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Collect token info if requested (before entity dedup changes offsets)
|
|
var tokensMeta []map[string]interface{}
|
|
if e.IncludeTokens {
|
|
tokensJSON := tokenizeText(text, e.Lang)
|
|
if tokensJSON != "" {
|
|
var rawTokens []string
|
|
if err := json.Unmarshal([]byte(tokensJSON), &rawTokens); err == nil {
|
|
for i, t := range rawTokens {
|
|
tokensMeta = append(tokensMeta, map[string]interface{}{
|
|
"text": t,
|
|
"index": i,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
result := &ExtractionResult{
|
|
Entities: entities,
|
|
Language: e.Lang,
|
|
Metadata: map[string]interface{}{
|
|
"n_entities": len(entities),
|
|
"model": langModel[e.Lang],
|
|
},
|
|
}
|
|
if len(tokensMeta) > 0 {
|
|
result.Metadata["n_tokens"] = len(tokensMeta)
|
|
result.Metadata["tokens"] = tokensMeta
|
|
}
|
|
|
|
if extractRelations && len(entities) >= 2 {
|
|
relations := e.extractRelations(text, entities)
|
|
result.Relations = relations
|
|
nTyped := 0
|
|
for _, r := range relations {
|
|
if r.Predicate != "related_to" {
|
|
nTyped++
|
|
}
|
|
}
|
|
result.Metadata["n_relations"] = nTyped
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// extractRelations attempts dep-based extraction via C++ parser; falls back to regex.
|
|
func (e *Extractor) extractRelations(text string, entities []Entity) []Relation {
|
|
relLang := e.Lang
|
|
if fb, ok := langFallback[e.Lang]; ok {
|
|
relLang = fb
|
|
}
|
|
// Try dep-based extraction via C++ parser — uses e.Lang (not relLang) so
|
|
// de/fr/es/pt/ja apply their language-specific DepExtractRelations rules.
|
|
tokensJSON := tokenizeText(text, e.Lang)
|
|
if tokensJSON == "" {
|
|
return extractRelationsWithOpts(text, entities, relLang, e.MaxDistance)
|
|
}
|
|
var tokens []string
|
|
if err := json.Unmarshal([]byte(tokensJSON), &tokens); err != nil || len(tokens) == 0 {
|
|
return extractRelationsWithOpts(text, entities, relLang, e.MaxDistance)
|
|
}
|
|
modelDir := e.findModelDir()
|
|
nerDir := modelDir + "/ner"
|
|
parserDir := modelDir + "/parser"
|
|
if deps, err := ParseTokensWithParser(nerDir, parserDir, tokens); err == nil && len(deps) > 0 {
|
|
depTokens := make([]DepToken, len(deps))
|
|
for i, d := range deps {
|
|
depTokens[i] = DepToken{Text: d.Text, Head: d.Head, Dep: d.Dep, Index: d.Index}
|
|
}
|
|
if rels := DepExtractRelations(text, depTokens, entities, e.Lang, e.MaxDistance); len(rels) > 0 {
|
|
return rels
|
|
}
|
|
}
|
|
// Fallback: regex-based extraction
|
|
return extractRelationsWithOpts(text, entities, relLang, e.MaxDistance)
|
|
}
|
|
|
|
func (e *Extractor) getPredictor(modelDir string) ModelPredictor {
|
|
modelCacheMu.Lock()
|
|
defer modelCacheMu.Unlock()
|
|
if p, ok := modelCache[modelDir]; ok {
|
|
return p
|
|
}
|
|
cModelDir := C.CString(modelDir + "/ner")
|
|
cModelVocab := C.CString(modelDir + "/vocab")
|
|
handle := C.ThincNER_Create(cModelDir, cModelVocab)
|
|
C.free(unsafe.Pointer(cModelDir))
|
|
C.free(unsafe.Pointer(cModelVocab))
|
|
// Don't cache a nil handle — return a one-shot error predictor instead.
|
|
if handle == nil {
|
|
fn := func(tokensJSON string) (string, error) {
|
|
return "", fmt.Errorf("ThincNER handle is nil for model dir: %s", modelDir)
|
|
}
|
|
return fn
|
|
}
|
|
p := func(tokensJSON string) (string, error) {
|
|
e.mu.Lock()
|
|
cTokensJSON := C.CString(tokensJSON)
|
|
cResult := C.ThincNER_Predict(handle, cTokensJSON)
|
|
e.mu.Unlock()
|
|
C.free(unsafe.Pointer(cTokensJSON))
|
|
if cResult == nil {
|
|
return "", fmt.Errorf("NER prediction failed")
|
|
}
|
|
defer C.ThincNER_FreeString(cResult)
|
|
return C.GoString(cResult), nil
|
|
}
|
|
modelCache[modelDir] = p
|
|
return p
|
|
}
|
|
|
|
// ExtractEntities extracts named entities from text using C++ ThincNER.
|
|
func (e *Extractor) ExtractEntities(text string) ([]Entity, error) {
|
|
tokensJSON := tokenizeText(text, e.Lang)
|
|
if tokensJSON == "" {
|
|
return nil, fmt.Errorf("tokenization failed")
|
|
}
|
|
|
|
modelDir := e.findModelDir()
|
|
predict := e.getPredictor(modelDir)
|
|
|
|
resultJSON, err := predict(tokensJSON)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var rawEntities []struct {
|
|
Text string `json:"text"`
|
|
Label string `json:"label"`
|
|
Start int `json:"start"`
|
|
End int `json:"end"`
|
|
Confidence float64 `json:"confidence"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resultJSON), &rawEntities); err != nil {
|
|
return nil, fmt.Errorf("failed to parse NER result: %w", err)
|
|
}
|
|
|
|
// Dedup by (text.lower(), start_char) — matching Python NERExtractor
|
|
// For CJK, strip spaces from entity text (BILUO decoder joins tokens with spaces)
|
|
isCJK := e.Lang == "zh" || e.Lang == "ja"
|
|
seen := make(map[string]bool)
|
|
entities := make([]Entity, 0, len(rawEntities))
|
|
for _, re := range rawEntities {
|
|
if skipLabels[re.Label] {
|
|
continue
|
|
}
|
|
if re.Confidence < e.ConfidenceThreshold {
|
|
continue
|
|
}
|
|
text := re.Text
|
|
if isCJK {
|
|
text = strings.ReplaceAll(text, " ", "")
|
|
}
|
|
key := strings.ToLower(text) + "|" + strconv.Itoa(re.Start)
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
appType := spacyToAppType[re.Label]
|
|
if appType == "" {
|
|
appType = strings.ToLower(re.Label)
|
|
}
|
|
entities = append(entities, Entity{
|
|
Text: text,
|
|
Label: re.Label,
|
|
StartChar: re.Start,
|
|
EndChar: re.End,
|
|
Confidence: re.Confidence,
|
|
AppType: appType,
|
|
Metadata: map[string]interface{}{"source": "thincner"},
|
|
})
|
|
}
|
|
return entities, nil
|
|
}
|
|
|
|
// findModelDir locates the spaCy model directory under /usr/share/infinity/resource/spacy.
|
|
func (e *Extractor) findModelDir() string {
|
|
modelName := langModel[e.Lang]
|
|
if modelName == "" {
|
|
modelName = "en_core_web_sm"
|
|
}
|
|
base := "/usr/share/infinity/resource/spacy/" + modelName
|
|
if dirExists(base) {
|
|
return base
|
|
}
|
|
if p := getenv("SPACY_MODEL_DIR"); p != "" {
|
|
return p
|
|
}
|
|
return base
|
|
}
|
|
|
|
func dirExists(path string) bool {
|
|
info, err := os.Stat(path)
|
|
return err == nil && info.IsDir()
|
|
}
|
|
|
|
// tokenizeText tokenizes text via C++ tokenizer (all languages).
|
|
// Returns JSON array of token strings.
|
|
func tokenizeText(text, lang string) string {
|
|
cText := C.CString(text)
|
|
cLang := C.CString(lang)
|
|
defer C.free(unsafe.Pointer(cText))
|
|
defer C.free(unsafe.Pointer(cLang))
|
|
|
|
cTokens := C.ThincNER_Tokenize(cText, cLang)
|
|
if cTokens == nil {
|
|
return ""
|
|
}
|
|
defer C.ThincNER_FreeString(cTokens)
|
|
return C.GoString(cTokens)
|
|
}
|
|
|
|
func getenv(key string) string {
|
|
return os.Getenv(key)
|
|
}
|
|
|
|
// DetectLanguage detects text language based on Unicode ranges.
|
|
// Pure Go, zero dependencies.
|
|
func DetectLanguage(text string) string {
|
|
han, hira, kata, latin := 0, 0, 0, 0
|
|
for _, r := range text {
|
|
switch {
|
|
case isHan(r):
|
|
han++
|
|
case isHiragana(r):
|
|
hira++
|
|
case isKatakana(r):
|
|
kata++
|
|
case isLatin(r):
|
|
latin++
|
|
}
|
|
}
|
|
total := han + hira + kata + latin
|
|
if total == 0 {
|
|
return "en"
|
|
}
|
|
// CJK majority
|
|
if float64(han+hira+kata)/float64(total) > 0.3 {
|
|
if hira+kata > han {
|
|
return "ja" // Japanese-heavy
|
|
}
|
|
if han > 0 {
|
|
return "zh" // Han-heavy → Chinese
|
|
}
|
|
return "en"
|
|
}
|
|
// Latin majority — default to en (user specifies de/fr/es/pt explicitly)
|
|
return "en"
|
|
}
|
|
|
|
func isHan(r rune) bool { return r >= 0x4E00 && r <= 0x9FFF }
|
|
func isHiragana(r rune) bool { return r >= 0x3040 && r <= 0x309F }
|
|
func isKatakana(r rune) bool { return r >= 0x30A0 && r <= 0x30FF }
|
|
func isLatin(r rune) bool { return (r >= 0x0041 && r <= 0x005A) || (r >= 0x0061 && r <= 0x007A) }
|