mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-12 14:45:42 +08:00
# 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>
573 lines
14 KiB
Go
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)
|
|
}
|