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) 程式碼說明

完整程式碼已上傳至以下倉庫,直接 copy‑paste 即可使用。

GitHub 倉庫連結

以下僅挑出重點說明。

_build_lookup_for:保留 search_fields 前綴規則

search_fields 支援前綴:

  • =field → 完全相等(不分大小寫)
  • ^field → 前綴匹配
  • 預設為 icontains

我希望即使引入新語法,也能保持這些規則。因此寫了一個小 helper 依前綴返回對應的 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/AND 邏輯

核心邏輯在 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) 普通關鍵字保持 (field 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)

這樣的レビュー保留了原本 Admin 搜尋的使用感,同時讓欄位指定的搜尋更精準。


4) 使用方式:只要在 Admin 加上 Mixin

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

現在 Admin 搜尋框可以接受以下寫法:

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

若需要自訂說明文字,可改寫 get_changelist_instance() 來調整。


5) 反思與限制:"雖然好用,但我還想要更多"

使用這個 Mixin 最大的收穫是,在運營時可以自行決定搜尋範圍,感受到的便利度相當高。

當然也有局限:

  • Full‑text search(@ 前綴) 尚未支援。若結合 PostgreSQL 的 full‑text,效果會更好,但需要額外的設定與權重管理,超出本篇範圍。
  • icontains 為主,資料量大時可能出現 效能問題。特別是多欄位 OR 加上多個 term,查詢會變重。雖然實務上搜尋詞通常較短,但最壞情況仍需注意。
  • 未來想實作的功能包括:
  • created_at:2026-01-01..2026-01-31日期範圍搜尋
  • status:active,-status:deleted否定條件
  • field>10簡易比較運算子

目前的實作已經足夠「小而確實」地提升工作效率。每當我在運營中因搜尋卡住時,這個工具就會派上用場,感受到一點點程式碼改動帶來的長期效益,真的很令人欣慰。


相關文章