mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
/**
|
||
* 分词引擎(Node.js 实现,依赖 @node-rs/jieba)
|
||
*
|
||
* 中文使用 jieba,英文/日语使用 Intl.Segmenter。
|
||
* 类型、停用词、文本处理工具来自 @openchatlab/core。
|
||
*/
|
||
|
||
import * as fs from 'fs'
|
||
import * as path from 'path'
|
||
import { createRequire } from 'module'
|
||
import type {
|
||
SupportedLocale,
|
||
PosFilterMode,
|
||
DictType,
|
||
SegmentOptions,
|
||
BatchSegmentOptions,
|
||
BatchSegmentResult,
|
||
} from '@openchatlab/core'
|
||
import { MEANINGFUL_POS_TAGS, isStopword, cleanText, isValidWord } from '@openchatlab/core'
|
||
|
||
interface JiebaInstance {
|
||
cut: (text: string, hmm?: boolean) => string[]
|
||
tag: (text: string) => Array<{ tag: string; word: string }>
|
||
}
|
||
|
||
interface JiebaCacheEntry {
|
||
instance: JiebaInstance
|
||
/** Whether the instance was built from a custom dict file on disk. */
|
||
fromDiskDict: boolean
|
||
}
|
||
|
||
let _nlpDir: string | null = null
|
||
const jiebaInstances = new Map<DictType, JiebaCacheEntry>()
|
||
const require = createRequire(import.meta.url)
|
||
|
||
/**
|
||
* 设置自定义词库目录路径(由应用初始化时调用)
|
||
*/
|
||
export function initNlpDir(nlpDir: string): void {
|
||
_nlpDir = nlpDir
|
||
}
|
||
|
||
export function getNlpDir(): string | null {
|
||
return _nlpDir
|
||
}
|
||
|
||
function existsDictOnDisk(dictId: string): boolean {
|
||
if (!_nlpDir) return false
|
||
return fs.existsSync(path.join(_nlpDir, `${dictId}.dict`))
|
||
}
|
||
|
||
function tryLoadDictFromDisk(dictId: string): Buffer | null {
|
||
if (!_nlpDir) return null
|
||
const dictPath = path.join(_nlpDir, `${dictId}.dict`)
|
||
if (!fs.existsSync(dictPath)) return null
|
||
try {
|
||
return fs.readFileSync(dictPath)
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取 Jieba 实例(支持多词库)
|
||
*/
|
||
export function getJieba(dictType: DictType = 'default'): JiebaInstance {
|
||
const effectiveType = dictType === 'default' ? 'zh-CN' : dictType
|
||
const cached = jiebaInstances.get(effectiveType)
|
||
if (cached) {
|
||
// Only invalidate when the on-disk dict state changed since the instance
|
||
// was built (dict added or removed). Instances built without a disk dict
|
||
// must stay cached, otherwise bulk NLP hot paths would rebuild
|
||
// a Jieba instance per call.
|
||
if (cached.fromDiskDict === existsDictOnDisk(effectiveType)) return cached.instance
|
||
jiebaInstances.delete(effectiveType)
|
||
console.error(`[NLP] jieba cache invalidated (dict ${cached.fromDiskDict ? 'removed' : 'added'}): ${effectiveType}`)
|
||
}
|
||
|
||
try {
|
||
const { Jieba } = require('@node-rs/jieba')
|
||
let instance: JiebaInstance
|
||
const diskDict = tryLoadDictFromDisk(effectiveType)
|
||
if (diskDict) {
|
||
instance = Jieba.withDict(diskDict)
|
||
console.error(`[NLP] jieba dict loaded: ${effectiveType} (${diskDict.length} bytes)`)
|
||
} else {
|
||
instance = new Jieba()
|
||
console.warn(`[NLP] jieba dict missing: ${effectiveType}, fallback to built-in tokenizer`)
|
||
}
|
||
jiebaInstances.set(effectiveType, { instance, fromDiskDict: !!diskDict })
|
||
return instance
|
||
} catch (error) {
|
||
console.error(`[NLP] Failed to load jieba module (dict=${effectiveType}):`, error)
|
||
throw new Error(`jieba 模块加载失败 (${effectiveType})`)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清除指定词库的缓存实例(词库更新后调用)
|
||
*/
|
||
export function clearJiebaInstance(dictType: DictType): void {
|
||
jiebaInstances.delete(dictType)
|
||
console.error(`[NLP] jieba instance cleared: ${dictType}`)
|
||
}
|
||
|
||
function segmentChinese(
|
||
text: string,
|
||
options: { posFilterMode?: PosFilterMode; customPosTags?: string[]; dictType?: DictType } = {}
|
||
): string[] {
|
||
const { posFilterMode = 'meaningful', customPosTags, dictType = 'default' } = options
|
||
const cleaned = cleanText(text)
|
||
if (!cleaned) return []
|
||
|
||
try {
|
||
const jieba = getJieba(dictType)
|
||
if (posFilterMode === 'all') return jieba.cut(cleaned, false)
|
||
const tagged = jieba.tag(cleaned)
|
||
const allowedTags = posFilterMode === 'custom' && customPosTags ? new Set(customPosTags) : MEANINGFUL_POS_TAGS
|
||
return tagged.filter((item) => allowedTags.has(item.tag)).map((item) => item.word)
|
||
} catch (error) {
|
||
console.error('[NLP] Chinese segmentation failed:', error)
|
||
try {
|
||
return getJieba('default').cut(cleanText(text), false)
|
||
} catch {
|
||
return cleaned.split('')
|
||
}
|
||
}
|
||
}
|
||
|
||
function segmentEnglish(text: string): string[] {
|
||
const cleaned = cleanText(text)
|
||
if (!cleaned) return []
|
||
try {
|
||
const segmenter = new Intl.Segmenter('en', { granularity: 'word' })
|
||
return [...segmenter.segment(cleaned)].filter((s) => s.isWordLike).map((s) => s.segment.toLowerCase())
|
||
} catch {
|
||
return cleaned
|
||
.toLowerCase()
|
||
.split(/\s+/)
|
||
.filter((w) => w.length > 0)
|
||
}
|
||
}
|
||
|
||
function segmentJapanese(text: string): string[] {
|
||
const cleaned = cleanText(text)
|
||
if (!cleaned) return []
|
||
try {
|
||
const segmenter = new Intl.Segmenter('ja', { granularity: 'word' })
|
||
return [...segmenter.segment(cleaned)].filter((s) => s.isWordLike).map((s) => s.segment)
|
||
} catch {
|
||
return cleaned.split('').filter((ch) => ch.trim().length > 0)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 通用分词入口
|
||
*/
|
||
export function segment(text: string, locale: SupportedLocale, options: SegmentOptions = {}): string[] {
|
||
const {
|
||
minLength,
|
||
posFilterMode = 'meaningful',
|
||
customPosTags,
|
||
enableStopwords = true,
|
||
dictType = 'default',
|
||
} = options
|
||
const isChinese = locale.startsWith('zh')
|
||
const isJapanese = locale === 'ja-JP'
|
||
const defaultMinLength = isChinese || isJapanese ? 2 : 3
|
||
const effectiveMinLength = minLength ?? defaultMinLength
|
||
|
||
let words: string[]
|
||
if (isChinese) {
|
||
words = segmentChinese(text, { posFilterMode, customPosTags, dictType })
|
||
} else if (isJapanese) {
|
||
words = segmentJapanese(text)
|
||
} else {
|
||
words = segmentEnglish(text)
|
||
}
|
||
|
||
return words.filter((word) => isValidWord(word, locale, effectiveMinLength, enableStopwords, isStopword))
|
||
}
|
||
|
||
/**
|
||
* 批量分词并统计词频
|
||
*/
|
||
export function batchSegmentWithFrequency(
|
||
texts: string[],
|
||
locale: SupportedLocale,
|
||
options: BatchSegmentOptions = {}
|
||
): BatchSegmentResult {
|
||
const {
|
||
minLength,
|
||
minCount = 2,
|
||
topN = 100,
|
||
posFilterMode,
|
||
customPosTags,
|
||
enableStopwords,
|
||
dictType,
|
||
excludeWords,
|
||
} = options
|
||
const wordFrequency = new Map<string, number>()
|
||
const excludeSet = excludeWords?.length ? new Set(excludeWords.map((w) => w.toLowerCase())) : null
|
||
|
||
for (const text of texts) {
|
||
const words = segment(text, locale, { minLength, posFilterMode, customPosTags, enableStopwords, dictType })
|
||
for (const word of words) {
|
||
if (excludeSet && excludeSet.has(word.toLowerCase())) continue
|
||
wordFrequency.set(word, (wordFrequency.get(word) || 0) + 1)
|
||
}
|
||
}
|
||
|
||
const filtered = new Map<string, number>()
|
||
let totalWords = 0
|
||
for (const [word, count] of wordFrequency) {
|
||
if (count >= minCount) {
|
||
filtered.set(word, count)
|
||
totalWords += count
|
||
}
|
||
}
|
||
|
||
const sorted = [...filtered.entries()].sort((a, b) => b[1] - a[1]).slice(0, topN)
|
||
return { words: new Map(sorted), uniqueWords: filtered.size, totalWords }
|
||
}
|
||
|
||
/**
|
||
* 中文单遍分词:一次 jieba.tag 同时产出词频与词性统计。
|
||
*
|
||
* 仅适用于中文 meaningful/custom 模式(all 模式走 jieba.cut,无词性)。
|
||
* 输出与 `batchSegmentWithFrequency` + `collectPosTagStats` 组合完全一致,
|
||
* 用于消除对同一批文本重复分词的开销。
|
||
*/
|
||
export function batchSegmentChineseWithStats(
|
||
texts: string[],
|
||
locale: SupportedLocale,
|
||
options: BatchSegmentOptions = {}
|
||
): BatchSegmentResult & { posTagStats: Map<string, number> } {
|
||
const {
|
||
minLength,
|
||
minCount = 2,
|
||
topN = 100,
|
||
posFilterMode = 'meaningful',
|
||
customPosTags,
|
||
enableStopwords = true,
|
||
dictType = 'default',
|
||
excludeWords,
|
||
} = options
|
||
|
||
const effectiveMinLength = minLength ?? 2
|
||
const allowedTags = posFilterMode === 'custom' && customPosTags ? new Set(customPosTags) : MEANINGFUL_POS_TAGS
|
||
const excludeSet = excludeWords?.length ? new Set(excludeWords.map((w) => w.toLowerCase())) : null
|
||
|
||
const wordFrequency = new Map<string, number>()
|
||
const posTagStats = new Map<string, number>()
|
||
|
||
try {
|
||
const jieba = getJieba(dictType)
|
||
for (const text of texts) {
|
||
const cleaned = cleanText(text)
|
||
if (!cleaned) continue
|
||
for (const { tag, word } of jieba.tag(cleaned)) {
|
||
if (!isValidWord(word, locale, effectiveMinLength, enableStopwords, isStopword)) continue
|
||
// 词性统计覆盖全部有效词(与 collectPosTagStats 一致)
|
||
posTagStats.set(tag, (posTagStats.get(tag) || 0) + 1)
|
||
// 词频仅统计命中允许词性的词(与 batchSegmentWithFrequency 一致)
|
||
if (!allowedTags.has(tag)) continue
|
||
if (excludeSet && excludeSet.has(word.toLowerCase())) continue
|
||
wordFrequency.set(word, (wordFrequency.get(word) || 0) + 1)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('[NLP] Chinese single-pass segmentation failed:', error)
|
||
}
|
||
|
||
const filtered = new Map<string, number>()
|
||
let totalWords = 0
|
||
for (const [word, count] of wordFrequency) {
|
||
if (count >= minCount) {
|
||
filtered.set(word, count)
|
||
totalWords += count
|
||
}
|
||
}
|
||
|
||
const sorted = [...filtered.entries()].sort((a, b) => b[1] - a[1]).slice(0, topN)
|
||
return { words: new Map(sorted), uniqueWords: filtered.size, totalWords, posTagStats }
|
||
}
|
||
|
||
/**
|
||
* 收集文本的词性统计
|
||
*/
|
||
export function collectPosTagStats(
|
||
texts: string[],
|
||
minWordLength: number = 2,
|
||
enableStopwords: boolean = true,
|
||
dictType: DictType = 'default'
|
||
): Map<string, number> {
|
||
const posStats = new Map<string, number>()
|
||
try {
|
||
const jieba = getJieba(dictType)
|
||
for (const text of texts) {
|
||
const cleaned = cleanText(text)
|
||
if (!cleaned) continue
|
||
const tagged = jieba.tag(cleaned)
|
||
for (const item of tagged) {
|
||
if (!isValidWord(item.word, 'zh-CN', minWordLength, enableStopwords, isStopword)) continue
|
||
posStats.set(item.tag, (posStats.get(item.tag) || 0) + 1)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('[NLP] Failed to collect POS stats:', error)
|
||
}
|
||
return posStats
|
||
}
|
||
|
||
import { POS_TAG_DEFINITIONS } from '@openchatlab/core'
|
||
import type { PosTagInfo } from '@openchatlab/core'
|
||
|
||
/**
|
||
* 获取所有词性标签信息
|
||
*/
|
||
export function getPosTagDefinitions(): PosTagInfo[] {
|
||
return POS_TAG_DEFINITIONS
|
||
}
|