☰
基于Scrapy和Elasticsearch与Django打造全文搜索引擎
2026/10/26 8:42:38 网站建设 项目流程

简介:本资源是一套完整的毕业设计项目实现方案,面向计算机专业本科生及Web开发初学者,聚焦小型全文搜索引擎的端到端构建,有效整合网络爬虫、倒排索引与Web交互三大核心能力。项目基于Scrapy实现定向网页抓取,ElasticSearch提供高性能全文检索与分词支持,Django搭建响应式搜索界面与后端服务,覆盖从数据采集、存储建模到用户查询的全链路实践。压缩包含2000个文件,主体为1605个Python脚本(含Scrapy爬虫、Django视图与ES交互逻辑)、122个HTML模板(含搜索页、结果页、分页组件)及89个JS前端交互文件,辅以CSS样式文件(如select2.css、widgets.css等)保障界面可用性,整体大小88.15MB。目前已有135人学习下载,资源结构清晰、模块解耦明确,附带完整可运行代码、索引映射定义与基础配置说明,适合毕设快速启动、技术栈串联训练及搜索引擎原理实操验证。 每年到毕设选题季,我都看到大量同学在同一个问题上反复纠结:题目怎么选,才既不显得太水,又能完整展示自己的工程能力,最好答辩的时候还能当场跑出效果。如果你主攻Python,那我强烈建议你考虑一下这个方向:基于Scrapy + Elasticsearch + Django 做一个小型全文搜索引擎。这个组合乍看有点“技术全家桶”的意思,但实际上每个组件的边界非常清晰:Scrapy负责把网页数据抓下来,Elasticsearch负责把数据建成倒排索引并提供搜索能力,Django负责把搜索结果变成网页和接口。三者拼起来就是一个完整的“爬取—索引—检索”闭环,从演示效果到答辩讲稿,你都有真东西可讲。

这篇文章我会把整个项目从零到一的完整链路拆开讲,包括技术选型的理由、环境搭建的坑、爬虫怎么写、索引怎么设计、搜索接口怎么调、前端怎么展示高亮结果,以及最后部署上线的注意事项。无论你是想拿它当毕业设计,还是单纯想练手一个全栈搜索引擎项目,这篇都值得从头到尾过一遍。我会把“为什么这么做”也讲清楚,而不是只丢给你一堆能跑但不知道为什么的代码。

1. 项目定位与技术选型:为什么非得是这三件套

1.1 这个项目到底在解决什么问题

先说清楚“全文搜索引擎”和普通数据库搜索有什么区别。很多人一上来会用MySQL的LIKE查询做搜索,比如WHERE title LIKE '%关键词%',这在数据量小的时候没毛病,但一旦数据到几万条以上,你会发现两个问题:一是慢,LIKE语句基本不走索引,全表扫一遍非常吃力;二是质量差,它只能做字符串完全包含匹配,不关心词义、不关心词序、不给结果排序。比如用户搜“Python机器学习”,它只会机械地匹配包含这串字符的文档,而搜不到“Python的机器学习入门指南”这种更自然的表达。

而Elasticsearch这类搜索引擎的核心是“倒排索引”。你可以把它理解成书最后面的关键字索引:解析每一篇文档,把内容拆成词条,然后记录“哪个词出现在哪篇文章里”。查询的时候不是扫全表,而是直接查词条对应的文档列表,再用相关度算法(比如BM25)给结果打分排序。这就是全文搜索引擎和普通数据库LIKE查询的本质区别。

所以这个毕设项目的定位很简单:用爬虫抓取一批垂直领域的内容(比如技术文章、新闻、课程介绍),清洗干净后导入Elasticsearch做索引,再通过Django封装出搜索页面和API,最终实现一个带高亮、带分页、带排序的小型垂直搜索引擎。它不是一个通用的大型搜索引擎,而是一个“麻雀虽小、五脏俱全”的完整系统,用来展示你对全链路技术的理解,刚好合适。

1.2 三件套的分工逻辑

Scrapy、Elasticsearch、Django这三个东西在项目里的角色可以这样理解:

  • Scrapy:数据采集层。负责从目标网站抓取原始页面,提取标题、正文、发布时间、作者等信息,做清洗后输出结构化数据。Scrapy是Python生态里最成熟的爬虫框架,自带请求调度、去重、中间件、Item Pipeline,适合做规模可控的抓取任务。它的异步并发模型能有效提升抓取速度,但又不至于像手写多线程那样难维护。

  • Elasticsearch:索引与检索引擎。负责接收Scrapy清洗后的数据,建立倒排索引,对外提供全文检索、高亮、过滤、聚合等能力。它是整个搜索引擎项目的核心引擎,也是你答辩时最值得展开讲的部分。为什么选ES而不是Solr?因为ES的生态更活跃,资料多,RESTful接口清晰,跟Python配合也顺。

  • Django:Web应用层。负责搭建搜索网站和提供API接口。用户在前端输入关键词,请求到Django,Django再转调Elasticsearch的查询接口,拿到结果后渲染到页面上。Django自带ORM、模板、表单、后台管理,能快速搭出一个像模像样的搜索站点,减少你在Web层花费的时间。

为什么不直接用Sphinx?Sphinx虽然老牌,但配置复杂、中文分词支持弱,社区活跃度也远不如ES。为什么不直接用Lucene?Lucene只是一个Java库,你还需要自己封装服务、处理并发、设计接口,工作量直接翻倍。相比之下,“Scrapy + ES + Django”是Python毕设项目里性价比最高的组合:每个组件都有明确职责,又能清晰展示技术深度。

2. 环境准备与基础搭建:版本选错,后面全是眼泪

2.1 版本组合怎么选

这一步我放在最前面讲,因为版本问题在整条链路里藏得最深,一旦埋下雷,后面每一步都可能踩上。很多同学上手就装最新版,结果ES 8.x默认开启安全认证,Django这边连es客户端都要配用户名密码;Scrapy新版又对Python版本有要求,环环相扣,最后花了半天时间全耗在环境上。

我实际用下来比较稳妥的版本组合是:

组件推荐版本说明
Python3.10 或 3.11兼容性最稳,别用3.7以下
Django4.2 LTS长期支持,资料多
Scrapy2.11.x稳定版本
scrapy-playwright最新版需要配合Playwright浏览器
Elasticsearch7.17.x不用8.x是因为7.x没有强制安全认证,适合毕设
elasticsearch-py7.17.x必须和ES主版本保持一致,别装8.x的客户端去连7.x
IK分词器7.17.x必须和ES版本完全一致,否则加载不出来

这里有个容易被忽略的核心原则:Elasticsearch主版本、elasticsearch-py客户端版本、IK分词器版本三者必须严格对齐。比如ES是7.17.3,IK分词器也必须下载7.17.3的包,elasticsearch-py要用7.x版。你装一个ES 8.x却用7.x的客户端连接,直接报版本不兼容;IK版本对不上,ES启动时插件加载直接失败。版本问题看似基础,但绝对是新手遇到频率最高的坑。

2.2 Elasticsearch安装与启动:全平台踩坑实录

Elasticsearch的安装本身不复杂,但操作系统不同,坑的位置不一样。

Linux下的操作:下载7.17.x的tar包,解压后,不要直接用root用户启动(ES出于安全策略会拒绝root直接运行),建议创建一个es用户,把目录属主改过去,然后切换用户启动。还需要把Linux系统参数vm.max_map_count调到至少262144,否则启动会报“max virtual memory areas vm.max_map_count [65530] is too low”,这个错误很经典,用sysctl -w vm.max_map_count=262144可以临时解决,永久生效要写到/etc/sysctl.conf。启动命令是:

# 解压 tar -zxvf elasticsearch-7.17.3-linux-x86_64.tar.gz # 创建用户并授权 useradd es chown -R es:es elasticsearch-7.17.3 # 切换用户启动 su es cd elasticsearch-7.17.3/bin ./elasticsearch -d

Windows下的操作:下载zip包解压后,直接双击bin/elasticsearch.bat就能起来。但要注意三点:第一,解压路径不要带中文和空格,有的机器上带中文路径会启动异常;第二,ES自带了JDK,只要JAVA_HOME变量没有强制指向别的JDK,它默认用自己的内置JDK,不用额外装Java;第三,如果机器内存较小,启动前先去config/jvm.options把-Xms和-Xmx从默认的1g调小或调大,比如-Xms512m -Xmx512m,否则占用内存太猛。

启动后,访问http://localhost:9200,能看到一串带cluster_name、version信息的JSON,就说明ES起来了。如果你想确认健康状态,访问http://localhost:9200/_cluster/health,返回"status" : "green"表示集群健康。ES的9200端口就是HTTP REST接口,后面Django会走这个口子;9300是集群节点通信端口,单机部署不需要管它。

2.3 Django和Scrapy项目骨架快速搭建

环境到位后,先建两个子项目。我建议在同一个目录下分成两个工程,一个是后端Web工程,一个是爬虫工程,代码目录清晰分开,后续维护不迷路。

# 创建Django工程 django-admin startproject search_site cd search_site python manage.py startapp search_api # 回到根目录,创建Scrapy工程 scrapy startproject article_spider cd article_spider scrapy genspider article example.com

Django这边需要装elasticsearch-py和djangorestframework(如果你决定用DRF写API),Scrapy这边需要装scrapy-playwright和对应的浏览器内核。安装命令大致这样:

pip install django==4.2.* djangorestframework elasticsearch==7.17.* pip install scrapy scrapy-playwright playwright install chromium

这里有同学会问:为什么Scrapy要配Playwright?因为现在很多目标站点都是前后端分离的SPA应用,页面里的正文是通过JavaScript异步加载的,甚至内容嵌在iframe里,Scrapy默认的HTTP请求拿到的HTML是空的。加一个Playwright就是为了解决这种动态渲染问题,后面的章节我会专门展开。

3. 爬虫采集层:Scrapy的完整实战

3.1 先写一个基础Spider,跑通抓取链路

写爬虫之前,先明确一件事:我们做的搜索引擎,抓取对象尽量选择公开的、允许爬取的内容站点,最好是你自己有权限的测试站。毕设演示阶段,完全可以用自己搭建的博客站或者目标站点允许抓取的部分来跑通流程,既合法又能完整展示技术链路。

一个最基础的Spider长这样:

import scrapy class ArticleSpider(scrapy.Spider): name = "article" def start_requests(self): # 这里的起始URL,替换成你自己的目标列表页 yield scrapy.Request(url="http://example.com/list", callback=self.parse_list) def parse_list(self, response): # 从列表页提取详情页链接 for url in response.css("a.article-link::attr(href)").getall(): yield scrapy.Request(url=response.urljoin(url), callback=self.parse_detail) def parse_detail(self, response): # 提取标题、正文、发布时间等字段 yield { "title": response.css("h1.title::text").get(), "content": response.css("div.content::text").getall(), "publish_time": response.css("span.date::text").get(), "url": response.url, }

这段代码体现了Scrapy的核心机制:start_requests是入口,parse_list负责解析列表页并yield新的请求,parse_detail负责解析详情页并把结构化数据yield出去。Scrapy的调度器会自动管理这些请求,保证并发、去重、失败重试都处理在框架内部。

抓详情页的时候,一个重要技巧是尽量提取“干净”的正文。很多页面的正文区域混杂着广告、推荐阅读、脚本标签,你需要先定位到正文所在的容器节点,再提取文本。用response.css("div.article-content")先把正文区块框出来,再在这个区块内部提取文本,比直接在整个页面上抓文本干净得多。

3.2 动态页面和iframe:Playwright的正确打开方式

现在很多网站的数据都在JS里加载,甚至放在iframe里。举个例子:一个页面主体框架是静态HTML,但评论列表、数据表格这种模块是放在一个iframe里异步加载的。直接用Scrapy的Request去抓,返回的HTML里没有iframe内的内容。

最简单可靠的方案就是scrapy-playwright。你不必对每个请求都启用浏览器渲染,只在需要的时候通过meta标记即可:

import scrapy class ArticleSpider(scrapy.Spider): name = "article" def parse_list(self, response): for url in response.css("a.article-link::attr(href)").getall(): yield scrapy.Request( url=response.urljoin(url), callback=self.parse_detail, meta={"playwright": True}, # 只对详情页启用渲染 ) async def parse_detail(self, response): # 直接取常规字段 title = response.css("h1.title::text").get() # 取iframe里的内容 page = response.meta["playwright_page"] iframe_content = await page.frame_locator("#comment-iframe").locator("body").text_content() yield { "title": title, "content": iframe_content or "", }

为什么用async def parse_detail?因为Playwright的调用是异步的,Scrapy的callback需要配合异步协程。这里面最需要注意的点是:meta={"playwright": True}只对当前请求生效,不要全局开启,因为渲染页面比普通HTTP请求慢几十倍,全开会拖慢整个爬虫。我见过太多人图省事给所有请求都开了渲染,结果一个小型爬虫跑得比单线程还慢。

还有一个小坑:启用Playwright之后,response.css这些解析方式依旧可用,但如果你需要在页面里点击、滚动、等待元素,就必须拿到playwright_page对象来操作。比如等待动态内容加载完成,可以await page.wait_for_selector("div.content")再提取,否则可能抓到空数据。

3.3 数据清洗与Item Pipeline:给ES喂干净数据

爬虫提取出来的原始数据往往带着HTML标签、换行符、多余空白,放进ES之前必须清洗。清洗逻辑最好放在Item Pipeline里,这样Spider只负责提取,Pipeline负责加工,职责分离。

一个典型的Pipeline长这样:

import re import hashlib from elasticsearch import Elasticsearch, helpers class CleanDataPipeline: def process_item(self, item, spider): # 清洗正文 content = "".join(item.get("content", [])) content = re.sub(r"<[^>]+>", "", content) # 去标签 content = re.sub(r"\s+", " ", content).strip() # 去多余空白 item["content"] = content # 用URL生成稳定文档ID url = item.get("url", "") item["doc_id"] = hashlib.sha1(url.encode("utf-8")).hexdigest() return item class ElasticsearchPipeline: def open_spider(self, spider): self.es = Elasticsearch(["http://localhost:9200"]) def process_item(self, item, spider): action = { "_op_type": "index", "_index": "articles", "_id": item["doc_id"], # 用稳定ID,重复抓取自动覆盖 "_source": { "title": item["title"], "content": item["content"], "url": item["url"], "publish_time": item.get("publish_time"), }, } helpers.bulk(self.es, [action]) return item

这里有两个关键的工程经验。第一,用URL做文档ID实现幂等:同一个URL重复抓取时,ES会直接覆盖旧文档,而不是新增一条,防止索引数据重复。第二,清洗要放在入库前:ES不是用来存垃圾的,正文里残留的标签和脚本会影响分词质量,进而影响搜索结果的相关性。

3.4 增量抓取与Scrapy-Redis的简单扩展

爬虫不能只跑一次。搜索引擎要持续可用,就需要周期性更新数据。最简单实用的增量策略是:抓取前先查一下ES里有没有这个URL的文档,有且时间没变就跳过;或者抓取列表页的时候只取最近“一段时间内”的新内容。

另一个常见的提升方向是引入Scrapy-Redis。Scrapy默认的去重队列在内存里,爬虫一停,去重记录就丢了。配置了Scrapy-Redis之后,请求队列和去重指纹放到Redis里,爬虫中断后重新启动,可以从Redis里接着跑,实现断点续爬,而且多个爬虫节点可以共享同一个请求队列,天然支持分布式扩展。配置很简单:

# settings.py SCHEDULER = "scrapy_redis.scheduler.Scheduler" DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter" REDIS_URL = "redis://localhost:6379/0"

加上这几行,爬虫的调度核心就从内存切换到了Redis。毕设阶段,不需要真做分布式,但能在技术上讲清楚“这个设计可以水平扩展”,就已经是一个非常好的加分项了。

4. 索引层:Elasticsearch核心设计

4.1 倒排索引和中文分词:先搞懂再动手

这是整个搜索引擎项目中最重要的一节。ES之所以能快速全文检索,靠的是倒排索引。以文章标题“Python机器学习实战”为例,分词后变成“python”“机器”“学习”“实战”,ES维护一张映射表:每个词条指向包含它的文档ID列表。查询“机器学习”时,先分词成“机器”“学习”,然后直接查表拿到文档集合,计算相关度得分,返回排序结果。

这里的关键是分词器。ES默认的standard分词器对中文是按单个汉字切分的,搜“学习”可能匹配到“学到”“学习委员”等噪声内容,召回一堆不相关的结果。所以中文搜索必须要装IK分词器。IK提供了两种分词模式:

  • ik_max_word:最细粒度切分,比如“中华人民共和国”切成“中华人民共和国”“中华人民”“中华”“华人”“人民共和国”等。索引建这个,能让召回率更高。
  • ik_smart:粗粒度切分,只保留最合理的词,比如“中华人民共和国”只切成“中华人民共和国”。搜索时用这个,能让精度更高。

实际项目里,索引用ik_max_word,搜索用ik_smart,一粗一细,兼顾召回和精度。这是一个非常经典且实用的配置。

IK分词器还支持自定义词库。你可以在plugins/ik/config/IKAnalyzer.cfg.xml里配置ext_dict,指向一个词库文件,把“毕业设计”“机器学习”“深度学习”这类领域词加进去,分词效果会大幅提升。我之前在项目里加入领域词库后,搜索质量肉眼可见地变好,算是一个低成本高收益的优化点。

4.2 索引Mapping设计:字段类型决定搜索行为

创建索引之前,Mapping设计要想清楚。我建议的articles索引结构大概这样:

{ "mappings": { "properties": { "title": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } }, "content": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" }, "url": { "type": "keyword" }, "publish_time": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss" }, "author": { "type": "keyword" } } } }

这个Mapping里有几个设计点值得展开。第一,title字段同时建了text和keyword子字段:text用于分词搜索,keyword用于精确匹配、排序和聚合。第二,content和title都指定了analyzer和search_analyzer,一个管索引分词,一个管查询分词,双向匹配才能保证中文搜索效果。第三,url和author用keyword而不是text,因为它们不需要分词,keyword类型搜索是精确匹配,还能用于聚合统计。

创建索引的时候,在命令行用curl:

curl -X PUT "http://localhost:9200/articles" -H "Content-Type: application/json" -d @mapping.json

也可以用Python客户端:

from elasticsearch import Elasticsearch es = Elasticsearch(["http://localhost:9200"]) mapping = { "mappings": { "properties": { "title": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart", "fields": {"keyword": {"type": "keyword", "ignore_above": 256}} }, "content": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" }, "url": {"type": "keyword"}, "publish_time": {"type": "date", "format": "yyyy-MM-dd HH:mm:ss"}, "author": {"type": "keyword"} } } } if not es.indices.exists(index="articles"): es.indices.create(index="articles", body=mapping)

4.3 数据写入:Bulk批量比逐条快得多

Scrapy Pipeline里,如果每抓一篇文章就调用一次ES的index接口,数据量一大性能就很差。正确做法是用helpers.bulk批量写入。这里的关键点:不要每处理一个item就bulk一次,而是攒够一批(比如100条)再写一次。

from elasticsearch import Elasticsearch, helpers es = Elasticsearch(["http://localhost:9200"]) actions = [] for item in items: action = { "_op_type": "index", "_index": "articles", "_id": item["doc_id"], "_source": { "title": item["title"], "content": item["content"], "url": item["url"], "publish_time": item["publish_time"], }, } actions.append(action) if len(actions) >= 100: helpers.bulk(es, actions) actions.clear() if actions: helpers.bulk(es, actions)

数据写入阶段最常见的报错是字段类型冲突。比如你第一次写入的publish_time是字符串“2024-05-01”,ES自动识别成date类型;但下一次某个字段传了条数字,ES就会报mapper [publish_time] of different type。解决办法是:在Mapping里明确规定字段类型,写入前统一做类型校验和转换。

5. 后端服务层:Django如何对接ES

5.1 搜索API设计

Django负责把ES的能力包装成HTTP接口。我的建议是直接用DRF写一个搜索API视图,入口参数设计为q(搜索关键词)、page(页码)、size(每页条数)。前端不管ES长什么样,只跟Django交互。

from rest_framework.views import APIView from rest_framework.response import Response from elasticsearch import Elasticsearch es = Elasticsearch(["http://localhost:9200"]) class SearchAPIView(APIView): def get(self, request): q = request.GET.get("q", "").strip() page = int(request.GET.get("page", 1)) size = int(request.GET.get("size", 10)) if page < 1: page = 1 if size > 50: # 防止一次取太多 size = 50 body = build_search_dsl(q, page, size) result = es.search(index="articles", body=body) # 组装返回数据 data = { "total": result["hits"]["total"]["value"], "page": page, "size": size, "results": [ { "title": hit["_source"]["title"], "url": hit["_source"]["url"], "publish_time": hit["_source"].get("publish_time", ""), "score": hit["_score"], "highlight": hit.get("highlight", {}), } for hit in result["hits"]["hits"] ], } return Response(data)

这里build_search_dsl是核心函数,负责根据用户输入动态构造ES查询DSL。为什么不直接在前端拼ES查询?因为ES的DSL结构复杂,直接暴露给前端既不安全也不好维护。Django作为中间服务层,屏蔽了ES的细节,未来想换搜索引擎,只需改这里。

5.2 查询DSL:must、should、must_not到底怎么组合

ES查询的核心是bool查询,它有四个子句,作用完全不同:

  • must:必须匹配,等价于AND,同时参与相关度评分。
  • filter:必须匹配,但不参与评分,结果会被缓存,适合做过滤条件。
  • should:可以匹配,等价于OR,用来提升文档分数;如果must或filter里没有条件,should至少要匹配一个。
  • must_not:必须不匹配,等价于NOT。

一个实用的搜索结果页查询DSL长这样:

{ "query": { "bool": { "must": [ { "multi_match": { "query": "机器学习", "fields": ["title^3", "content"], "type": "best_fields" } } ], "filter": [ { "range": { "publish_time": { "gte": "2023-01-01" } } } ], "must_not": [ { "term": { "author.keyword": "admin" } } ], "should": [ { "term": { "title.keyword": "从零开始" } } ], "minimum_should_match": 0 } }, "highlight": { "pre_tags": ["<em>"], "post_tags": ["</em>"], "fields": { "title": {}, "content": {"fragment_size": 150, "number_of_fragments": 3} } }, "from": 20, "size": 10, "sort": [ {"_score": "desc"}, {"publish_time": "desc"} ] }

这个DSL里包含了几个非常关键的优化点。fields: ["title^3", "content"]表示标题字段的相关性权重是3倍,因为标题比正文更能代表一篇文档的主题。filter里放时间范围,既过滤了结果,又不会影响评分,还能让ES缓存过滤结果,比直接塞进must更高效。highlight里给标题和正文都配置高亮,正文高亮片段控制在150个字符,返回3个片段,避免页面被超长摘要撑爆。

还有一个新手容易犯的错:当查询里同时有must和should时,should下子句的默认minimum_should_match是0,也就是说should条件一个都不满足也能返回。只有在只有should的条件下,才会默认要求至少匹配一个子句。这个规则搞不清楚,就会出现“为什么我加了should,结果里还是有完全无关的内容”这种问题。要强行要求必须匹配一定数量的should子句,就显式设置"minimum_should_match": 1。

5.3 搜索结果分页:别掉进深分页的坑

ES的默认分页是from + size,比如取第3页,每页10条,就是from: 20, size: 10。这种分页方式在数据量小的时候没问题,但当你有100万条文档时,from到10000以上就会报错:

Result window is too large, from + size must be less than or equal to [10000]

这是ES的深分页保护机制,为的是避免内存被大偏移量打爆。毕设项目索引几万条数据,一般碰不到这个上限;但如果你想在答辩时展示一下自己对深分页问题的理解,可以用search_after方式实现滚动翻页。核心思路是:记录当前页最后一条文档的排序值,下一页把这些排序值传给search_after,让ES直接跳到那个位置之后继续取数据。

search_after的查询示例:

{ "query": { "match_all": {} }, "sort": [ {"_id": "asc"} ], "size": 10, "search_after": ["abc123"] }

注意:使用search_after时,sort必须是确定的字段,否则翻页会乱。这个方案在小项目里不是必须的,但你能讲清楚它跟from + size的差异,技术上会比同龄人扎实一截。

5.4 简单统计搜索行为:用聚合或ESQL都能做

搜索过程中,统计“哪些词被搜得最多”“不同日期搜索量是多少”这类指标,是很有价值的扩展功能。ES做统计有两种思路:一种是传统的聚合查询,另一种是ES 8.11+引入的ESQL。

传统聚合大概这样:

{ "aggs": { "hot_keywords": { "terms": { "field": "query_keyword.keyword", "size": 10 } } } }

ESQL则更像SQL,直观易懂:

FROM search_logs | STATS count = COUNT(*) BY query_keyword | SORT count DESC | LIMIT 10

区别在于:聚合查询适用于7.x、8.x所有版本,ESQL只在8.11以上的版本可用。如果你选的ES 7.17,建议走传统聚合路线。实际使用时,可以在Django搜索服务里把每次搜索的q参数、搜索时间、返回条数记录到一个ES索引search_logs,再通过聚合接口出报表。这个功能放到答辩里,既展示了项目“迭代意识”,又能直接拿出数据说话。

6. Web界面与前后端交互

6.1 搜索页交互设计:简单但不能糙

前端方案我推荐最朴素的Django模板 + 原生fetch,不要去引一套重前端框架。理由很简单:搜索引擎页面本来就是搜索框加结果列表加分页,没必要把工程复杂度拉高。核心是交互要顺滑,比如按回车搜索、点击分页翻页、搜索过程中显示loading状态。

一个基础的搜索页面数据流是这样的:用户在表单输入关键词,提交到Django的搜索视图,视图调ES搜索服务拿到结果,渲染模板输出。如果用fetch做异步搜索,前端只需请求/search/api/?q=关键词&page=1,拿到JSON后动态渲染结果列表。

这里有一个很加分的体验细节:搜索建议。ES的Completion Suggester可以做输入前缀联想,用户输入“机器”时,下拉提示“机器学习”“机器视觉”。实现方式是在Mapping里增加一个suggest字段,类型是completion,写入数据时把标题或关键词塞进去。查询接口用suggest子句返回联想结果。这个小功能对搜索型产品的体验提升非常明显,代码量也不大,适合作为展示亮点。

6.2 高亮结果渲染与XSS防护:别直接用safe

搜索结果的高亮是全文搜索引擎的基本能力,ES返回的高亮字段带着<em>标签:

"highlight": { "title": ["Python <em>机器学习</em>实战"] }

如果直接把这段字符串塞进Django模板并用|safe渲染,那高亮效果有了,但XSS漏洞也埋下了:如果ES索引的文档内容里恰好有恶意JavaScript脚本,这些脚本会被浏览器直接执行。正确做法是,先对高亮内容做HTML转义,再只允许<em>标签通过:

import html from django.utils.html import format_html def safe_highlight(text): escaped = html.escape(text) # 把转义后的 &lt;em&gt; 还原成真正的 <em> escaped = escaped.replace("&lt;em&gt;", "<em>").replace("&lt;/em&gt;", "</em>") return format_html(escaped)

这样既保留了高亮样式,又避免了整段原生HTML被直接注入。这个细节在答辩时如果有人问“安全性怎么考虑的”,就是很好的回应。

7. 部署上线与高频问题速查

7.1 三个服务的启动顺序与联调

本地联调阶段,服务启动顺序建议是:先启动Elasticsearch,再跑Scrapy导入数据,最后启动Django。ES没起来就导数据,Pipeline会直接连接失败;数据没导入就开Django搜索页,搜索接口会返回空结果。

调试技巧:第一步先用curl "http://localhost:9200/articles/_search?q=关键词"确认ES里有没有数据、能不能搜到结果,再用Django的搜索API测。如果ES直接查不出来,问题在爬虫和分词;如果ES查得出来但Django接口查不出来,问题在Django的DSL组装逻辑。按这个顺序排查,能省下很多时间。

7.2 高频坑位与解决方案速查表

我在实际项目中遇到的坑,整理成一张表,按症状排布,遇到直接对号入座:

症状可能原因解决方法
ES启动闪退内存不足 / JDK版本冲突调整jvm.options内存;确认使用内置JDK
ES启动报vm.max_map_count过低Linux系统参数sysctl -w vm.max_map_count=262144
IK分词器不生效IK版本与ES版本不一致严格下载相同版本的IK插件
搜索报Result window is too largefrom+size超过默认限额改用search_after或调大index.max_result_window
索引写入报mapper [xxx] of different type同名字段类型冲突清理索引,检查写入字段类型
爬虫抓取文本为空JS渲染或iframe内容对相关页面启用Playwright
搜索接口返回500Django访问ES失败检查ES是否启动、elasticsearch-py版本是否匹配
搜索结果没有高亮查询没配置highlight在DSL里添加highlight字段

这个表我建议直接保存下来,毕设调试期间你会反复用到。还有一个很重要的实操经验:每次修改ES的Mapping结构或重新导数据,最干脆的做法是删除索引重建,而不是在原索引上反复改字段:

curl -X DELETE "http://localhost:9200/articles"

重新创建并导数据比解决字段冲突快得多。

7.3 部署到Linux服务器的简单方案

如果你要把项目部署到服务器上演示,我推荐最省心的组合:ES用systemd管理,Scrapy用crontab定时跑,Django用gunicorn跑,前面再挂一个nginx做反向代理和静态文件服务。

ES的systemd服务文件比较简单,核心就三块:指定启动用户、指定ES路径、启动后检查9200端口。Django用gunicorn启动:

gunicorn search_site.wsgi:application -b 127.0.0.1:8000 --workers=3

nginx配置一个server块,监听80端口,/反代到8000端口,/static/指向Django静态资源目录。前端所有请求都走80端口,对外暴露的就是一个普通的搜索网站,但底层是完整的三层架构。部署的时候记得关掉ES的HTTP跨域访问限制,或者只允许本机访问;如果你要用浏览器插件看ES索引数据,还需要在elasticsearch.yml里设置:

http.cors.enabled: true http.cors.allow-origin: "*"

注意这仅用于开发调试,生产环境不建议开放。

8. 毕业设计答辩可以重点讲的内容

8.1 技术亮点怎么包装

答辩的时候,最忌讳把项目讲成“我搭了一个搜索框”。这个项目的技术亮点可以从三个层面去讲。

第一个层面是完整链路。从爬虫采集、数据清洗、中文分词、倒排索引、搜索排序到Web展示,所有环节都是自己打通并优化的,这是一个真实系统的完整闭环,而不是教材里的一个孤立概念。

第二个层面是细节优化。比如title字段权重设置、索引用ik_max_word而搜索用ik_smart、filter和must的使用差异、高亮XSS防护,这些都是工程经验沉淀,属于“你不踩坑就不会知道”的东西。答辩老师问到任何细节,你都能给出设计依据,这就是最好的加分项。

第三个层面是可扩展性。Scrapy-Redis支持分布式爬虫,ES天然支持集群扩容,搜索日志可以做热榜推荐,这些都可以作为未来的演进方向。讲出来,会显得你有整体架构意识。

8.2 如果后续有时间,我会往这些方向扩展

如果做完整套毕设之后还有余力,我个人建议可以挑一个方向做深。比如,在搜索接口里加入一个基于搜索日志的“热搜词推荐”功能,用ES的聚合统计最近7天搜索最多的词;再比如,给文章索引增加一个completion字段,实现搜索框里的实时联想。这两个功能都能直接提升项目演示的“可玩性”,代码量不大,但对答辩印象分的提升非常明显。

还有一个成本不高但很有价值的方向:把Django后台管理页面接进来,用Django Admin直接管理爬取到的数据,可以手动删除质量差的文档,观察它对搜索结果的影响。这在演示场景里非常直观,能让评委看到你的系统是可控的、可维护的。

9. 最后分享两个压箱底的经验

如果你按这篇文章的节奏从头搭这个项目,我最后想强调两点,都是我自己实操中踩坑总结出来的。

第一,版本锁死、环境一次到位。Python 3.10、Django 4.2、Scrapy 2.11、ES 7.17、IK 7.17、elasticsearch-py 7.17,这套组合千万别混装。很多项目失败不是逻辑写不出来,而是环境装了好几天都没起来,最后心态崩了。环境搭好后,建议用pip freeze > requirements.txt把依赖锁住,换机器部署时直接pip install -r requirements.txt,省掉无数抓狂时刻。

第二,数据先跑通,功能再迭代。很多同学喜欢一上来就把所有功能都设计好再动手写代码,结果项目周期拖得很长。更务实的路径是:先搭一个最简版本——Scrapy抓10条数据,ES建好索引,Django出一个能搜出结果的页面。把这个闭环跑通,你已经完成了毕设的60%。剩下的时间用来优化分词、调整排序、加高亮、加建议,每一步都是肉眼可见的进步。

这个项目整体工作量适中,但技术密度不低,做下来基本可以把Python工程化能力、爬虫、搜索引擎原理、Web开发都过一遍。动手的时候如果卡在某个环节,回来看这篇文章对应的小节,大概率能找到答案。

希望这篇内容能帮你把这个毕设吃得透透的。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询