目录
做 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}
注意 analyzer 和 search_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
切换与调试小结
实际部署时的几条经验:
- 本地开发用 Whoosh:不要装环境变量
DJANGO_ELASTICSEARCH_HOST,跑python manage.py rebuild_index后索引文件会落到whoosh_index/目录,零依赖。 - 生产用 ES:在 docker-compose / k8s 里把
DJANGO_ELASTICSEARCH_HOST=http://es:9200这一组环境变量喂进去,应用启动时自动切到 ES 后端,索引第一次会被ArticleDocumentManager.__init__触发的create_index()自动建好。 RealtimeSignalProcessor的副作用:每次Article.save()都会同步写索引,文章很多的批量导入场景会很慢。批处理时临时把它换成BaseSignalProcessor(不监听信号),导入完再手动rebuild_index。- 测试 jieba 分词效果:直接在 shell 里
from jieba.analyse import ChineseAnalyzer; list(ChineseAnalyzer()("Django 信号机制实战")),能看到完整的 token 流,对比预期帮助调优。 - ES 索引 mapping 变更:
ArticleDocument的字段定义改了之后需要delete_index重建,老索引的 mapping 是不可变的。
收尾
整套方案的精髓不在魔改本身,而在「用 haystack 把后端的差异锁在两个文件里」:业务侧只见 SearchQuerySet 接口,从单机 Whoosh 切到分布式 ES,业务代码一行不用改。Whoosh + Jieba 跑日常开发够用,ES + IK 顶得住生产流量,环境变量一拨就切。如果你的 Django 项目里全文搜索还在用 __icontains,可以照着这套结构搬过去,半天就能落地。
本文由 liangliangyy 原创,转载请注明出处。
评论
0