Files
ragflow/internal/service/nlp/wordnet.go
Jin Hai 70e9743ef1 RAGFlow go API server (#13240)
# RAGFlow Go Implementation Plan 🚀

This repository tracks the progress of porting RAGFlow to Go. We'll
implement core features and provide performance comparisons between
Python and Go versions.

## Implementation Checklist

- [x] User Management APIs
- [x] Dataset Management Operations
- [x] Retrieval Test
- [x] Chat Management Operations
- [x] Infinity Go SDK

---------

Signed-off-by: Jin Hai <haijin.chn@gmail.com>
Co-authored-by: Yingfeng Zhang <yingfeng.zhang@gmail.com>
2026-03-04 19:17:16 +08:00

573 lines
14 KiB
Go

//
// Copyright 2026 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 wordnet provides a Go implementation of NLTK's WordNet synsets functionality.
// This implementation reads WordNet 3.0 database files and provides synonym set lookup.
package nlp
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
)
// POS constants for WordNet parts of speech
const (
NOUN = "n"
VERB = "v"
ADJ = "a"
ADV = "r"
)
// Morphy substitution rules for each POS
var morphologicalSubstitutions = map[string][][2]string{
NOUN: {
{"s", ""},
{"ses", "s"},
{"ves", "f"},
{"xes", "x"},
{"zes", "z"},
{"ches", "ch"},
{"shes", "sh"},
{"men", "man"},
{"ies", "y"},
},
VERB: {
{"s", ""},
{"ies", "y"},
{"es", "e"},
{"es", ""},
{"ed", "e"},
{"ed", ""},
{"ing", "e"},
{"ing", ""},
},
ADJ: {
{"er", ""},
{"est", ""},
{"er", "e"},
{"est", "e"},
},
ADV: {},
}
// File suffix mapping for POS
var fileMap = map[string]string{
NOUN: "noun",
VERB: "verb",
ADJ: "adj",
ADV: "adv",
}
// Synset represents a WordNet synset (synonym set)
type Synset struct {
Name string
POS string
Offset int
Lemmas []string
Definition string
Examples []string
}
// WordNet is the main struct for WordNet operations
type WordNet struct {
wordNetDir string
lemmaPosOffsetMap map[string]map[string][]int
exceptionMap map[string]map[string][]string
dataFileCache map[string]*os.File
dataFileCacheOffset map[string]int64
fileMutexes map[string]*sync.Mutex // Mutex for each POS to ensure concurrency safety
}
// NewWordNet creates a new WordNet instance with the given WordNet directory
func NewWordNet(wordNetDir string) (*WordNet, error) {
wn := &WordNet{
wordNetDir: wordNetDir,
lemmaPosOffsetMap: make(map[string]map[string][]int),
exceptionMap: make(map[string]map[string][]string),
dataFileCache: make(map[string]*os.File),
dataFileCacheOffset: make(map[string]int64),
fileMutexes: make(map[string]*sync.Mutex),
}
// Initialize exception maps for all POS
for pos := range fileMap {
wn.exceptionMap[pos] = make(map[string][]string)
}
// Load exception files
if err := wn.loadExceptionMaps(); err != nil {
return nil, fmt.Errorf("failed to load exception maps: %w", err)
}
// Load lemma pos offset map
if err := wn.loadLemmaPosOffsetMap(); err != nil {
return nil, fmt.Errorf("failed to load lemma pos offset map: %w", err)
}
return wn, nil
}
// Close closes all cached file handles
func (wn *WordNet) Close() {
for pos, f := range wn.dataFileCache {
if mutex, ok := wn.fileMutexes[pos]; ok {
mutex.Lock()
f.Close()
mutex.Unlock()
} else {
f.Close()
}
}
}
// loadExceptionMaps loads the .exc files for each POS
func (wn *WordNet) loadExceptionMaps() error {
for pos, suffix := range fileMap {
filename := filepath.Join(wn.wordNetDir, suffix+".exc")
file, err := os.Open(filename)
if err != nil {
// It's okay if the file doesn't exist for some POS
continue
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) >= 2 {
// First field is the inflected form, rest are base forms
wn.exceptionMap[pos][fields[0]] = fields[1:]
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("error reading %s: %w", filename, err)
}
}
return nil
}
// loadLemmaPosOffsetMap loads the index files for each POS
func (wn *WordNet) loadLemmaPosOffsetMap() error {
for _, suffix := range fileMap {
filename := filepath.Join(wn.wordNetDir, "index."+suffix)
file, err := os.Open(filename)
if err != nil {
return fmt.Errorf("failed to open %s: %w", filename, err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
// Skip license header lines (lines starting with space)
if len(line) == 0 || line[0] == ' ' {
continue
}
fields := strings.Fields(line)
if len(fields) < 6 {
continue
}
// Parse index file format:
// lemma pos n_synsets n_pointers [pointers] n_senses n_ranked_synsets [synset_offsets...]
lemma := strings.ToLower(fields[0])
filePos := fields[1]
nSynsets, err := strconv.Atoi(fields[2])
if err != nil {
continue
}
nPointers, err := strconv.Atoi(fields[3])
if err != nil {
continue
}
// Calculate field positions
fieldIdx := 4
// Skip pointer symbols
for i := 0; i < nPointers && fieldIdx < len(fields); i++ {
fieldIdx++
}
// Read n_senses and n_ranked_synsets
if fieldIdx >= len(fields) {
continue
}
_, err = strconv.Atoi(fields[fieldIdx]) // n_senses
if err != nil {
continue
}
fieldIdx++
if fieldIdx >= len(fields) {
continue
}
_, err = strconv.Atoi(fields[fieldIdx]) // n_ranked_synsets
if err != nil {
continue
}
fieldIdx++
// Read synset offsets
var offsets []int
for i := 0; i < nSynsets && fieldIdx < len(fields); i++ {
offset, err := strconv.Atoi(fields[fieldIdx])
if err != nil {
continue
}
offsets = append(offsets, offset)
fieldIdx++
}
// Store in map
if wn.lemmaPosOffsetMap[lemma] == nil {
wn.lemmaPosOffsetMap[lemma] = make(map[string][]int)
}
wn.lemmaPosOffsetMap[lemma][filePos] = offsets
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("error reading %s: %w", filename, err)
}
}
return nil
}
// morphy performs morphological analysis to find base forms of a word
func (wn *WordNet) morphy(form string, pos string, checkExceptions bool) []string {
form = strings.ToLower(form)
exceptions := wn.exceptionMap[pos]
substitutions := morphologicalSubstitutions[pos]
// Helper function to apply substitution rules
applyRules := func(forms []string) []string {
var results []string
for _, f := range forms {
for _, sub := range substitutions {
old, new := sub[0], sub[1]
if strings.HasSuffix(f, old) {
base := f[:len(f)-len(old)] + new
results = append(results, base)
}
}
}
return results
}
// Helper function to filter forms that exist in WordNet
filterForms := func(forms []string) []string {
var results []string
seen := make(map[string]bool)
for _, f := range forms {
if posMap, ok := wn.lemmaPosOffsetMap[f]; ok {
if _, hasPos := posMap[pos]; hasPos {
if !seen[f] {
results = append(results, f)
seen[f] = true
}
}
}
}
return results
}
var forms []string
if checkExceptions {
if baseForms, ok := exceptions[form]; ok {
forms = baseForms
}
}
// If no exception found, apply rules
if len(forms) == 0 {
forms = applyRules([]string{form})
}
// Filter to keep only valid forms, also check original form
return filterForms(append([]string{form}, forms...))
}
// getDataFile returns the data file for a given POS, with caching
func (wn *WordNet) getDataFile(pos string) (*os.File, *sync.Mutex, error) {
if pos == "s" { // Adjective satellite uses the same file as adjective
pos = ADJ
}
// Get or create mutex for this POS
mutex, exists := wn.fileMutexes[pos]
if !exists {
mutex = &sync.Mutex{}
wn.fileMutexes[pos] = mutex
}
if file, ok := wn.dataFileCache[pos]; ok {
return file, mutex, nil
}
suffix, ok := fileMap[pos]
if !ok {
return nil, nil, fmt.Errorf("unknown POS: %s", pos)
}
filename := filepath.Join(wn.wordNetDir, "data."+suffix)
file, err := os.Open(filename)
if err != nil {
return nil, nil, fmt.Errorf("failed to open %s: %w", filename, err)
}
wn.dataFileCache[pos] = file
return file, mutex, nil
}
// parseDataLine parses a line from a data file and returns a Synset
func parseDataLine(line string, pos string) (*Synset, error) {
// Data file format:
// synset_offset lex_filenum ss_type w_cnt word lex_id [word lex_id...] p_cnt [ptr_symbol synset_offset pos src_trgt...] [frames...] | gloss
parts := strings.SplitN(line, "|", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid line format: no gloss separator")
}
dataPart := strings.TrimSpace(parts[0])
glossPart := strings.TrimSpace(parts[1])
// Parse gloss to get definition and examples
var definition string
var examples []string
// Remove quotes from examples
gloss := glossPart
for {
start := strings.Index(gloss, "\"")
if start == -1 {
break
}
end := strings.Index(gloss[start+1:], "\"")
if end == -1 {
break
}
end += start + 1
example := gloss[start+1 : end]
if len(examples) == 0 && start > 0 {
definition = strings.TrimSpace(gloss[:start])
}
examples = append(examples, example)
gloss = gloss[end+1:]
}
if definition == "" {
definition = strings.Trim(glossPart, "; ")
// Remove quoted examples from definition
definition = regexpRemoveQuotes(definition)
}
// Final cleanup: trim trailing semicolon and whitespace to match Python NLTK
definition = strings.TrimRight(definition, "; ")
// Parse data part
fields := strings.Fields(dataPart)
if len(fields) < 4 {
return nil, fmt.Errorf("invalid data line: too few fields")
}
offset, err := strconv.Atoi(fields[0])
if err != nil {
return nil, fmt.Errorf("invalid offset: %w", err)
}
// lexFilenum := fields[1] // Not used currently
ssType := fields[2]
wCnt, err := strconv.ParseInt(fields[3], 16, 32)
if err != nil {
return nil, fmt.Errorf("invalid word count: %w", err)
}
// Parse lemmas
var lemmas []string
fieldIdx := 4
for i := 0; i < int(wCnt) && fieldIdx+1 < len(fields); i++ {
lemma := fields[fieldIdx]
// Remove syntactic marker if present (e.g., "(a)" or "(p)")
if idx := strings.Index(lemma, "("); idx != -1 {
lemma = lemma[:idx]
}
// Keep original case for lemmas (Python NLTK preserves case)
lemmas = append(lemmas, lemma)
fieldIdx += 2 // skip lex_id
}
if len(lemmas) == 0 {
return nil, fmt.Errorf("no lemmas found")
}
// Build synset name from first lemma (Python uses lowercase in synset name)
senseIndex := 1 // Default to 1, would need to look up in index for actual sense number
name := fmt.Sprintf("%s.%s.%02d", strings.ToLower(lemmas[0]), ssType, senseIndex)
return &Synset{
Name: name,
POS: ssType,
Offset: offset,
Lemmas: lemmas,
Definition: definition,
Examples: examples,
}, nil
}
// regexpRemoveQuotes removes quoted strings from text (simplified version)
func regexpRemoveQuotes(s string) string {
var result strings.Builder
inQuote := false
for _, ch := range s {
if ch == '"' {
inQuote = !inQuote
continue
}
if !inQuote {
result.WriteRune(ch)
}
}
return strings.TrimSpace(strings.Trim(result.String(), "; "))
}
// synsetFromPosAndOffset retrieves a synset by POS and byte offset
func (wn *WordNet) synsetFromPosAndOffset(pos string, offset int) (*Synset, error) {
file, mutex, err := wn.getDataFile(pos)
if err != nil {
return nil, err
}
// Lock only for Seek and Read operations to minimize critical section
mutex.Lock()
// Seek to the offset
_, err = file.Seek(int64(offset), 0)
if err != nil {
mutex.Unlock()
return nil, fmt.Errorf("failed to seek to offset %d: %w", offset, err)
}
reader := bufio.NewReader(file)
line, err := reader.ReadString('\n')
mutex.Unlock() // Release lock immediately after reading
if err != nil {
return nil, fmt.Errorf("failed to read line at offset %d: %w", offset, err)
}
//if len(line) < 8 {
// fmt.Println(line)
//}
// Verify the offset matches
lineOffset := strings.TrimSpace(line[:8])
expectedOffset := fmt.Sprintf("%08d", offset)
if lineOffset != expectedOffset {
return nil, fmt.Errorf("offset mismatch: expected %s, got %s", expectedOffset, lineOffset)
}
synset, err := parseDataLine(line, pos)
if err != nil {
return nil, err
}
// Calculate the correct sense number by looking up the offset in the index
// This operation only accesses memory map, no need for file lock
senseNum := wn.findSenseNumber(synset.Lemmas[0], pos, offset)
if senseNum > 0 {
synset.Name = fmt.Sprintf("%s.%s.%02d", synset.Lemmas[0], synset.POS, senseNum)
}
return synset, nil
}
// findSenseNumber finds the sense number for a lemma in a given synset
func (wn *WordNet) findSenseNumber(lemma string, pos string, offset int) int {
lemma = strings.ToLower(lemma)
if posMap, ok := wn.lemmaPosOffsetMap[lemma]; ok {
if offsets, hasPos := posMap[pos]; hasPos {
for i, off := range offsets {
if off == offset {
return i + 1 // sense numbers are 1-indexed
}
}
}
}
return 1 // Default to 1 if not found
}
// Synsets returns all synsets for a given lemma and optional POS.
// If pos is empty, all parts of speech are searched.
// This is the main function equivalent to NLTK's wordnet.synsets()
func (wn *WordNet) Synsets(lemma string, pos string) []*Synset {
lemma = strings.ToLower(lemma)
var poses []string
if pos == "" {
poses = []string{NOUN, VERB, ADJ, ADV}
} else {
poses = []string{pos}
}
var results []*Synset
seen := make(map[string]bool)
for _, p := range poses {
// Get morphological forms
forms := wn.morphy(lemma, p, true)
for _, form := range forms {
if posMap, ok := wn.lemmaPosOffsetMap[form]; ok {
if offsets, hasPos := posMap[p]; hasPos {
for _, offset := range offsets {
// Create unique key to avoid duplicates
key := fmt.Sprintf("%s-%d", p, offset)
if !seen[key] {
seen[key] = true
synset, err := wn.synsetFromPosAndOffset(p, offset)
if err == nil {
results = append(results, synset)
}
}
}
}
}
}
}
return results
}
// Name returns the synset name (e.g., "dog.n.01")
func (s *Synset) NameStr() string {
return s.Name
}
// String returns a string representation of the synset
func (s *Synset) String() string {
return fmt.Sprintf("Synset('%s')", s.Name)
}