加上中文注释

This commit is contained in:
2026-04-08 17:48:05 +08:00
parent 6c2f5ad978
commit c154abf410
11 changed files with 830 additions and 560 deletions
+92 -66
View File
@@ -1,43 +1,45 @@
// Package analyzer provides keyword extraction and language detection.
// analyzer 包提供文本分词、关键词提取和语种检测功能。
//
// Keyword extraction uses gojieba for Chinese segmentation and simple token
// splitting for ASCII words. Language detection uses lingua-go (pure Go, no CGo).
// 分词策略:中文使用 gojieba(结巴分词 C++)进行精确/搜索模式分词;
// 纯 ASCII 英文按空格切分。语言检测使用 lingua-go(纯 Go,无外部模型文件)。
package analyzer
import (
"encoding/json"
"math"
"os"
"strings"
"sync"
"unicode"
"encoding/json" // JSON 反序列化(加载屏蔽词列表)
"math" // 数学运算(最小值、开方)
"os" // 文件系统操作(读取屏蔽词文件)
"strings" // 字符串操作
"sync" // 互斥锁(保护 jieba 的非线程安全调用)
"unicode" // Unicode 字符判断
"github.com/pemistahl/lingua-go"
"github.com/yanyiwu/gojieba"
"github.com/pemistahl/lingua-go" // 纯 Go 语言检测库(支持 75 种语言)
"github.com/yanyiwu/gojieba" // GojiebaC++ 结巴分词的 Go 封装
)
// Keyword holds a (word, weight) pair.
// Keyword 表示一个关键词及其权重。
type Keyword struct {
Word string `json:"word"`
Weight float32 `json:"weight"`
Word string `json:"word"` // 分词后的单词/词组
Weight float32 `json:"weight"` // TF(词频)权重,反映该词在页面中的重要程度
}
// Analyzer wraps jieba and lingua into a thread-safe analysis pipeline.
// Analyzer 封装结巴分词和语言检测器,提供线程安全的分析流水线。
type Analyzer struct {
jieba *gojieba.Jieba
detector lingua.LanguageDetector
stopWords map[string]bool
mu sync.Mutex // gojieba is not goroutine-safe
jieba *gojieba.Jieba // 结巴分词句柄(C++ 实现,非线程安全)
detector lingua.LanguageDetector // 语言检测器(lingua-go
stopWords map[string]bool // 屏蔽词集合(标点符号、停用词等)
mu sync.Mutex // 保护 jieba 调用的互斥锁
}
// New creates an Analyzer.
// stopWordsPath is the JSON file with punctuation/stop words (may be empty string).
// modelPath is ignored (kept for API compatibility; lingua-go uses built-in data).
// New 创建一个 Analyzer 实例。
// modelPath:语言模型路径(已废弃,lingua-go 使用内置数据,无需外部文件);
// stopWordsPath:屏蔽词 JSON 文件路径(不含文件时传入空字符串)。
func New(modelPath, stopWordsPath string) (*Analyzer, error) {
// 初始化结巴分词(加载词典,需调用 Free 释放)
j := gojieba.NewJieba()
// Build a lingua detector that covers the languages we care about.
// AllLanguages() covers 75 languages including Chinese, Japanese, Korean, etc.
// 构建 lingua 语言检测器,覆盖所有 75 种语言(含中日韩英等)
// MinimumRelativeDistance=0.15:降低检测阈值,提高短文本召回率
detector := lingua.NewLanguageDetectorBuilder().
FromAllLanguages().
WithMinimumRelativeDistance(0.15).
@@ -52,12 +54,14 @@ func New(modelPath, stopWordsPath string) (*Analyzer, error) {
}, nil
}
// Close releases resources held by the analyzer.
// Close 释放 Analyzer 持有的资源(主要是结巴分词的 C++ 内存)。
func (a *Analyzer) Close() {
a.jieba.Free()
}
// loadStopWords reads a JSON array of stop-word strings.
// loadStopWords 从 JSON 文件加载屏蔽词列表到 map 中(O(1) 查找)。
// JSON 格式:字符串数组,如 ["", "。", "的", "了"]。
// 文件不存在或格式错误时返回空 map。
func loadStopWords(path string) map[string]bool {
if path == "" {
return map[string]bool{}
@@ -73,23 +77,29 @@ func loadStopWords(path string) map[string]bool {
}
m := make(map[string]bool, len(words))
for _, w := range words {
m[strings.ToLower(w)] = true
m[strings.ToLower(w)] = true // 转为小写存储,大小写不敏感
}
return m
}
// Tokenize segments a string into tokens using jieba for CJK and space-split for ASCII.
// Tokenize 将字符串分词为词列表。
// searchMode=true:搜索模式分词(更细粒度,适合搜索查询);
// searchMode=false:精确模式分词(适合页面内容分析)。
// 策略:纯 ASCII 字母数字按空格切分;含中文/其他字符的片段交给结巴处理。
func (a *Analyzer) Tokenize(s string, searchMode bool) []string {
// 超长文本截断(jieba 对极长文本处理效率下降)
if len(s) > 10000 {
s = s[:10000]
}
// Sanitize: replace invalid UTF-8 sequences so gojieba (C++) never sees decode errors.
// 清洗非 UTF-8 字节,防止 gojieba C++ 层报 "decode failed" 错误
s = strings.ToValidUTF8(s, "")
var result []string
for _, part := range strings.Fields(s) {
if isASCIIAlnum(part) {
// 纯 ASCII 片段直接保留,不走 jieba
result = append(result, part)
} else {
// 非 ASCII(含中文/日文等):加锁后调用 jieba 分词
a.mu.Lock()
var tokens []string
if searchMode {
@@ -104,71 +114,84 @@ func (a *Analyzer) Tokenize(s string, searchMode bool) []string {
return result
}
// Normalize strips non-alphanumeric, non-CJK characters and lowercases.
// Normalize 标准化字符串:去除非字母数字非 CJK 字符,并转为小写。
// 用于分词后清洗,使不同大小写/全角格式的同一词归一为统一形式。
func Normalize(s string) string {
var b strings.Builder
for _, r := range s {
// 保留:英文字母(a-zA-Z0-9)和 CJK 统一汉字(0x4e00-0x9fa5
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || (r >= 0x4e00 && r <= 0x9fa5) {
if r >= 'A' && r <= 'Z' {
b.WriteRune(unicode.ToLower(r))
b.WriteRune(unicode.ToLower(r)) // 大写转小写
} else {
b.WriteRune(r)
b.WriteRune(r) // 直接写入
}
}
}
return b.String()
}
// weightedTokens builds a map of token→weight from a text with an optional weight multiplier.
// weightedTokens 对一段文本计算每个分词的 TF(词频)权重,返回 token→权重 map。
// w 为权重倍数(标题权重 1.0,描述权重 0.5,正文权重 1.0)。
func (a *Analyzer) weightedTokens(text string, w float32) map[string]float32 {
tokens := a.Tokenize(text, false)
d := make(map[string]float32)
// 归一化分母:至少 8,防止只有 1-2 个词时权重过大
n := math.Max(8, float64(len(tokens)))
counts := make(map[string]int)
for _, t := range tokens {
t = Normalize(t)
// 跳过空词、屏蔽词、超长词(超过 32 字符)
if t == "" || a.stopWords[t] || len(t) > 32 {
continue
}
counts[t]++
}
// 权重计算:min(0.2, 词频/总词数) × 权重倍数
// 即单词权重上限 0.2,避免某个词过度主导
for k, v := range counts {
d[k] = float32(math.Min(0.2, float64(v)/n)) * w
}
return d
}
// Analyze extracts weighted keywords from title, description, and body text.
// Returns a slice sorted by weight descending.
// Analyze 从标题、描述、正文三段文本中提取关键词并计算综合权重。
// 标题权重 1.0,描述权重 0.5,正文权重 1.0,三者权重相加后排序返回。
// 返回按权重降序排列的关键词切片。
func (a *Analyzer) Analyze(title, description, text string) []Keyword {
// 分别计算三段的词权重
maps := []map[string]float32{
a.weightedTokens(title, 1.0),
a.weightedTokens(description, 0.5),
a.weightedTokens(text, 1.0),
a.weightedTokens(title, 1.0), // 标题权重最高
a.weightedTokens(description, 0.5), // 描述权重中等
a.weightedTokens(text, 1.0), // 正文权重同标题
}
// 合并三段权重:先去重建立 key 集合
combined := make(map[string]float32)
for _, m := range maps {
for k := range m {
combined[k] = 0
}
}
// 再累加各段权重
for k := range combined {
for _, m := range maps {
combined[k] += m[k]
}
}
// 转换为结果切片
result := make([]Keyword, 0, len(combined))
for k, v := range combined {
result = append(result, Keyword{Word: k, Weight: v})
}
sortKeywords(result)
sortKeywords(result) // 按权重降序
return result
}
// Segment returns search-mode tokens for a query string.
// Segment 对查询字符串进行搜索模式分词并标准化(用于搜索场景)。
// 去除屏蔽词和超长词,返回有效分词列表。
func (a *Analyzer) Segment(query string, searchMode bool) []string {
tokens := a.Tokenize(query, searchMode)
var result []string
@@ -182,34 +205,35 @@ func (a *Analyzer) Segment(query string, searchMode bool) []string {
return result
}
// linguaToISO639 maps lingua.Language to the ISO 639-1 code used by the rest of the engine.
// Returns "" for unknown or unsupported languages.
// linguaToISO639 lingua 的语言枚举映射为 ISO 639-1 两字母代码。
// 只包含引擎关心的主要语种,未知语种返回空字符串。
var linguaToISO639 = map[lingua.Language]string{
lingua.Chinese: "zh",
lingua.English: "en",
lingua.Japanese: "ja",
lingua.Korean: "ko",
lingua.French: "fr",
lingua.German: "de",
lingua.Spanish: "es",
lingua.Portuguese: "pt",
lingua.Italian: "it",
lingua.Russian: "ru",
lingua.Arabic: "ar",
lingua.Hindi: "hi",
lingua.Dutch: "nl",
lingua.Polish: "pl",
lingua.Swedish: "sv",
lingua.Turkish: "tr",
lingua.Vietnamese: "vi",
lingua.Thai: "th",
lingua.Indonesian: "id",
lingua.Malay: "ms",
lingua.Chinese: "zh", // 中文
lingua.English: "en", // 英语
lingua.Japanese: "ja", // 日语
lingua.Korean: "ko", // 韩语
lingua.French: "fr", // 法语
lingua.German: "de", // 德语
lingua.Spanish: "es", // 西班牙语
lingua.Portuguese: "pt", // 葡萄牙语
lingua.Italian: "it", // 意大利语
lingua.Russian: "ru", // 俄语
lingua.Arabic: "ar", // 阿拉伯语
lingua.Hindi: "hi", // 印地语
lingua.Dutch: "nl", // 荷兰语
lingua.Polish: "pl", // 波兰语
lingua.Swedish: "sv", // 瑞典语
lingua.Turkish: "tr", // 土耳其语
lingua.Vietnamese: "vi", // 越南语
lingua.Thai: "th", // 泰语
lingua.Indonesian: "id", // 印尼语
lingua.Malay: "ms", // 马来语
}
// DetectLanguage returns the ISO 639-1 language code for the text, or "".
// DetectLanguage 检测文本语种,返回 ISO 639-1 两字母代码,或空字符串表示无法判断。
// 截断到 2000 字符以提升检测速度(lingua 对长文本处理较慢)。
func (a *Analyzer) DetectLanguage(text string) string {
text = strings.ReplaceAll(text, "\n", " ")
text = strings.ReplaceAll(text, "\n", " ") // 换行替换为空格
if len(text) > 2000 {
text = text[:2000]
}
@@ -218,16 +242,17 @@ func (a *Analyzer) DetectLanguage(text string) string {
}
lang, exists := a.detector.DetectLanguageOf(text)
if !exists {
return ""
return "" // 检测失败
}
if code, ok := linguaToISO639[lang]; ok {
return code
return code // 映射为 ISO 代码
}
return ""
}
// ---- sorting ----
// ---- 排序算法 ----
// sortKeywords 对关键词切片按权重降序排列(插入排序,适合小规模数据)。
func sortKeywords(kws []Keyword) {
for i := 1; i < len(kws); i++ {
key := kws[i]
@@ -240,6 +265,7 @@ func sortKeywords(kws []Keyword) {
}
}
// isASCIIAlnum 判断字符串是否全部由 ASCII 字母或数字组成。
func isASCIIAlnum(s string) bool {
for _, r := range s {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {