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 Repository] (https://github.com/mikihands/djangomixins/blob/main/admin_fieldscopedsearch.py)

以下僅挑出核心概念說明。

_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/AND 邏輯

主要邏輯寫在 get_search_results() 中:

  1. 先用 shlex 把輸入字串切成 token。
  2. 把 token 分成兩類 * 符合 field:valuefieldsearch_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 搜尋的使用感,同時讓 field:value 能精準縮小範圍。


4) 使用方式:只要把 Mixin 加到 Admin 即可

設定非常簡單。

@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 最大的收穫是,讓我在日常運營時能自行調整搜尋範圍,感受到即時的效率提升。

然而它仍有以下限制:

  • Full‑text search(@ 前綴) 尚未支援。若結合 PostgreSQL 的 full‑text,搜尋會更聰明,只是需要額外的設定與權重管理。
  • icontains 為主,資料量大時可能會出現效能問題。特別是多欄位 OR 再加上多個 term,查詢會變得沉重。雖然大多數營運情境下關鍵字都很短,但最壞情況仍須留意。
  • 未來想嘗試的功能包括:
  • created_at:2026-01-01..2026-01-31日期範圍搜尋
  • status:active,-status:deleted否定條件
  • field>10簡易比較運算子

總體而言,這個小工具已經足夠「小而確實」地提升使用體驗。每當我在運營中碰到搜尋卡關的點,它就會馬上派上用場,讓工作流程更順暢。

只要稍微投入一些思考與實作,就能讓日常工作效率永久提升,這也是程式開發的魅力所在。


相關文章