Documentation
¶
Index ¶
- func NewGseSearchAnalyzer() *analysis.Analyzer
- func NewGseSearchTokenizer() analysis.Tokenizer
- func NewGseStandardAnalyzer() *analysis.Analyzer
- func NewGseStandardTokenizer() analysis.Tokenizer
- func NewGseStopTokenFilter() analysis.TokenFilter
- func ReleaseToken(t *Token)
- type AnalysisConfig
- type Backend
- type CNGramProcessor
- type CorrectProcessor
- type Engine
- type EngineConfig
- type ForceNoSplitProcessor
- type ForceSplitProcessor
- type GseBackend
- type JiebaBackend
- type LowerCaseProcessor
- type PinyinConfig
- type PinyinProcessor
- type Processor
- type STConvertProcessor
- type StopProcessor
- type SynonymConfig
- type SynonymMode
- type SynonymProcessor
- type Token
- type TokenStream
- type UniqueProcessor
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func NewGseSearchAnalyzer ¶
─── NewGseSearchAnalyzer ─────────────────────────────────── 兼容旧的 gse_search analyzer 注册
func NewGseSearchTokenizer ¶
func NewGseStandardAnalyzer ¶
─── NewGseStandardAnalyzer ───────────────────────────────── 兼容旧的 gse_standard analyzer 注册 旧代码在 pkg/uquery/analysis/analyzer.go 中注册了 "gse_standard" 通过这个函数,旧代码可以无缝切换到新引擎
⚡ 关键知识点:
这个函数返回的是 bluge 的 *analysis.Analyzer 但内部使用的是我们的新版 Engine 所以旧代码不需要任何修改就能用新引擎!
func NewGseStandardTokenizer ¶
─── 兼容 Tokenizer ───────────────────────────────────────── 兼容旧的 tokenizer 注册(gse_standard, gse_search)
func NewGseStopTokenFilter ¶
func NewGseStopTokenFilter() analysis.TokenFilter
─── NewGseStopTokenFilter ────────────────────────────────── 兼容旧的 gse_stop token filter 注册 把新版 StopProcessor 包装成 analysis.TokenFilter 接口
func ReleaseToken ¶
func ReleaseToken(t *Token)
ReleaseToken 把用完的 Token 归还到对象池 归还前清空所有字段,避免下次取到时还有旧数据 这叫做"对象清理"(zeroing),是使用对象池的必备操作
面试题:如果不清理会怎样? → 下次 Get() 拿到的 Token 会包含上一次的数据,导致"数据污染"
Term 不为 nil、Start/End 指向上次的位置,bug 很难排查
Types ¶
type AnalysisConfig ¶
type AnalysisConfig struct {
// ═══ 基础配置 ═══════════════════════════════════════
Backend string // 分词后端:"gse" 或 "jieba"
Concurrency int // 分词并发 goroutine 数
// ═══ 词典路径 ═══════════════════════════════════════
// 所有词典文件路径,支持相对路径和绝对路径
DictPath string // 主词典
UserDictPath string // 用户自定义词典
SynonymPath string // 同义词词典
StopPath string // 停用词词典
PinyinPath string // 拼音词典
PosKeepPath string // 词性保留配置
ForceSplitPath string // 强制分词词典
ForceNoSplitPath string // 禁止分词词典
CorrectPath string // 纠错词典
WeightPath string // 权重词典
DomainDicts []string // 领域词库列表,如 ["medical", "legal"]
// ═══ Pipeline 配置 ══════════════════════════════════
PinyinFull bool // 全拼扩展:"手机"→"shouji"
PinyinFirstLetter bool // 首字母扩展:"手机"→"sj"
PinyinKeepOriginal bool // 拼音扩展后是否保留原词
SynonymMode string // 同义词模式:"bidirectional" 或 "unidirectional"
NgramMin int // N-gram 最小长度
NgramMax int // N-gram 最大长度
STConvertDir string // 繁简转换方向:"s2t"(简→繁) 或 "t2s"(繁→简)
// ═══ 停用词配置 ════════════════════════════════════
StopEnable bool // 是否启用停用词过滤
StopPosFilter bool // 是否启用词性过滤
StopMinTokenLen int // 最小 token 长度,小于此值被过滤
// ═══ 性能配置 ═══════════════════════════════════════
CacheSize int // 分词结果缓存条数
CacheTTLSec int // 缓存有效期(秒)
MaxInputLen int // 单次分词最大字符数
// ═══ 调试配置 ═══════════════════════════════════════
Debug bool // 是否输出 Debug 日志
SlowThresholdMs int // 慢分词阈值(毫秒),超此值记录慢查询
Explain bool // 是否启用 Explain 模式
// ═══ 热加载配置 ════════════════════════════════════
DictWatch bool // 是否启用词典热加载(修改词典自动生效)
DictWatchDebounceMs int // 热加载防抖时间(毫秒)
}
─── AnalysisConfig ───────────────────────────────────────── AnalysisConfig 分词引擎配置 所有可调整的参数都集中在这里
每个字段都有详细的注释,方便新人理解
分段说明:
- 基础配置(Backend, Concurrency)
- 词典路径(各种词典的文件位置)
- Pipeline 配置(拼音/同义词/N-gram等开关)
- 停用词配置
- 性能配置(缓存大小/超时)
- 调试配置
- 热加载配置
func ConfigFromEnv ¶
func ConfigFromEnv() AnalysisConfig
─── ConfigFromEnv ────────────────────────────────────────── ConfigFromEnv 从环境变量加载配置 读取 ZINC_ANALYSIS_* 环境变量覆盖默认值
func DefaultConfig ¶
func DefaultConfig() AnalysisConfig
─── DefaultConfig ────────────────────────────────────────── DefaultConfig 返回默认配置 这是"安全"的默认值,适合大多数用户
默认配置原则:
- 能正常工作(不开任何高级功能也能用)
- 不过度消耗资源(CacheSize=10000,Concurrency=4)
- 不产生过多日志(Debug=false)
如果要调整,可以直接修改返回的结构体字段
type Backend ¶
type Backend interface {
// Name 返回后端的名称
// 用于区分不同的后端,比如 "gse"、"jieba"
Name() string
// Cut 对文本进行分词,返回 Token 切片
//
// 参数:
// text ← 要分词的文本,比如 "我爱北京天安门"
// isSearch ← 是否为搜索模式
// true :搜索模式,会做子词扩展(提高召回率)
// false:索引模式,精确分词(提高准确率)
//
// 返回值:
// []Token ← 分词结果,比如 ["我", "爱", "北京", "天安门"]
//
// 为什么搜索模式需要子词扩展?
// 比如用户搜"复仇者联盟",但文档索引了"复仇者"和"联盟"
// 如果索引模式拆了但搜索模式不分,永远搜不到
// 所以搜索模式要拆得更细
//
// 特别注意:
// 返回的 Token.Term 应该直接引用 text 的内存,不要复制
// 这样可以"零拷贝"(zero-copy),性能更好
// 但引用意味着调用方不能修改 text
Cut(text []byte, isSearch bool) []Token
}
─── Backend 接口 ─────────────────────────────────────────── Backend 是分词后端的"合同"(contract) 任何实现了这两个方法的结构体,都可以当作分词后端来用
Go 的接口和 Java/C# 的最大区别:
Java:需要显式写 implements XXX Go:不需要!只要结构体有 Name() 和 Cut() 方法,就自动满足 Backend 接口 这叫"鸭子类型"(Duck Typing):如果它走路像鸭子,叫起来像鸭子, 那它就是鸭子
目前我们有两个后端实现了这个接口:
- GseBackend → 基于 go-ego/gse,轻量快速
- JiebaBackend → 基于 fumiama/jieba,精度更高
- 你也可以自己实现一个 Backend,然后注册到引擎里
type CNGramProcessor ¶
─── CNGramProcessor ──────────────────────────────────────── CNGramProcessor 实现了 Processor 接口 对中文字词做 2-3 gram 扩展(默认配置)
比如输入 ["复仇者联盟"],输出变成:
["复仇者联盟", "复仇", "仇者", "复仇者", "者联", "联盟"]
为什么原始 token 也保留?
因为 N-gram 只是"辅助",不能替代精确分词 如果用户搜"复仇者联盟"这个完整词,精确匹配分数更高
func NewCNGramProcessor ¶
func NewCNGramProcessor() *CNGramProcessor
─── NewCNGramProcessor ───────────────────────────────────── 默认配置:2-3 gram 为什么默认是 2 和 3?
1-gram 就是单字,切出来太碎,噪音大 4-gram 以上变化太少,收益不高 2-gram(bi-gram)和 3-gram(tri-gram)是最常用的
func (*CNGramProcessor) Name ¶
func (p *CNGramProcessor) Name() string
func (*CNGramProcessor) Process ¶
func (p *CNGramProcessor) Process(tokens []Token) []Token
─── Process ──────────────────────────────────────────────── Process 对中文字词做 N-gram 扩展
算法:
对于每个 Token,遍历所有可能的起始位置 i 对于每个起始位置,取从 i 开始、长度在 [MinGram, MaxGram] 的子串
比如 "复仇者联盟"(4 个字),MinGram=2, MaxGram=3:
i=0, n=2: "复仇" ← 2-gram 从第 0 个字开始 i=1, n=2: "仇者" ← 2-gram 从第 1 个字开始 i=2, n=2: "者联" ← 2-gram 从第 2 个字开始 i=3, n=2: 超出范围(i+n > len) i=0, n=3: "复仇者" i=1, n=3: "仇者联" i=2, n=3: "者联盟"
type CorrectProcessor ¶
type CorrectProcessor struct {
// contains filtered or unexported fields
}
─── CorrectProcessor ─────────────────────────────────────── 纠错处理器:把错误写法映射为正确写法
工作原理很简单:
查纠错词典,如果 Token 在词典中,替换为正确的写法
纠错词典文件格式(correct.txt):
不锈刚=不锈钢 手鸡=手机 门砍=门槛
func NewCorrectProcessor ¶
func NewCorrectProcessor(path string) *CorrectProcessor
─── NewCorrectProcessor ──────────────────────────────────── 从纠错词典文件加载映射表
func (*CorrectProcessor) Name ¶
func (p *CorrectProcessor) Name() string
func (*CorrectProcessor) Process ¶
func (p *CorrectProcessor) Process(tokens []Token) []Token
Process 对 Token 进行纠错,如果有匹配就替换为正确的词
type Engine ¶
type Engine struct {
// contains filtered or unexported fields
}
─── Engine ───────────────────────────────────────────────── Engine 是分词引擎的核心结构体 它像一个"工厂流水线":
- Backend 负责"原材料切割"(把文本切成词)
- Pipeline 负责"加工处理"(去停用词、加拼音等)
Engine 有两个"工作模式":
- IndexTokens:索引模式,精确分词,不要冗余
- SearchTokens:搜索模式,子词扩展,提高召回
为什么索引和搜索要用不同的 pipeline?
索引时要精确,不要噪音 搜索时要全面,宁可多不可少 比如"复仇者联盟": 索引时只存"复仇者联盟" 搜索时扩展出"复仇者"、"联盟"、"复联" 这样用户搜"复仇者"也能找到
func NewEngine ¶
func NewEngine(cfg EngineConfig) *Engine
─── NewEngine ────────────────────────────────────────────── NewEngine 创建分词引擎 接收一个 EngineConfig 结构体,返回 Engine 指针
使用示例:
engine := NewEngine(EngineConfig{
Backend: NewGseBackend(),
IndexPipeline: []Processor{NewStopProcessor(nil)},
SearchPipeline: []Processor{NewStopProcessor(nil)},
})
知识点:为什么返回指针而不是值?
Engine 中包含了 Processor 切片,切片本身就是引用类型 如果返回值,拷贝整个结构体反而浪费 而且指针可以保证所有引用 Engine 的地方看到的是同一个实例
func (*Engine) BackendName ¶
─── BackendName ──────────────────────────────────────────── BackendName 返回当前使用的后端名称 用于日志和调试
比如日志输出:
"当前使用 jieba 后端进行分词"
func (*Engine) IndexAnalyzer ¶
─── IndexAnalyzer ────────────────────────────────────────── IndexAnalyzer 返回一个 bluge 兼容的 analyzer 用于 bridge 新旧两套系统
为什么需要这个方法?
旧系统的 buildField() 使用 bluge 的 analysis.Analyzer 来分词 新系统的 engine 使用自己的 Engine.IndexTokens() 来分词 这个方法让旧代码能通过 bluge 的接口调用新 engine
知识点:
这就是"适配器模式"(Adapter Pattern) 新引擎是 USB-C 接口,旧代码是 USB-A IndexAnalyzer 就是那个转接头
func (*Engine) IndexTokens ¶
─── IndexTokens ──────────────────────────────────────────── IndexTokens 是"索引模式"的分词入口 专门给"建立索引"时用的
工作流程:
text → backend.Cut(text, false) → 精确分词 ↓ 逐 processor 处理 → 最终 Token 切片
与 SearchTokens 的区别:
索引模式传 isSearch=false,backend 不会做子词扩展 搜索模式传 isSearch=true,backend 会做子词扩展
知识点(面试常考):
为什么索引和搜索要用不同的分词策略? → 这是信息检索(IR, Information Retrieval)的核心思想 索引时追求"精度"(Precision):进来的都是对的 搜索时追求"召回"(Recall):该找到的都要找到 两个字面相反的目标,所以需要不同的策略
func (*Engine) SearchTokens ¶
─── SearchTokens ─────────────────────────────────────────── SearchTokens 是"搜索模式"的分词入口 专门给"搜索查询"时用的
搜索模式 vs 索引模式的关键区别:
- 传给 backend 的 isSearch=true,后端会做子词扩展
- 可能使用不同的 pipeline(搜索 pipeline 侧重召回)
type EngineConfig ¶
type EngineConfig struct {
Backend Backend // 分词后端(gse / jieba)
IndexPipeline []Processor // 索引时用的管道链
SearchPipeline []Processor // 搜索时用的管道链
Config AnalysisConfig // 引擎配置(词典路径、开关等)
}
─── EngineConfig ─────────────────────────────────────────── EngineConfig 是创建 Engine 时需要的配置 因为 Engine 的字段太多(backend + indexPipe + searchPipe + config) 所以用配置模式(Config Pattern)来封装
为什么不用构造函数传一堆参数? 如果 NewEngine(backend, pipe1, pipe2, config) 要传 4 个参数 每个参数的类型还不一样,调用方容易搞错顺序 用结构体 Config 可以按名字传,更清晰
知识点:Functional Options Pattern 更灵活
很多 Go 库用 `WithXxx()` 函数来设置可选参数
比如 grpc.Dial("addr", grpc.WithInsecure())
但这里 Config 够简单了,用结构体就行
type ForceNoSplitProcessor ¶
type ForceNoSplitProcessor struct {
// contains filtered or unexported fields
}
─── ForceNoSplitProcessor ────────────────────────────────── 禁止分词处理器:保持某些词不分词
词典文件格式(force_no_split.txt):
普洱茶 布加迪威龙 可口可乐
每行一个词,分词器如果把这个词切开了 这个处理器会把它合并回去
func NewForceNoSplitProcessor ¶
func NewForceNoSplitProcessor(path string) *ForceNoSplitProcessor
─── NewForceNoSplitProcessor ───────────────────────────────
func (*ForceNoSplitProcessor) Name ¶
func (p *ForceNoSplitProcessor) Name() string
func (*ForceNoSplitProcessor) Process ¶
func (p *ForceNoSplitProcessor) Process(tokens []Token) []Token
─── Process ──────────────────────────────────────────────── Process 把被错误切分的 Token 合并回去
算法:贪心合并
从前往后扫描 Token,尝试把连续几个 Token 拼起来 如果拼起来的词在禁止分词表中,就合并成一个 Token
比如输入 ["普洱", "茶"],禁止词表中有 "普洱茶"
- 从 i=0 开始,尝试 j=2(拼起来="普洱茶")
- "普洱茶" 在词表中 → 合并成一个 Token
- 跳过已合并的部分
但注意:这是个简单的贪心算法 如果输入 ["云", "南", "普洱", "茶"] 词表中有 "云南" 和 "普洱茶" 会优先匹配 "云南",然后剩下的 "普洱" "茶" 才能合并 这是贪心算法的局限
type ForceSplitProcessor ¶
type ForceSplitProcessor struct {
// contains filtered or unexported fields
}
─── ForceSplitProcessor ──────────────────────────────────── 强制分词处理器:把某些长词按指定方式切分开
词典文件格式(force_split.txt):
石墨烯=石墨 烯 核糖核酸=核糖 核酸 京东商城=京东 商城
func NewForceSplitProcessor ¶
func NewForceSplitProcessor(path string) *ForceSplitProcessor
─── NewForceSplitProcessor ─────────────────────────────────
func (*ForceSplitProcessor) Name ¶
func (p *ForceSplitProcessor) Name() string
func (*ForceSplitProcessor) Process ¶
func (p *ForceSplitProcessor) Process(tokens []Token) []Token
─── Process ──────────────────────────────────────────────── Process 对 Token 进行强制切分
如果当前 Token 在强制分词映射表中 就用映射表中的切分结果替换它 这样可以覆盖分词器的默认行为
type GseBackend ¶
type GseBackend struct {
// contains filtered or unexported fields
}
─── GseBackend ───────────────────────────────────────────── GseBackend 实现了 Backend 接口 它是 ZincSearch++ 默认的分词后端,因为:
- 纯 Go 实现,无 CGo,编译方便
- 启动快,只需要 0.1 秒加载词典
- 内存少,词典只有 ~8MB
- 速度中等,1000 字约 0.5ms
缺点:
- 准确率不如 jieba(词典只有 ~20 万词)
- 词性标注功能弱
- 社区不够活跃
func NewGseBackend ¶
func NewGseBackend() *GseBackend
─── NewGseBackend ────────────────────────────────────────── NewGseBackend 创建一个 gse 后端 默认加载最小词典(只包含"zinc"这个词),启动极其迅速 如果需要真正的中文分词,需要通过环境变量指定大词典
为什么默认只加载"zinc"?
因为 gse 内嵌了完整的中文词典(~110K 行代码!) 如果全部加载,包会很大(~8MB),启动也会变慢 所以设计为"按需加载":需要时通过环境变量指定词典路径
知识点:Go 的 init() 函数执行顺序
gse.Segmenter 在 init() 时会加载默认词典 但我们的 NewGseBackend() 是手动调用,可以控制词典
func (*GseBackend) Cut ¶
func (b *GseBackend) Cut(text []byte, isSearch bool) []Token
─── Cut ──────────────────────────────────────────────────── Cut 是 Backend 接口的核心方法 对输入的文本进行分词,返回 Token 切片
参数:
text:要分词的原始文本,如 "我爱北京天安门" isSearch:是否为搜索模式 → false:精确模式,gse.Segment() → true :搜索模式,gse.CutSearch()(子词扩展)
返回值:
[]Token:分词结果列表
知识点:
- gse.Segment() 返回 gse 的 Segment 对象,包含 Token 的文本和位置
- gse.CutSearch() 返回 []string,只包含文本,不包含位置
- 所以搜索模式无法获取 Token 的位置信息(Start/End 为 0) 但搜索模式的位置信息对 BM25 排序没有影响(BM25 只看词频不看位置) 只有短语搜索(match_phrase)才需要位置信息
func (*GseBackend) Name ¶
func (b *GseBackend) Name() string
─── Name ─────────────────────────────────────────────────── Name 返回后端的名称,用于标识和调试 在日志里会显示:"使用 gse 后端"
type JiebaBackend ¶
type JiebaBackend struct {
// contains filtered or unexported fields
}
─── JiebaBackend ─────────────────────────────────────────── JiebaBackend 实现了 Backend 接口 它是高精度中文搜索的首选后端
优势:
- 完整 jieba 词典,~35 万词
- 词性标注支持(POS tagging)
- HMM 新词识别(能识别"新冠肺炎"这样的新词)
- 搜索引擎模式(CutForSearch)
劣势:
- 词典加载慢(~1 秒)
- 内存占用高(~52MB)
- 纯 Go 实现但依赖 CGo 的某些功能(其实 fumiama/jieba 是纯 Go)
func NewJiebaBackend ¶
func NewJiebaBackend(dictPath string) *JiebaBackend
─── NewJiebaBackend ──────────────────────────────────────── NewJiebaBackend 创建一个 jieba 后端 参数 dictPath 指向词典文件(约 4.9MB) 如果路径无效,会创建一个空的 segmenter
知识点:
为什么 jieba 词典需要从文件加载? 因为完整词典太大(4.9MB),不适合编译进 Go 二进制 gse 把词典编译进去是因为它用内嵌方式(go:embed) 但 jieba 的设计是"加载外部文件"
func (*JiebaBackend) Cut ¶
func (b *JiebaBackend) Cut(text []byte, isSearch bool) []Token
─── Cut ──────────────────────────────────────────────────── Cut 对输入进行分词
isSearch=true 时使用搜索引擎模式(CutForSearch)
搜索引擎模式会在精确分词的基础上,进一步拆分长词 比如 "中国科学技术大学": 精确模式:["中国科学技术大学"] 搜索模式:["中国科学", "科学技术", "技术大学", "中国科学技术大学"]
这样用户搜"中国科学"也能找到这篇文章
func (*JiebaBackend) Name ¶
func (b *JiebaBackend) Name() string
─── Name ─────────────────────────────────────────────────── Name 返回后端的名称
type LowerCaseProcessor ¶
type LowerCaseProcessor struct{}
─── LowerCaseProcessor ─────────────────────────────────────
func (*LowerCaseProcessor) Name ¶
func (p *LowerCaseProcessor) Name() string
func (*LowerCaseProcessor) Process ¶
func (p *LowerCaseProcessor) Process(tokens []Token) []Token
Process 把每个 Token 的文本转小写 对中文没影响,因为中文没有大小写
type PinyinConfig ¶
type PinyinConfig struct {
Full bool // 全拼模式:"中国"→"zhongguo"
FirstLetter bool // 首字母模式:"中国"→"zg"
KeepOriginal bool // 是否保留原始中文词
}
─── PinyinConfig ─────────────────────────────────────────── PinyinConfig 拼音扩展的配置
三种模式的组合:
Full=true + FirstLetter=false + KeepOriginal=true 输入:"手机" → 输出:["手机", "shouji"] 这是默认模式,也是推荐模式 Full=true + FirstLetter=true + KeepOriginal=true 输入:"手机" → 输出:["手机", "shouji", "sj"] 适合对召回率要求高的场景 Full=false + FirstLetter=false + KeepOriginal=false 输入:"手机" → 输出:[](啥也不保留,极端但没用)
type PinyinProcessor ¶
type PinyinProcessor struct {
// contains filtered or unexported fields
}
─── PinyinProcessor ────────────────────────────────────────
func NewPinyinProcessor ¶
func NewPinyinProcessor(cfg PinyinConfig) *PinyinProcessor
─── NewPinyinProcessor ───────────────────────────────────── 如果两个模式都关了,强制开启全拼
func (*PinyinProcessor) Name ¶
func (p *PinyinProcessor) Name() string
func (*PinyinProcessor) Process ¶
func (p *PinyinProcessor) Process(tokens []Token) []Token
─── Process ──────────────────────────────────────────────── Process 对 Token 进行拼音扩展
工作原理:
- 遍历每个 Token
- 对 Token 中的每个汉字,查拼音映射表
- 如果全部汉字都有拼音,生成扩展 Token
- 如果包含英文字母或数字,跳过(不生成拼音)
为什么只有"全中文字符"的词才生成拼音?
比如 "4G" 这个词,4 是数字,不是汉字 强行生成拼音会变成奇怪的 "4g" 或 "si g" 所以只对全中文的词做拼音扩展
type Processor ¶
type Processor interface {
// Name 返回处理器的名称
// 用于日志和调试,比如 "stop"、"pinyin"、"synonym"
Name() string
// Process 对 Token 切片进行处理,返回处理后的 Token 切片
//
// 处理方式可以是:
// 1. 删除某些 Token(如停用词过滤)
// 2. 修改某些 Token(如繁简转换、大小写转换)
// 3. 增加新的 Token(如同义词扩展、拼音扩展、N-gram)
// 4. 排序或去重
//
// 注意:
// - 输入 tokens 可以就地修改(in-place modification),
// 因为调用方不会复用这个切片(所有权已转移)
// - 如果不需要修改,直接返回原切片(零成本抽象)
// - 如果 tokens == nil,直接返回 nil(防御性编程)
//
// 性能建议:
// - 如果能就地修改,就不要新建切片
// - 如果需要新增 Token,用 append 预分配容量
// - 循环中用 range 而不是下标,range 不会越界
Process(tokens []Token) []Token
}
─── Processor 接口 ───────────────────────────────────────── Processor 是分词管道中的"一站" 每个 Processor 接收一批 Token,处理(增/删/改),然后输出
管道执行顺序(以索引模式为例):
原始文本 → GseBackend.Cut() → Token 切片 ↓ 1. stconvert(繁简转换):把繁体"復仇"变成简体"复仇" ↓ 2. stop(停用词过滤):去掉没意义的词,如"的"、"了" ↓ 3. synonym(同义词扩展):"手机" → "手机" + "移动电话" ↓ 4. pinyin(拼音扩展):"手机" → "手机" + "shouji" ↓ 5. ngram(N-gram 补偿):"复仇者联盟" → "复仇者" + "者联盟" + ... ↓ 最终结果:处理后的 Token 切片
为什么用管道模式?
- 灵活:想加功能就加一个 Processor,想去掉就删掉
- 解耦:每个 Processor 不用关心前后是谁,只负责自己的事
- 可测:每个 Processor 可以单独测试
- 可复用:索引时和搜索时可以用不同的管道组合
知识点(设计模式):
- Pipeline Pattern ≈ Decorator Pattern + Chain of Responsibility
- 和 Java Servlet Filter、ASP.NET Middleware 是同一个思想
- Go 标准库中的 http.Handler 也是类似的链式处理
type STConvertProcessor ¶
type STConvertProcessor struct {
// contains filtered or unexported fields
}
─── STConvertProcessor ───────────────────────────────────── STConvertProcessor 实现了 Processor 接口 ST 是 Simplified/Traditional(简体/繁体)的缩写
原理:使用 gocc(Go 中文转换库)做 OpenCC 风格的繁简转换 OpenCC 是目前最流行的繁简转换库 比简单的"一对一"映射更准确 因为有些字在不同语境下有不同含义 比如"面"在"麵包"中是"面包",在"面對"中是"面对"
func NewSTConvertProcessor ¶
func NewSTConvertProcessor() *STConvertProcessor
─── NewSTConvertProcessor ────────────────────────────────── 创建繁简转换处理器 默认使用 t2s(繁体→简体)方向 如果要简→繁,可以改为 s2t
func (*STConvertProcessor) Name ¶
func (p *STConvertProcessor) Name() string
func (*STConvertProcessor) Process ¶
func (p *STConvertProcessor) Process(tokens []Token) []Token
─── Process ──────────────────────────────────────────────── Process 对每个 Token 做繁简转换 如果转换后的文本和原文本不同,说明确实有繁简差异 比如 "復仇" → "复仇"(不同,说明有转换) 比如 "中国" → "中国"(相同,说明已经是简体)
type StopProcessor ¶
type StopProcessor struct {
// contains filtered or unexported fields
}
─── StopProcessor ────────────────────────────────────────── StopProcessor 实现了 Processor 接口 功能:过滤掉停用词,减少索引噪音
工作方式很简单:遍历所有 Token,如果 Token 在停用词表中,就跳过
比如输入 ["我", "爱", "北京"],输出 ["爱", "北京"] "我" 在停用词表中,被过滤掉了
func NewStopProcessor ¶
func NewStopProcessor(stopWords map[string]bool) *StopProcessor
─── NewStopProcessor ─────────────────────────────────────── NewStopProcessor 创建停用词处理器 如果传入 nil,使用内置默认停用词表 如果传入自定义停用词表,使用自定义的
面试题:为什么用 map[string]bool 而不是 map[string]struct{}?
→ map[string]struct{} 更省内存(空结构体不占空间)
→ 但访问时需要用 _, ok := m[key] 来判断
→ map[string]bool 可以直接用 if m[key] { ... }
→ 代码可读性更好,内存差异可以忽略
func (*StopProcessor) Name ¶
func (p *StopProcessor) Name() string
func (*StopProcessor) Process ¶
func (p *StopProcessor) Process(tokens []Token) []Token
─── Process ──────────────────────────────────────────────── Process 过滤停用词
实现方式:双指针法(Two Pointers)
n 是"有效 token 的指针",i 是"遍历指针" 遍历所有 token,如果不是停用词,就放到 n 的位置,n++ 最后返回 tokens[:n]
为什么不用"新建一个切片再 append"?
用双指针可以直接在原切片上操作,不分配新内存 性能更好,GC 压力更小
举个例子:
输入 ["我", "爱", "北京"] i=0: "我" 是停用词 → 跳过,n=0 i=1: "爱" 不是 → tokens[0]="爱", n=1 i=2: "北京" 不是 → tokens[1]="北京", n=2 返回 tokens[:2] = ["爱", "北京"]
type SynonymConfig ¶
type SynonymConfig struct {
Mode SynonymMode // 同义词模式
Path string // 同义词文件路径
CaseSensitive bool // 是否大小写敏感
}
─── SynonymConfig ──────────────────────────────────────────
type SynonymMode ¶
type SynonymMode string
─── SynonymMode ──────────────────────────────────────────── SynonymMode 同义词模式
const ( // SynonymBidirectional 双向等价 // "计算机" = "电脑" = "PC" // 搜任何一个都能找到全部三个 SynonymBidirectional SynonymMode = "bidirectional" // SynonymUnidirectional 单向映射 // "华为" → "Huawei",但 "Huawei" → "华为" 不会 // 适用于:用户常用说法 → 官方说法 // 比如用户搜"苹果",但文档里写的是"Apple" SynonymUnidirectional SynonymMode = "unidirectional" )
type SynonymProcessor ¶
type SynonymProcessor struct {
// contains filtered or unexported fields
}
─── SynonymProcessor ───────────────────────────────────────
func NewSynonymProcessor ¶
func NewSynonymProcessor(cfg SynonymConfig) *SynonymProcessor
─── NewSynonymProcessor ──────────────────────────────────── 从同义词文件加载同义词映射
文件格式有两种:
格式一:双向等价(逗号分隔)
计算机,电脑,PC iPhone,苹果手机,苹果 每行的词互相等价,搜"计算机"也能找到"电脑"
格式二:单向映射(等号)
华为=Huawei 小米=Xiaomi 搜"华为"也能找到"Huawei",但反过来不行
func (*SynonymProcessor) Name ¶
func (p *SynonymProcessor) Name() string
func (*SynonymProcessor) Process ¶
func (p *SynonymProcessor) Process(tokens []Token) []Token
─── Process ──────────────────────────────────────────────── Process 对 Token 进行同义词扩展
工作原理:
遍历每个 Token,如果它在同义词映射表中 就把它的所有同义词作为新的 Token 追加到结果中
比如 mapping 中有 "计算机"→["电脑", "PC"] 输入 ["计算机", "科学"],输出:
["计算机", "科学", "电脑", "PC"]
注意:同义词扩展会导致索引/搜索的 Token 数量增加 在搜索场景中,这可以提高召回率
type Token ¶
type Token struct {
Term []byte // 分词后的词,如"北京"、"天安门"。注意是 []byte 不是 string
Start int // 这个词在原始文本中的起始位置(字节偏移量)
End int // 这个词在原始文本中的结束位置(字节偏移量)
Pos int // 这个词在文档中的位置序号(第几个词)
POS string // 词性标注(Part of Speech),如 n/v/a。仅 jieba 后端支持
}
─── Token 结构体 ─────────────────────────────────────────── Token 表示一个分词结果,"词"的最小单位 比如"我爱北京天安门"分词后,会得到多个 Token:
Token{Term:"我", Start:0, End:1}
Token{Term:"爱", Start:1, End:2}
Token{Term:"北京", Start:2, End:4}
Token{Term:"天安门", Start:4, End:7}
知识点:
- Term 是 []byte 而不是 string,因为 []byte 可以直接引用原始文本的内存 不需要拷贝,性能更好。这是 Go 性能优化的常见技巧
- Start/End 是字节偏移量,不是字符数(runes) 因为中文一个字符占 3 个字节,需要用 len([]rune(text)) 才能得到字符数 但字节偏移量更底层、更高效
- POS 是词性标注(Part of Speech),比如 n=名词, v=动词, a=形容词 只有 jieba 后端支持词性标注,gse 不支持
func AcquireToken ¶
func AcquireToken() *Token
AcquireToken 从对象池中"借"一个 Token 用完后必须调用 ReleaseToken 还回去 这种"借-还"模式在 Go 的标准库中很常见 比如 fmt.Fprintf 用的就是类似的 buffer 池
使用示例:
tok := AcquireToken()
tok.Term = []byte("北京")
// ... 使用 tok ...
ReleaseToken(tok) // 用完记得还!
type TokenStream ¶
type TokenStream []Token
─── TokenStream ──────────────────────────────────────────── TokenStream 是 Token 的切片,表示一串分词结果 相当于 []Token 的别名,但用类型别名更清晰
知识点:
- type 定义新类型 vs type alias(=)的区别 → 新类型可以绑方法,别名不能。这里用新类型是为了语义清晰
- 为什么不是 []*Token?因为 Token 很小(几个字段),用值可以减少指针 的 GC 扫描压力。Go 中小的结构体用值传递反而更快
type UniqueProcessor ¶
type UniqueProcessor struct{}
─── UniqueProcessor ──────────────────────────────────────── 去重实现方式:用 map 记录已经出现过的 Token 如果 Token 已经出现过,就跳过
为什么不去重会影响 BM25 打分? BM25 公式中有一个关键因子:TF(词频,Term Frequency) 如果一个词在文档中出现 2 次,BM25 认为这个词更重要 但如果这 2 次是 N-gram 扩展出来的,不是原文就有 那 BM25 的 TF 就被"虚高"了 所以去重可以让 BM25 打分更准确
func (*UniqueProcessor) Name ¶
func (p *UniqueProcessor) Name() string
func (*UniqueProcessor) Process ¶
func (p *UniqueProcessor) Process(tokens []Token) []Token
─── Process ──────────────────────────────────────────────── Process 去除重复的 Token
实现细节:
用 map 记录已出现的词,空间换时间 用双指针法原地修改切片 不分配新内存
面试题:为什么用 map[string]bool 来去重?
因为需要 O(1) 的查找速度 如果用切片存储已出现词,每次查找要 O(n) 总时间复杂度会变成 O(n²)