<title>Wikipedia: Kit-Cat Klock</title> <url>https://en.wikipedia.org/wiki/Kit-Cat_Klock</url> <abstract>The Kit-Cat Klock is an art deco novelty wall clock shaped like a grinning cat with cartoon eyes that swivel in time with its pendulum tail.</abstract>
// Document represents a Wikipedia abstract dump Document. type Document struct { Title string`xml:"title"` URL string`xml:"url"` Text string`xml:"abstract"` ID int }
// loadDocuments loads a Wikipedia abstract dump and returns a slice of documents. // Dump example: https://dumps.wikimedia.org/enwiki/latest/enwiki-latest-abstract1.xml.gz funcloadDocuments(path string) ([]Document, error) { f, err := os.Open(path) if err != nil { returnnil, err } defer f.Close()
start = time.Now() matchedDocs := searchContent(docs, query) log.Printf("Search found %d documents in %v", len(matchedDocs), time.Since(start)) }
funcsearchContent(docs []Document, term string) []Document { var r []Document for _, doc := range docs { if strings.Contains(doc.Text, term) { r = append(r, doc) } } return r }
最终输出:
1 2 3
2020/08/31 23:57:28 Starting 2020/08/31 23:58:02 Loaded 613149 documents in 34.0629694s 2020/08/31 23:58:02 Search found 29658 documents in 58.0522ms
start = time.Now() matchedDocs := regexpSearch(docs, query) log.Printf("Search found %d documents in %v", len(matchedDocs), time.Since(start)) }
funcregexpSearch(docs []Document, term string) []Document { // Don't do this in production, it's a security risk. term needs to be sanitized. re := regexp.MustCompile(`(?i)\b` + term + `\b`) var r []Document for _, doc := range docs { if re.MatchString(doc.Text) { r = append(r, doc) } } return r }
输出结果如下:
1 2 3
2020/09/01 00:17:55 Starting 2020/09/01 00:18:30 Loaded 613149 documents in 34.9838063s 2020/09/01 00:18:33 Search found 292 documents in 2.2390365s
可以看到搜索花了2秒多;
随着数据集越来越大,我们需要扫描越来越多的文档;
该算法的时间复杂度是线性的-需要扫描的文档数等于文档总数;
如果我们有600万份文档,而不是60万份,搜索需要20秒!
所以我们需要更优的算法;
倒排索引
为了使搜索查询更快,我们将对文本进行预处理并预先建立索引;
FTS的核心是使用一种称为倒排索引的数据结构,倒排索引将文档中的每个单词与包含该单词的文档相关联;
例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
documents = { 1: "a donut on a glass plate", 2: "only the donut", 3: "listen to the drum machine", }
// tokenize returns a slice of tokens for the given text. functokenize(text string) []string { return strings.FieldsFunc(text, func(r rune)bool { // Split on any character that is not a letter or a number. return !unicode.IsLetter(r) && !unicode.IsNumber(r) }) }
for _, tc := range testCases { t.Run(tc.text, func(st *testing.T) { assert.EqualValues(st, tc.tokens, tokenize(tc.text)) }) } }
例如:
1 2
> tokenize("A donut on a glass plate. Only the donuts.") ["A", "donut", "on", "a", "glass", "plate", "Only", "the", "donuts"]
② 过滤器
在大多数情况下,仅仅将文本转换为标记列表是不够的;
为了使文本更易于索引和搜索,我们需要进行额外的规范化;
小写转换
为了使搜索不区分大小写,小写过滤器将标记转换为小写;
cAt、cAt和cAt规范化为cat;
稍后,当我们查询索引时,我们也会使用小写作为搜索词,这将使搜索词cAt与文本Cat匹配;
1 2 3 4 5 6 7
funclowercaseFilter(tokens []string) []string { r := make([]string, len(tokens)) for i, token := range tokens { r[i] = strings.ToLower(token) } return r }
var stopwords = map[string]struct{}{ // I wish Go had built-in sets. "a": {}, "and": {}, "be": {}, "have": {}, "i": {}, "in": {}, "of": {}, "that": {}, "the": {}, "to": {}, }
funcstopwordFilter(tokens []string) []string { r := make([]string, 0, len(tokens)) for _, token := range tokens { if _, ok := stopwords[token]; !ok { r = append(r, token) } } return r }
funcstemmerFilter(tokens []string) []string { r := make([]string, len(tokens)) for i, token := range tokens { r[i] = snowballeng.Stem(token, false) } return r }
> analyze("A donut on a glass plate. Only the donuts.") ["donut", "on", "glass", "plate", "only", "donut"]
下面我们利用这些标记来建立倒排索引;
创建倒排索引
回到倒排索引,它将文档中的每个单词映射到文档id;
内置map是存储映射的一个很好的候选对象;
映射中的键是一个令牌(字符串),值是一个文档ID列表:
1
type index map[string][]int
建立索引包括分析文档并将其ID添加到map中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
func(idx index) add(docs []document) { for _, doc := range docs { for _, token := range analyze(doc.Text) { ids := idx[token] if ids != nil && ids[len(ids)-1] == doc.ID { // Don't add same ID twice. continue } idx[token] = append(ids, doc.ID) } } }
funcmain() { idx := make(index) idx.add([]document{{ID: 1, Text: "A donut on a glass plate. Only the donuts."}}) idx.add([]document{{ID: 2, Text: "donut is a donut"}}) fmt.Println(idx) }
func(idx index) search(text string) [][]int { var r [][]int for _, token := range analyze(text) { if ids, ok := idx[token]; ok { r = append(r, ids) } } return r }
funcintersection(a []int, b []int) []int { maxLen := len(a) iflen(b) > maxLen { maxLen = len(b) } r := make([]int, 0, maxLen) var i, j int for i < len(a) && j < len(b) { if a[i] < b[j] { i++ } elseif a[i] > b[j] { j++ } else { r = append(r, a[i]) i++ j++ } } return r }
同时,我们需要更新search函数:
分析给定的查询文本、查找标记并计算ID列表之间的集合交集:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
func(idx index) search(text string) []int { var r []int for _, token := range analyze(text) { if ids, ok := idx[token]; ok { if r == nil { r = ids } else { r = intersection(r, ids) } } else { // Token doesn't exist. returnnil } } return r }
130764 The wildcat is a species complex comprising two small wild cat species, the European wildcat (Felis silvestris) and the African wildcat (F. lybica). 131692 Catopuma is a genus containing two Asian small wild cat species, the Asian golden cat (C. temminckii) and the bay cat.