1) 问题提出:“为什么会出现这么多无关结果?”

最近博客文章越来越多,系统化管理变得必不可少。我的博客支持多语言,同一个 slug 可能对应多个实例,文章数量增长后,想要精准定位某篇文章变得有点困难。虽然这说明博客内容在丰富,但也带来了检索挑战。

我认为难以一次性找到目标实例的根本原因在于 Django Admin 默认的搜索逻辑(search_fields过于宽松

手持放大镜在图书馆寻找图书的管理员

search_fields 设置很多(功能越多越常见)时,一输入关键词,搜索框会同时匹配:

  • 标题
  • 描述
  • 用户名
  • 甚至无关的备注字段

结果不再是“很多”,而是“我想找的东西被埋在海量结果中”。运营中急需定位某个领域,却被数十条结果淹没,实在让人烦恼。

于是我产生了“如果能只针对某个字段搜索就好了”的想法。


2) 解决思路:field:value 语法 + shlex 处理引号

思路其实很简单。

  • 通过 name:diff 指定字段进行搜索
  • 通过 title:"system design" 支持包含空格的关键词

这里 shlex.split() 非常有用。Admin 的搜索框默认按空格直接分割,这会导致 "system design" 之类的输入被拆开。使用 shlex,可以像 shell 一样尊重引号,正确拆分 token。

示例:

  • title:"system design"["title:system design"]
  • name:diff python["name:diff", "python"]

也就是说,即使 字段限定搜索普通关键词 混合,也能优雅地解析。


3) 代码说明

完整代码已上传至仓库,直接复制即可使用。

GitHub 仓库链接

下面只挑出核心部分进行讲解。

_build_lookup_for:保持 search_fields 前缀规则

Django Admin 的 search_fields 支持前缀:

  • =field → 精确匹配(不区分大小写)
  • ^field → 前缀匹配
  • 默认 → icontains

我希望即使引入新语法,也保持这些规则不变。因此实现了一个小助手根据前缀返回相应的 lookup。

def _build_lookup_for(self, raw_field):
    if raw_field.startswith('='):
        return raw_field.lstrip('=^@'), '__iexact'
    if raw_field.startswith('^'):
        return raw_field.lstrip('=^@'), '__istartswith'
    return raw_field.lstrip('=^@'), '__icontains'

例如在 admin.py 中写 search_fields = ['=name', 'title'],则:

  • name 使用 iexact
  • title 使用 icontains

同样的 lookup 规则也会应用到 field:value 的搜索中。


get_search_results:字段限定使用 AND,普通关键词保持原有 OR 逻辑

核心逻辑位于 get_search_results()

  1. shlex 将输入拆分为 token
  2. 将 token 分为两类 - field:value 且字段在 search_fields 中 → field_specific_terms - 其余 → normal_terms
  3. 字段限定搜索 累加为 AND
  4. 普通关键词保持 Django Admin 的 每个 term 对所有字段 OR,term 之间 AND

关键代码片段:

terms = shlex.split(search_term or "")

field_specs = [self._build_lookup_for(f) for f in getattr(self, 'search_fields', [])]
field_lookup_map = {}
for name, lookup in field_specs:
    field_lookup_map.setdefault(name, lookup)

field_specific_terms = []
normal_terms = []

for t in terms:
    if ':' in t:
        field, value = t.split(':', 1)
        if field in field_lookup_map and value != '':
            field_specific_terms.append((field, value))
        else:
            normal_terms.append(t)
    else:
        normal_terms.append(t)

(1) field:value 采用 AND 累加

for field, value in field_specific_terms:
    lookup = field_lookup_map[field]
    qs = qs.filter(Q(**{f"{field}{lookup}": value}))

例如 name:diff status:active 会被转化为 name == diff AND status == active

(2) 普通关键词保持 (字段 OR) → term AND

if normal_terms and field_lookup_map:
    for term in normal_terms:
        term_q = Q()
        for field, lookup in field_lookup_map.items():
            term_q |= Q(**{f"{field}{lookup}": term})
        qs = qs.filter(term_q)

因此 name:diff python 实际等价于:

  • name = diff AND
  • (name OR title OR description) 包含 python

这种设计的好处是 不破坏原有的搜索体验:字段限定让搜索更精确,剩余部分仍保持宽松。


4) 使用方法:在 Admin 中混入 Mixin 即可

@admin.register(MyModel)
class MyAdmin(FieldScopedSearchMixin, admin.ModelAdmin):
    search_fields = ['=name', 'title', 'description']

现在搜索框支持以下写法:

  • name:diff
  • title:"system design"
  • name:diff python

如果需要自定义帮助信息,只需覆盖 get_changelist_instance() 即可。


5) 回顾与局限:"很好用,但还有更大的欲望"

使用这个 Mixin 后,我最满意的是 可以自行调节搜索范围,显著降低了运营时的痛点。

但也有局限:

  • 全文搜索(@ 前缀) 暂未实现。若结合 PostgreSQL 的全文检索,可进一步提升智能程度,但涉及到权重、tsvector 等配置,超出了本例的范围。
  • 依赖 icontains,当数据量大时 性能可能受影响,尤其是多字段 OR 与多个 term 同时出现时查询会变重。实际生产中搜索词往往较短,但极端情况仍需注意。
  • 未来想尝试的功能包括:
  • created_at:2026-01-01..2026-01-31日期范围搜索
  • status:active,-status:deleted排除条件
  • field>10简单比较运算

总的来说,这个实现已经足够 小而精准,解决了我在运营中真实遇到的痛点,使用频率很高。仅仅是对思路和代码稍作打磨,就能让工作效率永久提升,这正是编码的魅力所在。


相关链接