analysis

package
v0.27.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 30, 2026 License: Apache-2.0 Imports: 11 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func NewGseSearchAnalyzer

func NewGseSearchAnalyzer() *analysis.Analyzer

─── NewGseSearchAnalyzer ─────────────────────────────────── 兼容旧的 gse_search analyzer 注册

func NewGseSearchTokenizer

func NewGseSearchTokenizer() analysis.Tokenizer

func NewGseStandardAnalyzer

func NewGseStandardAnalyzer() *analysis.Analyzer

─── NewGseStandardAnalyzer ───────────────────────────────── 兼容旧的 gse_standard analyzer 注册 旧代码在 pkg/uquery/analysis/analyzer.go 中注册了 "gse_standard" 通过这个函数,旧代码可以无缝切换到新引擎

⚡ 关键知识点:

这个函数返回的是 bluge 的 *analysis.Analyzer
但内部使用的是我们的新版 Engine
所以旧代码不需要任何修改就能用新引擎!

func NewGseStandardTokenizer

func NewGseStandardTokenizer() analysis.Tokenizer

─── 兼容 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 分词引擎配置 所有可调整的参数都集中在这里

每个字段都有详细的注释,方便新人理解

分段说明:

  1. 基础配置(Backend, Concurrency)
  2. 词典路径(各种词典的文件位置)
  3. Pipeline 配置(拼音/同义词/N-gram等开关)
  4. 停用词配置
  5. 性能配置(缓存大小/超时)
  6. 调试配置
  7. 热加载配置

func ConfigFromEnv

func ConfigFromEnv() AnalysisConfig

─── ConfigFromEnv ────────────────────────────────────────── ConfigFromEnv 从环境变量加载配置 读取 ZINC_ANALYSIS_* 环境变量覆盖默认值

func DefaultConfig

func DefaultConfig() AnalysisConfig

─── DefaultConfig ────────────────────────────────────────── DefaultConfig 返回默认配置 这是"安全"的默认值,适合大多数用户

默认配置原则:

  1. 能正常工作(不开任何高级功能也能用)
  2. 不过度消耗资源(CacheSize=10000,Concurrency=4)
  3. 不产生过多日志(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):如果它走路像鸭子,叫起来像鸭子,
那它就是鸭子

目前我们有两个后端实现了这个接口:

  1. GseBackend → 基于 go-ego/gse,轻量快速
  2. JiebaBackend → 基于 fumiama/jieba,精度更高
  3. 你也可以自己实现一个 Backend,然后注册到引擎里

type CNGramProcessor

type CNGramProcessor struct {
	MinGram int // 最小 gram 长度,默认 2
	MaxGram int // 最大 gram 长度,默认 3
}

─── 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 是分词引擎的核心结构体 它像一个"工厂流水线":

  1. Backend 负责"原材料切割"(把文本切成词)
  2. 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

func (e *Engine) BackendName() string

─── BackendName ──────────────────────────────────────────── BackendName 返回当前使用的后端名称 用于日志和调试

比如日志输出:

"当前使用 jieba 后端进行分词"

func (*Engine) IndexAnalyzer

func (e *Engine) IndexAnalyzer(field string, mappings *meta.Mappings) *analysis.Analyzer

─── IndexAnalyzer ────────────────────────────────────────── IndexAnalyzer 返回一个 bluge 兼容的 analyzer 用于 bridge 新旧两套系统

为什么需要这个方法?

旧系统的 buildField() 使用 bluge 的 analysis.Analyzer 来分词
新系统的 engine 使用自己的 Engine.IndexTokens() 来分词
这个方法让旧代码能通过 bluge 的接口调用新 engine

知识点:

这就是"适配器模式"(Adapter Pattern)
新引擎是 USB-C 接口,旧代码是 USB-A
IndexAnalyzer 就是那个转接头

func (*Engine) IndexTokens

func (e *Engine) IndexTokens(text []byte) []Token

─── IndexTokens ──────────────────────────────────────────── IndexTokens 是"索引模式"的分词入口 专门给"建立索引"时用的

工作流程:

text → backend.Cut(text, false) → 精确分词
  ↓
逐 processor 处理 → 最终 Token 切片

与 SearchTokens 的区别:

索引模式传 isSearch=false,backend 不会做子词扩展
搜索模式传 isSearch=true,backend 会做子词扩展

知识点(面试常考):

为什么索引和搜索要用不同的分词策略?
→ 这是信息检索(IR, Information Retrieval)的核心思想
  索引时追求"精度"(Precision):进来的都是对的
  搜索时追求"召回"(Recall):该找到的都要找到
  两个字面相反的目标,所以需要不同的策略

func (*Engine) SearchTokens

func (e *Engine) SearchTokens(text []byte) []Token

─── SearchTokens ─────────────────────────────────────────── SearchTokens 是"搜索模式"的分词入口 专门给"搜索查询"时用的

搜索模式 vs 索引模式的关键区别:

  1. 传给 backend 的 isSearch=true,后端会做子词扩展
  2. 可能使用不同的 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

比如输入 ["普洱", "茶"],禁止词表中有 "普洱茶"

  1. 从 i=0 开始,尝试 j=2(拼起来="普洱茶")
  2. "普洱茶" 在词表中 → 合并成一个 Token
  3. 跳过已合并的部分

但注意:这是个简单的贪心算法 如果输入 ["云", "南", "普洱", "茶"] 词表中有 "云南" 和 "普洱茶" 会优先匹配 "云南",然后剩下的 "普洱" "茶" 才能合并 这是贪心算法的局限

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++ 默认的分词后端,因为:

  1. 纯 Go 实现,无 CGo,编译方便
  2. 启动快,只需要 0.1 秒加载词典
  3. 内存少,词典只有 ~8MB
  4. 速度中等,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 接口 它是高精度中文搜索的首选后端

优势:

  1. 完整 jieba 词典,~35 万词
  2. 词性标注支持(POS tagging)
  3. HMM 新词识别(能识别"新冠肺炎"这样的新词)
  4. 搜索引擎模式(CutForSearch)

劣势:

  1. 词典加载慢(~1 秒)
  2. 内存占用高(~52MB)
  3. 纯 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 进行拼音扩展

工作原理:

  1. 遍历每个 Token
  2. 对 Token 中的每个汉字,查拼音映射表
  3. 如果全部汉字都有拼音,生成扩展 Token
  4. 如果包含英文字母或数字,跳过(不生成拼音)

为什么只有"全中文字符"的词才生成拼音?

比如 "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 切片

为什么用管道模式?

  1. 灵活:想加功能就加一个 Processor,想去掉就删掉
  2. 解耦:每个 Processor 不用关心前后是谁,只负责自己的事
  3. 可测:每个 Processor 可以单独测试
  4. 可复用:索引时和搜索时可以用不同的管道组合

知识点(设计模式):

  • 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²)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL