Query Language#

[1]:
import typing as T
import os
import shutil
from pathlib import Path

from whoosh import fields as F
from whoosh.index import exists_in, create_in, open_dir
from whoosh import qparser, query, sorting

from rich import print as rprint
from rich.console import Console
[2]:
console = Console()
console.options.max_width = 80

dir_index = Path(os.getcwd()).joinpath(".whoosh_index")

dir_index.mkdir(parents=True, exist_ok=True)

def clear_index():
    shutil.rmtree(dir_index, ignore_errors=True)

def get_index(schema: F.SchemaClass):
    if exists_in(str(dir_index)):
        idx = open_dir(str(dir_index))
    else:
        dir_index.mkdir(parents=True, exist_ok=True)
        idx = create_in(
            dirname=str(dir_index),
            schema=schema,
        )
    return idx

[3]:
def result_to_docs(res) -> T.List[T.Dict[str, T.Any]]:
    return [hit.fields() for hit in res]

1#

[4]:
class BookSchema(F.SchemaClass):
    isbn = F.ID(stored=True, unique=True)
    title = F.TEXT(stored=True)
    author = F.NGRAM(stored=True, minsize=2, maxsize=6)
    year = F.NUMERIC(stored=True)

schema = BookSchema()

data = [
    dict(isbn="0954452933", title="Sustainable Energy - without the hot air", author="MacKay, David JC", year=2009),
]

clear_index()
idx = get_index(schema)
writer = idx.writer()
for doc in data:
    writer.add_document(**doc)
writer.commit()

def search(q: query.Query):
    # console.rule("Query", characters="=")
    print("---------- Query ----------------------------")
    # console.rule("equivalent query string", characters="-")
    print("---------- equivalent query string ----------")
    print(q)

    # console.rule("equivalent query object", characters="-")
    print("---------- equivalent query object ----------")
    rprint(repr(q))

    # console.rule("Result", characters="=")
    print("---------- Result ----------------------------")
    with idx.searcher() as sr:
        docs = result_to_docs(sr.search(q))
        rprint(docs)

Query#

Elastic Search Query DSL, MongoDB 这类文档搜索类似, Whoosh 的 Query 也是针对多个可以被搜索的 Field 以及所需要的搜索方式 (例如 精确等于, 模糊等于, 全文搜索, 区间搜索等) 构成多个 Term, 然后用 AND, OR, NOT 等对这些 Term 做排列组合, 然后对结果进行排序, 从而得到最终的搜索结果.

Whoosh 提供两种方式来构建 Query. 一种是使用结构化的查询语言, 用 whoosh.query 模块中的类来构建 Query 对象. 还有一种使用类似你在 Google Search 搜索框中输入字符串, 然后用 whoosh.qparser 模块中的类来解析这个字符串, 自动生成最终的 Query 对象. 无论你用的哪种方法, 最终实际上都是通过结构化的 Query 对象来进行查询的.

初探 QueryParser 和结构化 Query#

使用自动化的 QueryParser, 这个类只能针对某一个 Field 进行搜索.

[5]:
q_str = "energy"
q = qparser.QueryParser("title", schema=schema).parse(q_str)
search(q)
---------- Query ----------------------------
---------- equivalent query string ----------
title:energy
---------- equivalent query object ----------
Term('title', 'energy')
---------- Result ----------------------------
[
    {
        'author': 'MacKay, David JC',
        'isbn': '0954452933',
        'title': 'Sustainable Energy - without the hot air',
        'year': 2009
    }
]

手动创建 Query. 效果和前面用 Parser 自动生成是一样的.

[6]:
q = query.Term("title", "energy")
search(q)
---------- Query ----------------------------
---------- equivalent query string ----------
title:energy
---------- equivalent query object ----------
Term('title', 'energy')
---------- Result ----------------------------
[
    {
        'author': 'MacKay, David JC',
        'isbn': '0954452933',
        'title': 'Sustainable Energy - without the hot air',
        'year': 2009
    }
]

注意, 由于 title 是 TEXT 索引类型, 需要单个的 Token, 也就是单词完全 match, 如果只是部分字母片段 match 是不行的.

[7]:
q = query.Term("title", "ener")
search(q)
---------- Query ----------------------------
---------- equivalent query string ----------
title:ener
---------- equivalent query object ----------
Term('title', 'ener')
---------- Result ----------------------------
[]

还是使用使用自动化的 QueryParser, 但这次我们有不止一个条件, 要求同时包含 sustainable 和 energy, 可以看出 QueryParser 默认会将多个 Term 用逻辑 AND 连接起来. 这点在官方文档中也有提及. 而且可以看到打印出来的 Query 是一个 AND([Term('title', 'sustainable'), Term('title', 'energy')]) 对象.

[8]:
q_str = "sustainable energy"
q = qparser.QueryParser("title", schema=schema).parse(q_str)
search(q)
---------- Query ----------------------------
---------- equivalent query string ----------
(title:sustainable AND title:energy)
---------- equivalent query object ----------
And([Term('title', 'sustainable'), Term('title', 'energy')])
---------- Result ----------------------------
[
    {
        'author': 'MacKay, David JC',
        'isbn': '0954452933',
        'title': 'Sustainable Energy - without the hot air',
        'year': 2009
    }
]

我们看看故意把一个词拿掉, 如果它默认是 OR, 那么应该依然会输出结果. 不过我们发现结果为空, 所以这和我们设想的一致.

[9]:
q_str = "sustainable ener"
q = qparser.QueryParser("title", schema=schema).parse(q_str)
search(q)
---------- Query ----------------------------
---------- equivalent query string ----------
(title:sustainable AND title:ener)
---------- equivalent query object ----------
And([Term('title', 'sustainable'), Term('title', 'ener')])
---------- Result ----------------------------
[]

同样, 以上的 query 也可以手动创建.

[10]:
q = query.And([query.Term("title", "sustainable"), query.Term("title", "energy")])
search(q)
---------- Query ----------------------------
---------- equivalent query string ----------
(title:sustainable AND title:energy)
---------- equivalent query object ----------
And([Term('title', 'sustainable'), Term('title', 'energy')])
---------- Result ----------------------------
[
    {
        'author': 'MacKay, David JC',
        'isbn': '0954452933',
        'title': 'Sustainable Energy - without the hot air',
        'year': 2009
    }
]

各种 Query Parser#

前面我们可以看出, 我们有各种 Query Parser 可用, 其中我们已经试过了 QueryParser, 它只能对一个 field 进行搜索. 下面是两个比较常用的 Parser:

  • QueryParser: 只能对一个 field 进行搜索.

  • MultifieldParser: 可以同时对多个 field 进行搜索. 这个应该是最常用的 Parser 了.

之后还有两个自带的 Parser, 我们列出了官方文档对它们的说明. 但我们需要先熟悉前面两个 Parser 里面的一些概念之后, 才能真正理解它们.

  • SimpleParser: Returns a QueryParser configured to support only +, -, and phrase syntax.

  • DisMaxParser: Returns a QueryParser configured to support only +, -, and phrase syntax, and which converts individual terms into DisjunctionMax queries across a set of fields.

在这一章, 我们来深入了解 QueryParserMultifieldParser.

Reference:

最常用的 MultifieldParser#

顾名思义, 默认你的 Term 会同时对多个 field 进行搜索. field 之间是 OR 的关系, 也就是只要有一个 match 即可. 而 Term 之间和之前一样, 默认仍然是 AND 的关系.

[11]:
q_str = "sustainable energy"
q = qparser.MultifieldParser(fieldnames=["title", "author"], schema=schema).parse(q_str)
search(q)
---------- Query ----------------------------
---------- equivalent query string ----------
((title:sustainable OR (author:sustai AND author:ustain AND author:staina AND author:tainab AND author:ainabl AND author:inable)) AND (title:energy OR author:energy))
---------- equivalent query object ----------
And([Or([Term('title', 'sustainable'), And([Term('author', 'sustai'), Term('author', 'ustain'), Term('author',
'staina'), Term('author', 'tainab'), Term('author', 'ainabl'), Term('author', 'inable')])]), Or([Term('title',
'energy'), Term('author', 'energy')])])
---------- Result ----------------------------
[
    {
        'author': 'MacKay, David JC',
        'isbn': '0954452933',
        'title': 'Sustainable Energy - without the hot air',
        'year': 2009
    }
]

指定某个 Term 只对某个 field 生效.#

之前的例子说明, 所有的 Term 默认会对所有指定的 field 生效. 在 MultifieldParser 中, 我们仍然可以用语法来定义某个 Term 只对某个 field 生效. 这个语法是 ${fieldname}:${query_string}. 从下面的例子可以看出, 我们如果只输入 energy, 最会对 titleauthor 两个 field 进行搜索. 而如果输入 title:energy, 则只对 title 进行搜索.

[12]:
q_str = "energy"
q = qparser.MultifieldParser(fieldnames=["title", "author"], schema=schema).parse(q_str)
search(q)
---------- Query ----------------------------
---------- equivalent query string ----------
(title:energy OR author:energy)
---------- equivalent query object ----------
Or([Term('title', 'energy'), Term('author', 'energy')])
---------- Result ----------------------------
[
    {
        'author': 'MacKay, David JC',
        'isbn': '0954452933',
        'title': 'Sustainable Energy - without the hot air',
        'year': 2009
    }
]
[13]:
q_str = "title:energy"
q = qparser.MultifieldParser(fieldnames=["title", "author"], schema=schema).parse(q_str)
search(q)
---------- Query ----------------------------
---------- equivalent query string ----------
title:energy
---------- equivalent query object ----------
Term('title', 'energy')
---------- Result ----------------------------
[
    {
        'author': 'MacKay, David JC',
        'isbn': '0954452933',
        'title': 'Sustainable Energy - without the hot air',
        'year': 2009
    }
]

使用 AND 连接多个 Term#

那如果我们要用 ${fieldname}:${query_string} 语法同时对多个 field 进行搜索应该怎么做呢? 答案是使用 AND 关键字. 除了 AND, whoosh 还支持 OR, ANDNOT, ANDMAYBE, and NOT 关键字, 这些关键字的定义可以在 query 模块的文档 中找到.

[14]:
q_str = "title:energy AND author:david"
q = qparser.MultifieldParser(fieldnames=["title", "author"], schema=schema).parse(q_str)
search(q)
---------- Query ----------------------------
---------- equivalent query string ----------
(title:energy AND author:david)
---------- equivalent query object ----------
And([Term('title', 'energy'), Term('author', 'david')])
---------- Result ----------------------------
[
    {
        'author': 'MacKay, David JC',
        'isbn': '0954452933',
        'title': 'Sustainable Energy - without the hot air',
        'year': 2009
    }
]

使用括号来对 AND, OR, NOT 进行排列组合.#

对逻辑与或非进行排列组合是很常见的操作, 在 whoosh 中我们可以用括号来实现. 例如下面这个例子中, (title:notavailable AND author:xyz) 第一个括号中的条件是无法 match 任何文档的, 但是由于 OR 后面的 (title:energy) 的存在, 所以最终还是能 match 出结果.

[15]:
q_str = "(title:notavailable AND author:xyz) OR (title:energy)"
q = qparser.MultifieldParser(fieldnames=["title", "author"], schema=schema).parse(q_str)
search(q)
---------- Query ----------------------------
---------- equivalent query string ----------
((title:notavailable AND author:xyz) OR title:energy)
---------- equivalent query object ----------
Or([And([Term('title', 'notavailable'), Term('author', 'xyz')]), Term('title', 'energy')])
---------- Result ----------------------------
[
    {
        'author': 'MacKay, David JC',
        'isbn': '0954452933',
        'title': 'Sustainable Energy - without the hot air',
        'year': 2009
    }
]

使用 Range Query#

根据大小区间进行搜索是很常见的需求. 在 whoosh 中的语法是 [lowerbound TO upperbound], 如果只有大于则是 [lowerbound TO], 如果只有小于则是 [TO upperbound]. 例如下面这个例子, 我们搜索 year 大于 2000 的文档.

[16]:
q_str = "year:[2000 TO]"
q = qparser.MultifieldParser(fieldnames=["age"], schema=schema).parse(q_str)
search(q)
---------- Query ----------------------------
---------- equivalent query string ----------
year:[2000 TO ]
---------- equivalent query object ----------
NumericRange('year', 2000, None, False, False, boost=1.0, constantscore=True)
---------- Result ----------------------------
[
    {
        'author': 'MacKay, David JC',
        'isbn': '0954452933',
        'title': 'Sustainable Energy - without the hot air',
        'year': 2009
    }
]

使用 >< 来进行 Range Query#

在其他的系统中, 用 >< 来进行 Range Query 是非常常见的. 在 whoosh 中, 默认是不支持的. 不过我们可以通过启用 GtLtPlugin 插件来使它支持 (是的, whoosh 还自带一个插件系统). 所有的 Parser 对象, 无论是 QueryParser 还是 MultifieldParser 都有一个 plugins 属性, 可以看到目前启用了哪些 Plugin. 下面这个例子列出了默认情况下 MultifieldParser 启用的 Plugins.

举例来说:

  • MultifieldPlugin: 使得 MultifieldParser 能够同时解析多个 field.

  • FieldsPlugin: 使得我们能使用 ${fieldname}:${query_string} 语法针对某个 field 进行搜索.

  • RangePlugin: 使得我们能使用 [lowerbound TO upperbound] 语法进行 Range Query.

  • OperatorsPlugin: 使得我们能使用 AND, OR, NOT 等逻辑运算符.

  • GroupPlugin: 使得我们能用括号对逻辑运算符进行排列组合.

后面的我们就不一一列举了. 有兴趣的读者可以自己去看一下 whoosh.queryparser 模块的文档.

一些干货

所以本质上, 无论是什么 Parser, 它的底层其实是通过启用一个个的 Plugin 使得它能支持各种各样的功能. 而这些不同的 Parser 的实现原理其实是在初始化一个 Base Parser 之后, 自动启用各种各样的 Plugin 而已. 知道了这一点, 你完全可以写出适合自己的 Parser. 推荐你阅读 qparser.QueryParserqparser.MultifieldParser 的源码了解以下实现原理.

[17]:
qparser.MultifieldParser(["year"], schema=schema).plugins
[17]:
[<whoosh.qparser.plugins.WhitespacePlugin at 0x107c22490>,
 <whoosh.qparser.plugins.WhitespacePlugin at 0x107c1b400>,
 <whoosh.qparser.plugins.SingleQuotePlugin at 0x107c1b9a0>,
 <whoosh.qparser.plugins.FieldsPlugin at 0x107c1bf10>,
 <whoosh.qparser.plugins.WildcardPlugin at 0x107c1b340>,
 <whoosh.qparser.plugins.PhrasePlugin at 0x107c1b5b0>,
 <whoosh.qparser.plugins.RangePlugin at 0x107c1bb20>,
 <whoosh.qparser.plugins.GroupPlugin at 0x107c1b370>,
 <whoosh.qparser.plugins.OperatorsPlugin at 0x107c22820>,
 <whoosh.qparser.plugins.BoostPlugin at 0x107c22f10>,
 <whoosh.qparser.plugins.EveryPlugin at 0x107c22760>,
 <whoosh.qparser.plugins.MultifieldPlugin at 0x107c22430>]

现在, 我们可以通过 qparser.MultifieldParser.add_plugin() 方法来添加 GtLtPlugin, 于是我们就可以使用 >< 来进行 Range Query 了.

[18]:
q_str = "year:>2000"
parser = qparser.MultifieldParser(["year"], schema=schema)
parser.add_plugin(qparser.GtLtPlugin())
q = parser.parse(q_str)
search(q)
---------- Query ----------------------------
---------- equivalent query string ----------
year:{2000 TO ]
---------- equivalent query object ----------
NumericRange('year', 2000, None, True, False, boost=1.0, constantscore=True)
---------- Result ----------------------------
[
    {
        'author': 'MacKay, David JC',
        'isbn': '0954452933',
        'title': 'Sustainable Energy - without the hot air',
        'year': 2009
    }
]

使用 Fuzzy Search 模糊搜索#

有时候人类的输入会有错误, 而 Fuzzy search 可以自动找到拼写相近的词, 而这个相近的意思就是 Edit Distance. 例如 Fuzzy search 所允许的 edit distance 是 1, 那么真正匹配到的词和你输入的词的字符差异应该在 1 个以内. 例如 energi 可以和 energy 匹配上. energ 也能和 energy 匹配上. 这个语法在 whoosh 中是 ${query}~${edit_distance}. 而且, 你需要启用 FuzzyTermPlugin 才能使用这一功能.

下面这段代码没有启用 FuzzyTermPlugin, 所以搜不到东西.

[19]:
q_str = "energi~1"
q = qparser.MultifieldParser(["title"], schema=schema).parse(q_str)
search(q)
---------- Query ----------------------------
---------- equivalent query string ----------
title:energi
---------- equivalent query object ----------
Term('title', 'energi')
---------- Result ----------------------------
[]

下面这个例子启用了 Plugin, 并且使用了 Edit Distance = 1, 所以能搜索到.

[20]:
q_str = "energi~1"
parser = qparser.MultifieldParser(["title"], schema=schema)
parser.add_plugin(qparser.FuzzyTermPlugin())
q = parser.parse(q_str)
search(q)
---------- Query ----------------------------
---------- equivalent query string ----------
title:energi~
---------- equivalent query object ----------
FuzzyTerm('title', 'energi', boost=1.000000, maxdist=1, prefixlength=0)
---------- Result ----------------------------
[
    {
        'author': 'MacKay, David JC',
        'isbn': '0954452933',
        'title': 'Sustainable Energy - without the hot air',
        'year': 2009
    }
]

下面这个例子中的 edit distance 只有 1, 但是 ener 和 energy 的差异有 2, 所以搜不到.

[21]:
q_str = "ener~1"
parser = qparser.MultifieldParser(["title"], schema=schema)
parser.add_plugin(qparser.FuzzyTermPlugin())
q = parser.parse(q_str)
search(q)
---------- Query ----------------------------
---------- equivalent query string ----------
title:ener~
---------- equivalent query object ----------
FuzzyTerm('title', 'ener', boost=1.000000, maxdist=1, prefixlength=0)
---------- Result ----------------------------
[]

如果 edit distance 设为 2, 自然就能搜索到了. 注意, distance 越大, 搜索耗时就越多.

[22]:
q_str = "ener~2"
parser = qparser.MultifieldParser(["title"], schema=schema)
parser.add_plugin(qparser.FuzzyTermPlugin())
q = parser.parse(q_str)
search(q)
---------- Query ----------------------------
---------- equivalent query string ----------
title:ener~2
---------- equivalent query object ----------
FuzzyTerm('title', 'ener', boost=1.000000, maxdist=2, prefixlength=0)
---------- Result ----------------------------
[
    {
        'author': 'MacKay, David JC',
        'isbn': '0954452933',
        'title': 'Sustainable Energy - without the hot air',
        'year': 2009
    }
]

Fuzzy Search 中还有 prefix 的概念. 其含义是有时候我们希望最开头的几个前缀字符必须要 match, 而所允许出错的部分必须在后面. 这时候你可以指定 prefix 的长度, 例如为 4, 那么前 4 个字符就必须 Match. 下面我们给出了两个例子, 一个成功例子, 一个失败例子.

[23]:
# 成功
q_str = "energi~1/4"
parser = qparser.MultifieldParser(["title"], schema=schema)
parser.add_plugin(qparser.FuzzyTermPlugin())
q = parser.parse(q_str)
search(q)
---------- Query ----------------------------
---------- equivalent query string ----------
title:energi~
---------- equivalent query object ----------
FuzzyTerm('title', 'energi', boost=1.000000, maxdist=1, prefixlength=4)
---------- Result ----------------------------
[
    {
        'author': 'MacKay, David JC',
        'isbn': '0954452933',
        'title': 'Sustainable Energy - without the hot air',
        'year': 2009
    }
]
[24]:
# 失败
q_str = "nergy~1/4"
parser = qparser.MultifieldParser(["title"], schema=schema)
parser.add_plugin(qparser.FuzzyTermPlugin())
q = parser.parse(q_str)
search(q)
---------- Query ----------------------------
---------- equivalent query string ----------
title:nergy~
---------- equivalent query object ----------
FuzzyTerm('title', 'nergy', boost=1.000000, maxdist=1, prefixlength=4)
---------- Result ----------------------------
[]

使用 Wildcard 通配符#

很多人都很熟悉在 regex 正则表达式中对应的 *, ? 符号. whoosh 默认也支持这个功能. 其语法类似这个样子 te?t test* *b?g*.

Reference:

给不同的 Term 不同的权重#

为了更精确的获得匹配, 你可能会想给不同的 Term 不同的权重 (注意, 这里不是给不同的 Field 不同的权重, field 的权重是在定义 Schema 的时候就定义好了的). 这个功能是默认启用的, 其语法是 ^ 符号, 例如 ninja^2 cowboy bear^0.5.

Reference:

[ ]: