ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Python+Django+Elasticsearch+MongoDB:构建支持中文分词的小型搜索引擎

Python+Django+Elasticsearch+MongoDB:构建支持中文分词的小型搜索引擎 简介这是一套基于Python搭建的小型搜索引擎完整项目源码面向具备一定Python与Web开发基础、希望理解搜索引擎底层原理的开发者与学习者。项目以Django作为Web框架借助Scrapy爬虫抓取数据并存入MongoDB再通过Elasticsearch完成索引与全文检索覆盖了从数据采集、存储到检索展示的完整链路适合作为课程设计、毕业设计或技术练手的参考案例。压缩包共187个文件约281KB以py源码与pyc编译文件为主另含html模板、js脚本、sqlite3数据库、cfg与config配置、md说明文档及Git版本控制相关文件结构上保留了原始工程目录便于直接运行与二次修改。目前已有3203人学习下载读者可从中获取爬虫与检索模块的代码实现、Django与Elasticsearch的对接方式以及项目整体组织思路具体使用说明可参考作者GitHub仓库。1. 从零搭一个能搜中文的小引擎为什么我选了 Elasticsearch MongoDB Django有个做后端的朋友接了个私活客户要一个「站内搜索」——数据量不大几十万条文档但要求支持中文分词、按相关度排序、还能按分类过滤。他第一反应是上 MySQL 的 LIKE结果一测十万条数据模糊匹配就要好几秒中文分词更是无从谈起。后来他换了个思路用 Elasticsearch 做检索层MongoDB 存原始文档Django 做 API 和后台管理。这套组合跑下来单机环境下百万级文档的检索响应稳定在几十毫秒中文分词、高亮、聚合过滤全都能覆盖。这就是「基于 Python 的小型搜索引擎」这个资源包要解决的问题。它不是让你从零手写倒排索引而是用成熟组件搭一套可落地的检索系统。适合谁适合需要给现有项目加搜索功能的后端开发者、想学 Elasticsearch 实战的学生、以及需要快速搭一个内部文档检索工具的人。技术栈就是 Python Django Elasticsearch MongoDB下面我把这套东西拆开讲清楚。2. 架构选型为什么是 Elasticsearch 而不是 Whoosh 或纯数据库2.1 三个组件的分工逻辑先把这个架构的核心思路说清楚。很多人一上来就问「为什么不用 Whoosh纯 Python 多简单」这个问题我踩过坑值得展开讲。Whoosh 是纯 Python 实现的全文检索引擎优点是零依赖、pip 装完就能用。但它的硬伤在于索引构建速度慢中文分词需要额外接 jieba而且不支持分布式。数据量超过十万条之后检索延迟会明显上升。我实测过同样五十万条中文文档Whoosh 建索引要十几分钟Elasticsearch 只要一两分钟检索延迟差距在五到十倍。Elasticsearch 的核心优势是倒排索引 分片机制。倒排索引的本质是「词 → 文档 ID 列表」的映射检索时直接查词表不需要扫描全部文档。分片则让索引可以水平拆分单机也能通过多分片提升并发。中文场景下ES 内置的 IK 分词器或者 smartcn 分词器能直接处理中文切词不需要自己写分词逻辑。MongoDB 在这里的角色是「原始文档仓库」。为什么不把文档直接存在 ES 里因为 ES 的 _source 字段虽然能存原始数据但它本质是搜索引擎不适合做频繁的更新和事务操作。MongoDB 的文档模型天然适合存非结构化的原始数据更新灵活查询也方便。两者配合的模式是MongoDB 存全量原始文档ES 存索引字段和用于检索的副本。Django 则是整个系统的门面负责三件事提供 REST API 给前端调用、管理后台做数据录入和索引重建、以及协调 MongoDB 和 ES 之间的数据同步。2.2 数据流向与同步策略数据从写入到可检索走的是这样一条链路# sync_service.py # 数据同步服务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. 构造 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 取原始文档然后挑出需要检索的字段构造 ES 文档最后用 MongoDB 的 _id 作为 ES 的文档 ID 写入。用同一个 ID 的好处是幂等——重复同步不会产生重复文档ES 会直接覆盖。参数方面settings.ES_INDEX是索引名建议按业务命名比如article_index_v1。带版本号的好处是重建索引时可以无缝切换不用停机。es_client.index()的document参数在 elasticsearch-py 8.x 里替代了旧版的body如果你用的是 7.x写法要改成bodyes_doc。同步策略有两种实时同步和批量同步。实时同步是在 Django 的 model save 信号里触发适合数据量小、要求即时可搜的场景。批量同步是用定时任务或者管理命令全量重建适合数据量大、可以接受分钟级延迟的场景。我一般会两个都做日常增量走实时同步每天凌晨跑一次全量重建兜底。注意ES 的 refresh_interval 默认是 1 秒意味着写入后最多 1 秒才能被搜到。如果业务要求写入即可搜可以在写入后手动调es_client.indices.refresh(index...)但频繁 refresh 会影响性能不建议在高频写入场景用。3. 环境搭建与索引设计从零跑通第一条检索3.1 依赖安装与服务启动先把环境跑起来。这套系统需要三个服务Elasticsearch、MongoDB、以及 Django 应用本身。我用 docker-compose 管理省得每个服务单独配。# 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:这里有几个参数值得说。discovery.typesingle-node是单机模式省去集群发现的配置。xpack.security.enabledfalse关掉安全认证本地开发方便生产环境必须打开。ES_JAVA_OPTS限制 JVM 堆内存默认 ES 会吃掉一半物理内存开发机上不限制容易把系统拖垮512MB 到 1GB 对小型项目够用。启动命令就一行docker-compose up -d起来之后验证一下curl http://localhost:9200返回带 cluster_name 和 version 的 JSON 就说明 ES 正常。MongoDB 用mongosh连一下确认即可。Python 侧的依赖pip install django elasticsearch pymongo jieba版本上elasticsearch-py 的版本要和 ES 服务端匹配8.x 的客户端连 8.x 的服务端跨大版本会有兼容问题。这个坑后面会细说。3.2 索引映射与中文分词配置索引映射mapping是 ES 的核心配置决定了字段怎么存、怎么分词、怎么检索。中文场景下mapping 配错是最常见的翻车点。# es_index.py # 创建索引并配置中文分词映射 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} 创建成功)这段 mapping 里有几个关键决策。title和content用text类型因为需要分词检索。category和tags用keyword类型因为它们是精确匹配和聚合用的不需要分词。created_at用date类型支持范围查询和排序。分词器用的是ik_smart这是 IK 分词器的粗粒度模式。IK 还有ik_max_word细粒度模式会把「中华人民共和国」拆成「中华人民共和国、中华人民、中华、华人、人民共和国、人民、共和国、共和、国」等一堆词。粗粒度只拆成「中华人民共和国」索引体积小检索精度对大多数场景够用。如果你的业务需要召回更多结果可以换成ik_max_word。注意IK 分词器不是 ES 自带的需要单独安装插件。安装方式是进入 ES 容器执行bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v8.11.0/elasticsearch-analysis-ik-8.11.0.zip版本号必须和 ES 版本严格一致否则启动会报错。如果装不了 IK可以用 ES 内置的smartcn分词器替代配置里把ik_smart换成smartcn即可。3.3 检索接口的实现与参数调优索引建好、数据同步进去之后检索接口就是最后一步。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) # 构造查询体 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 })这个查询体里有几个参数直接决定搜索质量。fields: [title^3, content^1]里的^3是权重提升意思是标题命中的得分是内容命中的三倍。这个权重不是拍脑袋定的一般标题权重给 2 到 5 倍比较合理具体看业务对标题匹配的重视程度。type: best_fields是 multi_match 的评分策略取所有字段中得分最高的那个作为最终得分。另一种策略是most_fields把所有字段的得分加起来适合字段之间内容互补的场景。大多数搜索场景用best_fields就够了。highlight配置里的fragment_size控制高亮片段长度number_of_fragments控制返回几个片段。内容字段如果很长不限制片段数量会导致响应体过大。sort里_score在前、created_at在后意思是先按相关度排相关度相同的再按时间倒序。如果你希望时间优先把顺序反过来即可。4. 避坑与排查那些让我加班到凌晨的报错4.1 中文分词不生效搜「搜索引擎」搜不到「搜索」现象索引里明明有「搜索引擎」这个词但搜「搜索」返回零结果。原因mapping 里字段类型写成了keyword或者分词器没配对。keyword类型不分词整段文本作为一个词存只有完全匹配才能搜到。解决确认字段类型是text并且analyzer指定了中文分词器。已经建好的索引改 mapping 需要重建用es.indices.put_mapping只能加字段不能改字段类型。重建流程是新建一个带版本号的索引用_reindex把数据迁过去然后删旧索引、建别名指向新索引。4.2 elasticsearch-py 版本不匹配报UnsupportedProductError现象代码跑起来直接抛UnsupportedProductError连不上 ES。原因客户端和服务端大版本不一致。比如客户端是 7.x服务端是 8.x或者反过来。8.x 的 ES 默认开启了安全认证客户端没配证书也会报这个错。解决先确认版本pip show elasticsearch看客户端版本curl localhost:9200看服务端版本。两边大版本对齐。如果服务端开了安全认证客户端初始化时要传basic_auth和verify_certs参数。本地开发图省事就把xpack.security.enabled设为 false。4.3 同步时字段类型冲突写入报mapper_parsing_exception现象同步某条数据时 ES 报failed to parse field [created_at] of type [date]。原因MongoDB 里created_at存的是字符串或者时间戳而 ES mapping 里定义的是date类型格式对不上。解决在同步代码里做类型转换把 MongoDB 的日期统一转成 ISO 8601 格式字符串再写入。或者把 ES 的date字段加上format参数声明支持的格式列表比如format: yyyy-MM-dd HH:mm:ss||epoch_millis。我一般倾向在同步层做转换让 ES 的 mapping 保持干净。4.4 深分页导致Result window is too large现象翻到第 100 页之后报错Result window is too large, from size must be less than or equal to: [10000]。原因ES 默认限制from size不能超过 10000这是保护机制防止深分页拖垮集群。解决三种方案。一是用search_after游标分页适合逐页翻的场景。二是用scroll快照分页适合导出全量数据的场景。三是业务上限制最大页数超过就提示用户 refine 搜索条件。小型搜索引擎一般用第一种就够了。4.5 索引重建时服务不可用现象重建索引期间搜索接口返回空结果或者报索引不存在。原因直接删了旧索引再建新索引中间有个空窗期。解决用别名alias做无缝切换。流程是新建article_index_v2数据同步完成后把别名article_index从 v1 切到 v2然后删掉 v1。代码里始终用别名检索不直接写索引名。这样切换是原子的用户无感知。5. 进阶技巧用 search_after 做稳定分页与相关性调优深分页这个问题值得单独展开因为它是小型搜索引擎从「能用」到「好用」的分水岭。from size的分页方式在数据量大时性能急剧下降因为 ES 需要从每个分片取出from size条数据然后在协调节点排序最后截取from到from size这一段。翻到第 100 页时每个分片都要返回 1000 条开销是第 1 页的 100 倍。search_after的思路是「从上一页最后一条的位置继续取」不需要跳过前面的数据。实现上需要一个稳定的排序字段组合通常用_score加一个唯一字段比如_id。# search_after 分页实现 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保证排序稳定 ] } 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: # 取最后一条的排序值作为下一页的游标 next_cursor hits[-1][sort] return { results: hits, next_cursor: next_cursor }关键点是sort里必须有一个唯一字段做 tiebreaker。如果只用_score排序相同得分的文档顺序是不确定的search_after会漏数据或者重复数据。加上_id之后排序就稳定了。search_after的值直接取上一页最后一条的sort数组原样传回来即可。这个方案的代价是不支持随机跳页只能上一页下一页。对搜索引擎来说这通常不是问题用户很少会直接跳到第 50 页。如果业务确实需要跳页可以限制跳页范围比如只允许跳到前 100 条超出部分引导用户加关键词。相关性调优方面除了字段权重还可以用function_score做更精细的控制。比如给新文档加权、给特定分类加权、或者用脚本评分。我一般会先跑一批典型查询看 top 10 结果是否符合预期不符合就调权重反复几轮。这个过程有点像调参玄学但有几个原则标题权重永远高于内容精确匹配高于模糊匹配短文档的得分天然高于长文档可以用field_length归一化来缓解。从那以后我每次上线搜索功能前都会强制走一遍「典型查询回归」——准备二十个左右的查询词人工看 top 5 结果确认没有明显离谱的排序。这个习惯帮我拦住了好几次权重配错导致的线上问题。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表