建支持中文分詞的小型搜索引擎)
簡介這是一套基于Python搭建的小型搜索引擎完整項目源碼面向具備一定Python與Web開發(fā)基礎(chǔ)、希望理解搜索引擎底層原理的開發(fā)者與學(xué)習(xí)者。項目以Django作為Web框架借助Scrapy爬蟲抓取數(shù)據(jù)并存入MongoDB再通過Elasticsearch完成索引與全文檢索覆蓋了從數(shù)據(jù)采集、存儲到檢索展示的完整鏈路適合作為課程設(shè)計、畢業(yè)設(shè)計或技術(shù)練手的參考案例。壓縮包共187個文件約281KB以py源碼與pyc編譯文件為主另含html模板、js腳本、sqlite3數(shù)據(jù)庫、cfg與config配置、md說明文檔及Git版本控制相關(guān)文件結(jié)構(gòu)上保留了原始工程目錄便于直接運(yùn)行與二次修改。目前已有3203人學(xué)習(xí)下載讀者可從中獲取爬蟲與檢索模塊的代碼實現(xiàn)、Django與Elasticsearch的對接方式以及項目整體組織思路具體使用說明可參考作者GitHub倉庫。1. 從零搭一個能搜中文的小引擎為什么我選了 Elasticsearch MongoDB Django有個做后端的朋友接了個私活客戶要一個「站內(nèi)搜索」——數(shù)據(jù)量不大幾十萬條文檔但要求支持中文分詞、按相關(guān)度排序、還能按分類過濾。他第一反應(yīng)是上 MySQL 的 LIKE結(jié)果一測十萬條數(shù)據(jù)模糊匹配就要好幾秒中文分詞更是無從談起。后來他換了個思路用 Elasticsearch 做檢索層MongoDB 存原始文檔Django 做 API 和后臺管理。這套組合跑下來單機(jī)環(huán)境下百萬級文檔的檢索響應(yīng)穩(wěn)定在幾十毫秒中文分詞、高亮、聚合過濾全都能覆蓋。這就是「基于 Python 的小型搜索引擎」這個資源包要解決的問題。它不是讓你從零手寫倒排索引而是用成熟組件搭一套可落地的檢索系統(tǒng)。適合誰適合需要給現(xiàn)有項目加搜索功能的后端開發(fā)者、想學(xué) Elasticsearch 實戰(zhàn)的學(xué)生、以及需要快速搭一個內(nèi)部文檔檢索工具的人。技術(shù)棧就是 Python Django Elasticsearch MongoDB下面我把這套東西拆開講清楚。2. 架構(gòu)選型為什么是 Elasticsearch 而不是 Whoosh 或純數(shù)據(jù)庫2.1 三個組件的分工邏輯先把這個架構(gòu)的核心思路說清楚。很多人一上來就問「為什么不用 Whoosh純 Python 多簡單」這個問題我踩過坑值得展開講。Whoosh 是純 Python 實現(xiàn)的全文檢索引擎優(yōu)點(diǎn)是零依賴、pip 裝完就能用。但它的硬傷在于索引構(gòu)建速度慢中文分詞需要額外接 jieba而且不支持分布式。數(shù)據(jù)量超過十萬條之后檢索延遲會明顯上升。我實測過同樣五十萬條中文文檔Whoosh 建索引要十幾分鐘Elasticsearch 只要一兩分鐘檢索延遲差距在五到十倍。Elasticsearch 的核心優(yōu)勢是倒排索引 分片機(jī)制。倒排索引的本質(zhì)是「詞 → 文檔 ID 列表」的映射檢索時直接查詞表不需要掃描全部文檔。分片則讓索引可以水平拆分單機(jī)也能通過多分片提升并發(fā)。中文場景下ES 內(nèi)置的 IK 分詞器或者 smartcn 分詞器能直接處理中文切詞不需要自己寫分詞邏輯。MongoDB 在這里的角色是「原始文檔倉庫」。為什么不把文檔直接存在 ES 里因為 ES 的 _source 字段雖然能存原始數(shù)據(jù)但它本質(zhì)是搜索引擎不適合做頻繁的更新和事務(wù)操作。MongoDB 的文檔模型天然適合存非結(jié)構(gòu)化的原始數(shù)據(jù)更新靈活查詢也方便。兩者配合的模式是MongoDB 存全量原始文檔ES 存索引字段和用于檢索的副本。Django 則是整個系統(tǒng)的門面負(fù)責(zé)三件事提供 REST API 給前端調(diào)用、管理后臺做數(shù)據(jù)錄入和索引重建、以及協(xié)調(diào) MongoDB 和 ES 之間的數(shù)據(jù)同步。2.2 數(shù)據(jù)流向與同步策略數(shù)據(jù)從寫入到可檢索走的是這樣一條鏈路# sync_service.py # 數(shù)據(jù)同步服務(wù)MongoDB 寫入后同步到 Elasticsearch from pymongo import MongoClient from elasticsearch import Elasticsearch from django.conf import settings mongo_client MongoClient(settings.MONGO_URI) mongo_db mongo_client[settings.MONGO_DB] es_client Elasticsearch(settings.ES_HOSTS) def sync_document(doc_id): 將單條文檔從 MongoDB 同步到 ES # 1. 從 MongoDB 讀取原始文檔 doc mongo_db.articles.find_one({_id: doc_id}) if not doc: return {ok: False, reason: document not found} # 2. 構(gòu)造 ES 索引文檔剔除不需要檢索的字段 es_doc { title: doc.get(title, ), content: doc.get(content, ), category: doc.get(category, ), tags: doc.get(tags, []), created_at: doc.get(created_at), } # 3. 寫入 ES使用 doc_id 作為文檔 ID 保證冪等 es_client.index( indexsettings.ES_INDEX, idstr(doc_id), documentes_doc, ) return {ok: True}這段代碼的邏輯很直白先從 MongoDB 取原始文檔然后挑出需要檢索的字段構(gòu)造 ES 文檔最后用 MongoDB 的 _id 作為 ES 的文檔 ID 寫入。用同一個 ID 的好處是冪等——重復(fù)同步不會產(chǎn)生重復(fù)文檔ES 會直接覆蓋。參數(shù)方面settings.ES_INDEX是索引名建議按業(yè)務(wù)命名比如article_index_v1。帶版本號的好處是重建索引時可以無縫切換不用停機(jī)。es_client.index()的document參數(shù)在 elasticsearch-py 8.x 里替代了舊版的body如果你用的是 7.x寫法要改成bodyes_doc。同步策略有兩種實時同步和批量同步。實時同步是在 Django 的 model save 信號里觸發(fā)適合數(shù)據(jù)量小、要求即時可搜的場景。批量同步是用定時任務(wù)或者管理命令全量重建適合數(shù)據(jù)量大、可以接受分鐘級延遲的場景。我一般會兩個都做日常增量走實時同步每天凌晨跑一次全量重建兜底。注意ES 的 refresh_interval 默認(rèn)是 1 秒意味著寫入后最多 1 秒才能被搜到。如果業(yè)務(wù)要求寫入即可搜可以在寫入后手動調(diào)es_client.indices.refresh(index...)但頻繁 refresh 會影響性能不建議在高頻寫入場景用。3. 環(huán)境搭建與索引設(shè)計從零跑通第一條檢索3.1 依賴安裝與服務(wù)啟動先把環(huán)境跑起來。這套系統(tǒng)需要三個服務(wù)Elasticsearch、MongoDB、以及 Django 應(yīng)用本身。我用 docker-compose 管理省得每個服務(wù)單獨(dú)配。# docker-compose.yml version: 3.8 services: elasticsearch: image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0 environment: - discovery.typesingle-node - xpack.security.enabledfalse - ES_JAVA_OPTS-Xms512m -Xmx512m ports: - 9200:9200 volumes: - es_data:/usr/share/elasticsearch/data mongodb: image: mongo:7.0 ports: - 27017:27017 volumes: - mongo_data:/data/db volumes: es_data: mongo_data:這里有幾個參數(shù)值得說。discovery.typesingle-node是單機(jī)模式省去集群發(fā)現(xiàn)的配置。xpack.security.enabledfalse關(guān)掉安全認(rèn)證本地開發(fā)方便生產(chǎn)環(huán)境必須打開。ES_JAVA_OPTS限制 JVM 堆內(nèi)存默認(rèn) ES 會吃掉一半物理內(nèi)存開發(fā)機(jī)上不限制容易把系統(tǒng)拖垮512MB 到 1GB 對小型項目夠用。啟動命令就一行docker-compose up -d起來之后驗證一下curl http://localhost:9200返回帶 cluster_name 和 version 的 JSON 就說明 ES 正常。MongoDB 用mongosh連一下確認(rèn)即可。Python 側(cè)的依賴pip install django elasticsearch pymongo jieba版本上elasticsearch-py 的版本要和 ES 服務(wù)端匹配8.x 的客戶端連 8.x 的服務(wù)端跨大版本會有兼容問題。這個坑后面會細(xì)說。3.2 索引映射與中文分詞配置索引映射mapping是 ES 的核心配置決定了字段怎么存、怎么分詞、怎么檢索。中文場景下mapping 配錯是最常見的翻車點(diǎn)。# es_index.py # 創(chuàng)建索引并配置中文分詞映射 from elasticsearch import Elasticsearch from django.conf import settings es Elasticsearch(settings.ES_HOSTS) INDEX_NAME article_index_v1 mapping { settings: { number_of_shards: 1, number_of_replicas: 0, analysis: { analyzer: { ik_smart_analyzer: { type: custom, tokenizer: ik_smart } } } }, mappings: { properties: { title: { type: text, analyzer: ik_smart_analyzer, search_analyzer: ik_smart_analyzer }, content: { type: text, analyzer: ik_smart_analyzer }, category: { type: keyword }, tags: { type: keyword }, created_at: { type: date } } } } def create_index(): if es.indices.exists(indexINDEX_NAME): print(f索引 {INDEX_NAME} 已存在) return es.indices.create(indexINDEX_NAME, bodymapping) print(f索引 {INDEX_NAME} 創(chuàng)建成功)這段 mapping 里有幾個關(guān)鍵決策。title和content用text類型因為需要分詞檢索。category和tags用keyword類型因為它們是精確匹配和聚合用的不需要分詞。created_at用date類型支持范圍查詢和排序。分詞器用的是ik_smart這是 IK 分詞器的粗粒度模式。IK 還有ik_max_word細(xì)粒度模式會把「中華人民共和國」拆成「中華人民共和國、中華人民、中華、華人、人民共和國、人民、共和國、共和、國」等一堆詞。粗粒度只拆成「中華人民共和國」索引體積小檢索精度對大多數(shù)場景夠用。如果你的業(yè)務(wù)需要召回更多結(jié)果可以換成ik_max_word。注意IK 分詞器不是 ES 自帶的需要單獨(dú)安裝插件。安裝方式是進(jìn)入 ES 容器執(zhí)行bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v8.11.0/elasticsearch-analysis-ik-8.11.0.zip版本號必須和 ES 版本嚴(yán)格一致否則啟動會報錯。如果裝不了 IK可以用 ES 內(nèi)置的smartcn分詞器替代配置里把ik_smart換成smartcn即可。3.3 檢索接口的實現(xiàn)與參數(shù)調(diào)優(yōu)索引建好、數(shù)據(jù)同步進(jìn)去之后檢索接口就是最后一步。Django 里寫一個搜索視圖# views.py from rest_framework.decorators import api_view from rest_framework.response import Response from elasticsearch import Elasticsearch from django.conf import settings es Elasticsearch(settings.ES_HOSTS) api_view([GET]) def search(request): keyword request.GET.get(q, ).strip() category request.GET.get(category, ) page int(request.GET.get(page, 1)) size int(request.GET.get(size, 10)) if not keyword: return Response({error: keyword is required}, status400) # 構(gòu)造查詢體 must_clauses [ { multi_match: { query: keyword, fields: [title^3, content^1], type: best_fields } } ] filter_clauses [] if category: filter_clauses.append({term: {category: category}}) query_body { query: { bool: { must: must_clauses, filter: filter_clauses } }, highlight: { fields: { title: {}, content: {fragment_size: 150, number_of_fragments: 3} } }, from: (page - 1) * size, size: size, sort: [_score, {created_at: desc}] } resp es.search(indexarticle_index_v1, bodyquery_body) hits resp[hits][hits] results [] for hit in hits: item { id: hit[_id], score: hit[_score], source: hit[_source], highlight: hit.get(highlight, {}) } results.append(item) return Response({ total: resp[hits][total][value], page: page, results: results })這個查詢體里有幾個參數(shù)直接決定搜索質(zhì)量。fields: [title^3, content^1]里的^3是權(quán)重提升意思是標(biāo)題命中的得分是內(nèi)容命中的三倍。這個權(quán)重不是拍腦袋定的一般標(biāo)題權(quán)重給 2 到 5 倍比較合理具體看業(yè)務(wù)對標(biāo)題匹配的重視程度。type: best_fields是 multi_match 的評分策略取所有字段中得分最高的那個作為最終得分。另一種策略是most_fields把所有字段的得分加起來適合字段之間內(nèi)容互補(bǔ)的場景。大多數(shù)搜索場景用best_fields就夠了。highlight配置里的fragment_size控制高亮片段長度number_of_fragments控制返回幾個片段。內(nèi)容字段如果很長不限制片段數(shù)量會導(dǎo)致響應(yīng)體過大。sort里_score在前、created_at在后意思是先按相關(guān)度排相關(guān)度相同的再按時間倒序。如果你希望時間優(yōu)先把順序反過來即可。4. 避坑與排查那些讓我加班到凌晨的報錯4.1 中文分詞不生效搜「搜索引擎」搜不到「搜索」現(xiàn)象索引里明明有「搜索引擎」這個詞但搜「搜索」返回零結(jié)果。原因mapping 里字段類型寫成了keyword或者分詞器沒配對。keyword類型不分詞整段文本作為一個詞存只有完全匹配才能搜到。解決確認(rèn)字段類型是text并且analyzer指定了中文分詞器。已經(jīng)建好的索引改 mapping 需要重建用es.indices.put_mapping只能加字段不能改字段類型。重建流程是新建一個帶版本號的索引用_reindex把數(shù)據(jù)遷過去然后刪舊索引、建別名指向新索引。4.2 elasticsearch-py 版本不匹配報UnsupportedProductError現(xiàn)象代碼跑起來直接拋UnsupportedProductError連不上 ES。原因客戶端和服務(wù)端大版本不一致。比如客戶端是 7.x服務(wù)端是 8.x或者反過來。8.x 的 ES 默認(rèn)開啟了安全認(rèn)證客戶端沒配證書也會報這個錯。解決先確認(rèn)版本pip show elasticsearch看客戶端版本curl localhost:9200看服務(wù)端版本。兩邊大版本對齊。如果服務(wù)端開了安全認(rèn)證客戶端初始化時要傳basic_auth和verify_certs參數(shù)。本地開發(fā)圖省事就把xpack.security.enabled設(shè)為 false。4.3 同步時字段類型沖突寫入報mapper_parsing_exception現(xiàn)象同步某條數(shù)據(jù)時 ES 報failed to parse field [created_at] of type [date]。原因MongoDB 里created_at存的是字符串或者時間戳而 ES mapping 里定義的是date類型格式對不上。解決在同步代碼里做類型轉(zhuǎn)換把 MongoDB 的日期統(tǒng)一轉(zhuǎn)成 ISO 8601 格式字符串再寫入?;蛘甙?ES 的date字段加上format參數(shù)聲明支持的格式列表比如format: yyyy-MM-dd HH:mm:ss||epoch_millis。我一般傾向在同步層做轉(zhuǎn)換讓 ES 的 mapping 保持干凈。4.4 深分頁導(dǎo)致Result window is too large現(xiàn)象翻到第 100 頁之后報錯Result window is too large, from size must be less than or equal to: [10000]。原因ES 默認(rèn)限制from size不能超過 10000這是保護(hù)機(jī)制防止深分頁拖垮集群。解決三種方案。一是用search_after游標(biāo)分頁適合逐頁翻的場景。二是用scroll快照分頁適合導(dǎo)出全量數(shù)據(jù)的場景。三是業(yè)務(wù)上限制最大頁數(shù)超過就提示用戶 refine 搜索條件。小型搜索引擎一般用第一種就夠了。4.5 索引重建時服務(wù)不可用現(xiàn)象重建索引期間搜索接口返回空結(jié)果或者報索引不存在。原因直接刪了舊索引再建新索引中間有個空窗期。解決用別名alias做無縫切換。流程是新建article_index_v2數(shù)據(jù)同步完成后把別名article_index從 v1 切到 v2然后刪掉 v1。代碼里始終用別名檢索不直接寫索引名。這樣切換是原子的用戶無感知。5. 進(jìn)階技巧用 search_after 做穩(wěn)定分頁與相關(guān)性調(diào)優(yōu)深分頁這個問題值得單獨(dú)展開因為它是小型搜索引擎從「能用」到「好用」的分水嶺。from size的分頁方式在數(shù)據(jù)量大時性能急劇下降因為 ES 需要從每個分片取出from size條數(shù)據(jù)然后在協(xié)調(diào)節(jié)點(diǎn)排序最后截取from到from size這一段。翻到第 100 頁時每個分片都要返回 1000 條開銷是第 1 頁的 100 倍。search_after的思路是「從上一頁最后一條的位置繼續(xù)取」不需要跳過前面的數(shù)據(jù)。實現(xiàn)上需要一個穩(wěn)定的排序字段組合通常用_score加一個唯一字段比如_id。# search_after 分頁實現(xiàn) def search_with_cursor(keyword, size10, search_afterNone): query_body { query: { multi_match: { query: keyword, fields: [title^3, content^1] } }, size: size, sort: [ {_score: desc}, {_id: asc} # 唯一字段做 tiebreaker保證排序穩(wěn)定 ] } if search_after: query_body[search_after] search_after resp es.search(indexarticle_index, bodyquery_body) hits resp[hits][hits] next_cursor None if hits: # 取最后一條的排序值作為下一頁的游標(biāo) next_cursor hits[-1][sort] return { results: hits, next_cursor: next_cursor }關(guān)鍵點(diǎn)是sort里必須有一個唯一字段做 tiebreaker。如果只用_score排序相同得分的文檔順序是不確定的search_after會漏數(shù)據(jù)或者重復(fù)數(shù)據(jù)。加上_id之后排序就穩(wěn)定了。search_after的值直接取上一頁最后一條的sort數(shù)組原樣傳回來即可。這個方案的代價是不支持隨機(jī)跳頁只能上一頁下一頁。對搜索引擎來說這通常不是問題用戶很少會直接跳到第 50 頁。如果業(yè)務(wù)確實需要跳頁可以限制跳頁范圍比如只允許跳到前 100 條超出部分引導(dǎo)用戶加關(guān)鍵詞。相關(guān)性調(diào)優(yōu)方面除了字段權(quán)重還可以用function_score做更精細(xì)的控制。比如給新文檔加權(quán)、給特定分類加權(quán)、或者用腳本評分。我一般會先跑一批典型查詢看 top 10 結(jié)果是否符合預(yù)期不符合就調(diào)權(quán)重反復(fù)幾輪。這個過程有點(diǎn)像調(diào)參玄學(xué)但有幾個原則標(biāo)題權(quán)重永遠(yuǎn)高于內(nèi)容精確匹配高于模糊匹配短文檔的得分天然高于長文檔可以用field_length歸一化來緩解。從那以后我每次上線搜索功能前都會強(qiáng)制走一遍「典型查詢回歸」——準(zhǔn)備二十個左右的查詢詞人工看 top 5 結(jié)果確認(rèn)沒有明顯離譜的排序。這個習(xí)慣幫我攔住了好幾次權(quán)重配錯導(dǎo)致的線上問題。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取