Python 全文搜索:whoosh3支持BM25排序与词干匹配,不用 Elasticsearch

原文:https://dev.to/priyasundaram/add-real-search-to-your-python-web-app-no-elasticsearch-no-separate-service-8la(作者 @priyasundaram)

每个应用最终都会遇到比 WHERE title LIKE '%...%' 更好的搜索需求。LIKE 无法对结果排序,不知道 running 应该匹配 run,而且一旦表数据量可观,查询就会变慢。常规的下一步——搭一个 Elasticsearch 或 OpenSearch——意味着引入 JVM、一个需要维护的集群,以及一个需要保持同步的独立数据存储。对大量应用来说,这就像用起重机挂相框。

whoosh3 是一个纯 Python 全文搜索库(Whoosh 的维护版延续)。pip install whoosh3 之后,你就拥有 BM25 排序、词干提取、真正的查询语言,以及一个只是磁盘上一个文件夹的索引——它在你的进程内运行,不需要服务器。下面介绍如何把它接入 Web 应用,包括那些容易踩坑的运维细节。

按你的需求设计排序的 Schema

from whoosh.fields import Schema, TEXT, ID, NUMERIC
from whoosh.analysis import StemmingAnalyzer

schema = Schema(
    id=ID(stored=True, unique=True),
    title=TEXT(analyzer=StemmingAnalyzer(), stored=True, field_boost=2.0),
    body=TEXT(analyzer=StemmingAnalyzer(), stored=True),
    views=NUMERIC(stored=True, sortable=True),
)


这里有三个选择起着实际作用。id 上的 unique=True 让索引支持 upsert——用相同 id 写入两次会替换该行而不是重复插入。StemmingAnalyzerwidget 能匹配到 widgetsfield_boost=2.0 表示标题中的匹配权重是正文的两倍,这样正确的结果会自动浮到顶部,无需手动打分。

每个应用都需要的三个操作

索引只创建一次(它存在于一个目录中),之后所有操作就是 upsert、删除和搜索:

from whoosh import index
from whoosh.qparser import MultifieldParser, OrGroup
from whoosh.writing import AsyncWriter

ix = index.create_in("indexdir", schema)   # or index.open_dir("indexdir")

def upsert(doc: dict):
    w = AsyncWriter(ix)
    w.update_document(**doc)        # insert-or-replace on the unique `id`
    w.commit()

def delete(doc_id: str):
    w = AsyncWriter(ix)
    w.delete_by_term("id", doc_id)
    w.commit()

def search(q: str, k: int = 5):
    with ix.searcher() as s:
        parser = MultifieldParser(["title", "body"], ix.schema, group=OrGroup)
        hits = s.search(parser.parse(q), limit=k)
        return [(h["id"], h["title"], round(h.score, 3)) for h in hits]


update_document 就是完整的同步方案:每当真实数据库中的某行发生变化,就用相同的 id 调用 upsert,索引就会跟上。看看这对排序的影响:

upsert({"id":"1","title":"Getting started with widgets",
        "body":"install and configure your first widget","views":10})
upsert({"id":"2","title":"Widget troubleshooting",
        "body":"fix common widget errors and crashes","views":99})

search("widget")
# [('2', 'Widget troubleshooting', 1.435), ('1', 'Getting started with widgets', 1.397)]

upsert({"id":"1","title":"Getting started with gadgets",
        "body":"install your first gadget","views":10})   # doc 1 edited in place

search("widget")
# [('2', 'Widget troubleshooting', 2.386)]   <- doc 1 dropped out, score adjusted
search("gadget")
# [('1', 'Getting started with gadgets', 3.432)]


MultifieldParser 同时搜索标题和正文;OrGroup 让多词查询能匹配到任意词项(更倾向召回),而 BM25 仍然会把最接近的匹配排在最前面。

一个陷阱:同一时间只能有一个 writer

这是朴素集成在高负载下崩溃的地方。Whoosh 索引同一时间只允许一个 writer——在另一个 writer 未提交时打开第二个 ix.writer() 会得到 LockError。在并发请求的 Web 应用中,这种情况一定会发生。两种稳健模式:

  1. `AsyncWriter`(上面用到的):如果索引被锁,它会在后台线程中重试而不是抛异常。适合中低写入频率——评论、编辑、偶尔的管理员变更。
  2. 单一 writer / 写入队列: 把全部写入汇集到一个 worker(后台任务、Celery worker、专用线程)并批量提交。写入频繁时最佳,因为每次 commit() 都有固定开销,批量提交可以摊薄它。

读取没有这种限制:每个请求打开一个新的 ix.searcher()(它们很轻量,能看到最后一次提交的状态),绝不要在请求之间持有同一个 searcher。commit() 返回后,新 searcher 立即可见变更——接近实时,无需调整刷新间隔。

接入 FastAPI

端点只是那三个函数的薄封装:

from fastapi import FastAPI
app = FastAPI()

@app.put("/docs/{doc_id}")
def put(doc_id: str, doc: dict):
    upsert({"id": doc_id, **doc}); return {"ok": True}

@app.delete("/docs/{doc_id}")
def remove(doc_id: str):
    delete(doc_id); return {"ok": True}

@app.get("/search")
def query(q: str, k: int = 10):
    return [{"id": i, "title": t, "score": s} for i, t, s in search(q, k)]


这就是一个生产形态的搜索 API——排序、词干提取、增量更新——除了应用本身无需部署任何服务,搜索索引通过复制一个文件夹就能备份。FastAPI、Flask 和 Django 的完整可运行版本在仓库的 `examples/` 目录中。

什么时候仍需要 Elasticsearch?当你真的超出了单节点、进程内索引的规模——超大体量语料、分布式分片、集群分析。在此之前,嵌入式引擎要运行的东西更少、更容易出问题的地方也更少,而且完全够用。


原文:https://dev.to/priyasundaram/add-real-search-to-your-python-web-app-no-elasticsearch-no-separate-service-8la(作者 @priyasundaram)

发布评论
全部评论(0)