DjangoBlog 中文搜索方案:Haystack + Jieba + Whoosh / Elasticsearch 双引擎实战

Python 2026-06-03 71
目录
预计阅读时间:12 分钟

做 DjangoBlog 这种内容型站点,搜索是绕不开的功能。但 Django 自带的 __icontains 之流根本上不了台面:不分词、不打分、无法高亮、数据量稍大就拖死数据库。社区的标准答案是 django-haystack,一个搜索抽象层,可以挂 Whoosh、Elasticsearch、Solr、Xapian 多种后端。

但 haystack 默认配置在中文场景下几乎不可用:

  • Whoosh 默认的 StemmingAnalyzer 是为英文词干提取设计的,遇到「Django 信号机制」这种短语,要么按空格切分,要么干脆当成单个 token,召回率极差。
  • Elasticsearch 默认的 standard 分词器对中文是按单字切,「博客」会被切成 "博" 和 "客",搜索时几乎匹配不到有意义的结果。
  • 单字搜索还会被 stopwords 过滤掉,返回空结果。
  • 高亮模块对 CJK 字符不友好,经常出现 <mark> 标签插在词中间的 BUG。

DjangoBlog 的解法是「一套 haystack 接口 + 两套自定义后端」:开发环境用 Whoosh + Jieba(零依赖、跑测试快),生产环境用 Elasticsearch + IK 分词(性能、扩展性、推荐词全有了),通过环境变量自动切换。下面拆开讲。

整体架构

            ┌─────────────────────────┐
            │   django-haystack 接口  │
            │  SearchQuerySet / Form  │
            └────────────┬────────────┘
                         │
         ┌───────────────┴───────────────┐
         ▼                               ▼
 WhooshEngine (本地)             ElasticSearchEngine (远程)
 jieba.ChineseAnalyzer           ik_max_word / ik_smart
 文件存储 whoosh_index/           ES 8.x cluster

入口在 djangoblog/settings.py,根据 DJANGO_ELASTICSEARCH_HOST 环境变量决定走哪条路:

if 'ELASTICSEARCH_DSL' in locals():
    HAYSTACK_CONNECTIONS = {
        'default': {
            'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine',
        }
    }
else:
    HAYSTACK_CONNECTIONS = {
        'default': {
            'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine',
            'PATH': os.path.join(BASE_DIR, 'whoosh_index'),
        },
    }

HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'

最后一行的 RealtimeSignalProcessor 是 haystack 自带的实时索引信号处理器,文章 save/delete 时会同步更新索引,省掉了定时 rebuild_index 的麻烦。注意它是阻塞的,如果文章量很大、写入很频繁,建议换成异步队列处理。

方案一:Whoosh + Jieba 中文后端

Whoosh 是纯 Python 实现的全文检索库,部署只要 pip install whoosh jieba 两行,特别适合本地开发和小型博客。问题前面说了,它默认不会中文分词。

魔改思路非常优雅:拷贝 haystack 源码自带的 whoosh_backend.py,只把分析器换成 jieba 提供的 ChineseAnalyzer

核心改动在 djangoblog/whoosh_cn_backend.py

from jieba.analyse import ChineseAnalyzer  # 新增

class WhooshSearchBackend(BaseSearchBackend):
    def build_schema(self, fields):
        # ...
        for field_name, field_class in fields.items():
            # ... 各种类型的 schema 处理
            else:
                # 原版:analyzer=StemmingAnalyzer()
                # 现在:analyzer=ChineseAnalyzer()
                schema_fields[field_class.index_fieldname] = TEXT(
                    stored=True,
                    analyzer=ChineseAnalyzer(),
                    field_boost=field_class.boost,
                    sortable=True,
                )

jieba.analyse.ChineseAnalyzer 本质上是把 jieba 分词器封装成 Whoosh 的 Analyzer 接口,build_schema 阶段挂进去,索引和查询两端就都走 jieba 切词了。「Django 信号机制」会被切成 Django / 信号 / 机制,召回率立刻正常。

自己实现高亮

Whoosh 自带的高亮基于片段评分,对 CJK 字符经常切出乱序的片段,标签也容易插错位置。所以 _process_results 方法被重写了,改用正则匹配关键词位置,再手工裁剪上下文:

if highlight:
    # 找到关键词在正文中的第一次出现位置
    match_pos = -1
    for term in query_string.split():
        if len(term) >= 2:
            pos = plain_text.lower().find(term.lower())
            if pos >= 0:
                match_pos = pos
                break

    if match_pos >= 0:
        # 取关键词前 150 / 后 350 字符作为摘要
        start = max(0, match_pos - 150)
        end = min(len(plain_text), match_pos + 350)
        snippet = plain_text[start:end]

        # 套 <mark> 标签
        for term in query_string.split():
            if len(term) >= 2:
                snippet = re.sub(
                    r'(' + re.escape(term) + r')',
                    r'<mark>\1</mark>',
                    snippet,
                    flags=re.IGNORECASE,
                )
        highlighted['body'] = [snippet]

为什么 len(term) >= 2 才高亮?因为单字 token 一来匹配数巨多容易把整篇文章染色,二来 Whoosh 的 stopwords 过滤也会把单字过滤掉,本来就不该作为有效查询词。

正文是 Markdown,所以先经过 CommonMarkdown.get_markdown() 转 HTML 再 strip_tags 拿到纯文本,避免摘要里夹一堆 ** []() 之类的标记。

方案二:Elasticsearch + IK 分词

到了几千篇文章往上或者要做聚合分析,就该上 ES 了。DjangoBlog 的 ES 集成基于 elasticsearch-dsl 8.x,文档模型定义在 blog/documents.py

class ArticleDocument(Document):
    body = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
    title = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
    author = Object(properties={
        'nickname': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
        'id': Integer(),
    })
    # ...
    pub_time = Date()
    status = Text()
    type = Text()

    class Index:
        name = 'blog'
        settings = {"number_of_shards": 1, "number_of_replicas": 0}

注意 analyzersearch_analyzer 分开设置,这是 IK 分词器的经典用法:

  • ik_max_word 用于索引:尽可能多地切分,比如「中华人民共和国」会被切成「中华人民共和国 / 中华人民 / 中华 / 华人 / 人民共和国 / 人民 / 共和国 / 共和 / 国」,保证召回
  • ik_smart 用于查询:做粗粒度切分,「中华人民共和国」就是单个 token,保证精度

前置条件:ES 集群要装 IK 分词插件,版本号要和 ES 完全一致。

ES 8.x 的兼容点

ES 7.x 升 8.x 默认开启了 X-Pack 安全特性(HTTPS + 强制认证),DjangoBlog 在 documents.py 顶部做了一个动态参数装配,把不同的认证方式都囊括:

es_params = {
    'hosts': hosts,
    'verify_certs': es_config.get('verify_certs', False),
}

# 用户名密码认证(默认)
if 'username' in es_config and 'password' in es_config:
    es_params['basic_auth'] = (es_config['username'], es_config['password'])

# API Key 认证
if 'api_key' in es_config:
    es_params['api_key'] = es_config['api_key']

# 证书认证
if 'ca_certs' in es_config:
    es_params['ca_certs'] = es_config['ca_certs']

if 'client_cert' in es_config and 'client_key' in es_config:
    es_params['client_cert'] = es_config['client_cert']
    es_params['client_key'] = es_config['client_key']

es = Elasticsearch(**es_params)
connections.create_connection(**es_params)

设计上让 settings 里写什么就用什么,环境变量也能完整覆盖,不需要为生产 / 开发分别写两套连接代码。

另一个坑是 ES 8.x 把 hits.total 从 int 改成了对象 {'value': 123, 'relation': 'eq'},老代码会拿到一个 dict 算长度爆炸。后端里这样兜底:

hits_total = results['hits']['total']
hits = hits_total['value'] if isinstance(hits_total, dict) else hits_total

推荐词(Did you mean ...)

ES 后端的搜索还做了一个 spelling suggestion 的小特性。用户输错关键词时,先用 term suggester 拿一个推荐词,再用推荐词去做实际搜索:

@staticmethod
def get_suggestion(query: str) -> str:
    search = ArticleDocument.search() \
        .query("match", body=query) \
        .suggest('suggest_search', query, term={'field': 'body'}) \
        .execute()

    keywords = []
    for suggest in search.suggest.suggest_search:
        if suggest["options"]:
            keywords.append(suggest["options"][0]["text"])
        else:
            keywords.append(suggest["text"])
    return ' '.join(keywords)

并通过自定义的 ElasticSearchModelSearchForm 由前端表单字段 is_suggest 控制是否启用,避免误触发:

class ElasticSearchModelSearchForm(ModelSearchForm):
    def search(self):
        self.searchqueryset.query.backend.is_suggest = \
            self.data.get("is_suggest") != "no"
        return super().search()

实际查询时用 bool / should 拼标题和正文双字段匹配,再 filter 只挑已发布、类型为文章的:

q = Q('bool',
      should=[Q('match', body=suggestion), Q('match', title=suggestion)],
      minimum_should_match=1)

search = ArticleDocument.search() \
    .query('bool', filter=[q]) \
    .filter('term', status='p') \
    .filter('term', type='a')

if highlight:
    search = search.highlight(
        'title', 'body',
        fragment_size=150,
        number_of_fragments=3,
        pre_tags=['<mark>'],
        post_tags=['</mark>'],
    )

ES 自己的高亮就够用了,不像 Whoosh 那样要手撸。

接入 haystack 标准接口

两套后端最终都暴露成 haystack 的标准 BaseEngine

class WhooshEngine(BaseEngine):
    backend = WhooshSearchBackend
    query = WhooshSearchQuery

class ElasticSearchEngine(BaseEngine):
    backend = ElasticSearchBackend
    query = ElasticSearchQuery

业务代码完全不用关心切了哪个引擎。blog/search_indexes.py 的索引声明只有十行:

class ArticleIndex(indexes.SearchIndex, indexes.Indexable):
    text = indexes.CharField(document=True, use_template=True)
    title = indexes.CharField(model_attr='title', stored=True)
    body = indexes.CharField(model_attr='body', stored=True)

    def get_model(self):
        return Article

    def index_queryset(self, using=None):
        return self.get_model().objects.filter(status='p')

text 字段用 use_template=True,告诉 haystack 去 templates/search/indexes/blog/article_text.txt 找拼装规则:

{{ object.title }}
{{ object.author.username }}
{{ object.body }}

这样标题、作者名、正文都进同一个文档字段,搜索时一并命中。index_queryset 限定只索引 status='p'(已发布)的文章,草稿不会被检索出来。

表单也是标准的 haystack SearchForm 子类:

class BlogSearchForm(SearchForm):
    querydata = forms.CharField(required=True)

    def search(self):
        datas = super().search()
        if not self.is_valid():
            return self.no_query_found()
        if self.cleaned_data['querydata']:
            logger.info(self.cleaned_data['querydata'])
        return datas

切换与调试小结

实际部署时的几条经验:

  1. 本地开发用 Whoosh:不要装环境变量 DJANGO_ELASTICSEARCH_HOST,跑 python manage.py rebuild_index 后索引文件会落到 whoosh_index/ 目录,零依赖。
  2. 生产用 ES:在 docker-compose / k8s 里把 DJANGO_ELASTICSEARCH_HOST=http://es:9200 这一组环境变量喂进去,应用启动时自动切到 ES 后端,索引第一次会被 ArticleDocumentManager.__init__ 触发的 create_index() 自动建好。
  3. RealtimeSignalProcessor 的副作用:每次 Article.save() 都会同步写索引,文章很多的批量导入场景会很慢。批处理时临时把它换成 BaseSignalProcessor(不监听信号),导入完再手动 rebuild_index
  4. 测试 jieba 分词效果:直接在 shell 里 from jieba.analyse import ChineseAnalyzer; list(ChineseAnalyzer()("Django 信号机制实战")),能看到完整的 token 流,对比预期帮助调优。
  5. ES 索引 mapping 变更ArticleDocument 的字段定义改了之后需要 delete_index 重建,老索引的 mapping 是不可变的。

收尾

整套方案的精髓不在魔改本身,而在「用 haystack 把后端的差异锁在两个文件里」:业务侧只见 SearchQuerySet 接口,从单机 Whoosh 切到分布式 ES,业务代码一行不用改。Whoosh + Jieba 跑日常开发够用,ES + IK 顶得住生产流量,环境变量一拨就切。如果你的 Django 项目里全文搜索还在用 __icontains,可以照着这套结构搬过去,半天就能落地。


本文由 liangliangyy 原创,转载请注明出处。

相关推荐

评论

0
暂无评论,来发表第一条评论吧

发表评论

登录 后发表评论

发现更多