尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用Python从零实现垂直新闻搜索引擎:分词、倒排索引与BM25排序

用Python从零实现垂直新闻搜索引擎:分词、倒排索引与BM25排序 简介一套基于Python的新闻搜索引擎完整项目实例面向具备Python与Web开发基础、对信息检索和自然语言处理感兴趣的研发人员、数据工程师及计算机专业学生。围绕新闻抓取与反爬应对、中文分词、倒排索引、TF-IDF/BM25相关性排序等核心问题给出从数据采集、文本预处理、索引构建、检索排序到展示交互的五层架构方案并按抓取解析、分词清洗、索引构建、检索排序、查询接口与结果摘要等模块提供代码详解。系统支持Web与桌面GUI两种前端可应用于舆情监测、金融投资、媒体辅助及政策研究等垂直场景。资源为1个docx文档压缩包约118KB文档内含完整项目背景、目标与挑战、系统架构图说明、各模块核心代码、数据库设计及应用领域分析目录层次清晰便于对照练习与二次扩展。已有65人学习对于想要把自然语言处理落地为可解释搜索系统的开发者来说是一份高性价比的工程参考。1. 新闻搜索引擎立项垂直领域检索到底在解决什么问题“基于Python的新闻搜索引擎”听起来像课程作业真正做完你会发现决定系统质量的是分词词典、停用词表和排序公式怎么配而不是代码本身有多花哨。这个项目的落脚点是把自然语言处理里的分词、倒排索引、BM25排序和数据库存储串成一条能跑的检索管线面向垂直领域行业新闻、开源动态、技术资讯做精确信息召回而不是再造一个面向全网的门户搜索。它适合正在做NLP课设或毕设的学生、想给站内新闻库加搜索但不想直接上Elasticsearch的工程师以及想亲手还原一遍搜索引擎内部机制的入门者。下面的实现路径覆盖选型、语料、索引、GUI、排错与评估每步都给出可以直接改的参数。2. 选型先行分词、检索模型与数据库的取舍逻辑2.1 垂直领域改变了哪些设计前提垂直搜索不是通用搜索的缩小版。通用门户搜索靠的是海量网页、超链分析和点击反馈而垂直搜索的数据量往往只有几千到几十万篇用户输入也集中在几个固定主题上。这意味着两件事一是复杂的分布式索引在这里是过度设计二是通用词表和通用排序策略不能直接搬过来用。做垂直新闻检索时我一般会把需求拆成三个前提来论证分别是语料规模、领域词密集度和时间敏感性。语料规模决定要不要引入Elasticsearch几千到十万篇的量级用SQLite完全可以撑住领域词密集度决定jieba默认词典需要补多少词比如新闻里高频出现的公司名、产品名、会议名时间敏感性决定排序公式里要不要给近期新闻额外加分。把这三个前提想清楚选型时就不会摇摆。这里有个常见误用一上来就按通用搜索的标准设计标签体系、URL去重策略和分层索引结果做了两周还在搭架子。垂直领域最值钱的动作是先跑通一条最小管线再用评估数据决定哪里值得投入。搜索引擎的复杂度应该跟着数据规模和真实查询日志增长而不是跟着想象增长。2.2 检索模型选型TF-IDF、BM25、向量检索的适用边界中文新闻检索在中小语料上的排序质量BM25是性价比最高的选择。它和TF-IDF的差别在于BM25对词频做了饱和处理一个词在一篇文档里出现50次和出现200次对分数的贡献差距不会线性放大同时它引入文档长度归一化长文不会因为词多就天然占便宜。这两个特性对新闻场景非常重要因为新闻正文长度差异极大短讯可能只有几百字深度报道可能上万字。检索方式核心机制中文适配难度排序质量工程成本适用场景TF-IDF词频×逆文档频率依赖分词质量一般长文易占优低入门教学、原型验证BM25词频饱和文档长度归一化依赖分词质量需调k1和b较好长短文均衡低中小语料首选向量检索句子向量余弦相似度需要领域微调和语料训练语义召回更强但噪声更多中高大语料、语义搜索场景在垂直新闻项目里向量检索不是不能用而是收益来得太晚。做句向量需要额外的模型部署和领域微调数据对几万篇新闻的业务来说BM25召回不到的语义问题通常靠同义词表就能解决一大半。把同义词表和领域词典维护好花半天时间效果往往比训一个向量模型更可控。BM25有两个参数直接影响排序结果。k1控制词频饱和速度默认1.2到2.0新闻场景建议1.5b控制文档长度归一化的强度默认0.75如果正文字数差异悬殊可以往上调到0.85。这两个参数在后面的代码里都能直接改属于搜索引擎里少数值得反复实验的旋钮。2.3 数据库与字段设计为搜索服务的新闻库长什么样数据库不是用来存新闻正文就完事了它同时要承担倒排索引、文档统计和搜索日志三类职责。我习惯把它们拆成五张表这样一个文件就能承载整套搜索引擎的存储层路径上放一个schema.sql直接执行。CREATE TABLE IF NOT EXISTS news ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content TEXT, source TEXT, category TEXT, url TEXT UNIQUE, publish_time TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS term_stats ( term TEXT PRIMARY KEY, df INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS inverted_index ( term TEXT NOT NULL, doc_id INTEGER NOT NULL, tf REAL NOT NULL, PRIMARY KEY (term, doc_id) ); CREATE TABLE IF NOT EXISTS doc_stats ( doc_id INTEGER PRIMARY KEY, doc_len INTEGER NOT NULL, title_len INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS search_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, query TEXT NOT NULL, result_count INTEGER, top_doc_id INTEGER, clicked_doc_id INTEGER, ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP );这五张表各干一件事。news表存原始新闻url字段加唯一约束用于爬虫落库时去重term_stats表记录每个词的文档频率df是计算IDF的必需统计量inverted_index表就是倒排索引本体(term, doc_id)作联合主键避免同一词在同一篇文档里重复记录doc_stats表存每篇文档的总词数和标题词数BM25的长度归一化靠它search_log表记录每一次查询行为后面做检索质量评估时全靠这份日志。如果课程或项目里明确要求用MySQL表结构不需要改把SQLite连接换成pymysql连接注意批量插入时SQL语句里的问号占位符改成百分号占位符就可以。SQLite在单机、单进程的检索场景下性能完全够还能把整个数据库文件直接拷贝给别人演示这是它在这个项目里最大的优势。3. 从语料到搜索框新闻搜索引擎的最小可运行管线3.1 语料准备RSS抓取与HTML抓取的最小实现新闻语料最常见的获取方式是抓RSS源因为RSS自带标题、链接和发布时间格式结构化程度高如果目标站点没有RSS再退一步去抓HTML页面。抓取时注意只在允许的范围内采集遵守站点robots声明不把抓取频率设置得很激进。下面的代码同时覆盖这两种情况。import time import random import xml.etree.ElementTree as ET import requests from bs4 import BeautifulSoup HEADERS {User-Agent: Mozilla/5.0 (news-search-demo)} def fetch_rss(rss_url): resp requests.get(rss_url, timeout10, headersHEADERS) resp.encoding resp.apparent_encoding root ET.fromstring(resp.text) for item in root.iter(item): yield { title: item.findtext(title), url: item.findtext(link), publish_time: item.findtext(pubDate), content: item.findtext(description), source: rss_url.split(/)[2], } def fetch_html_page(url): resp requests.get(url, timeout10, headersHEADERS) resp.encoding resp.apparent_encoding soup BeautifulSoup(resp.text, html.parser) title soup.title.get_text(stripTrue) if soup.title else # 取正文区域优先article标签退而求其次取整页文本 article soup.find(article) content article.get_text(\n, stripTrue) if article else soup.get_text(\n, stripTrue) return {title: title, url: url, content: content} def save_news(conn, news_list): sql INSERT OR IGNORE INTO news (title, content, source, category, url, publish_time) VALUES (?, ?, ?, ?, ?, ?) conn.executemany(sql, news_list) conn.commit()fetch_rss用ElementTree解析XML遍历所有item节点取值description字段就是正文摘要。fetch_html_page用BeautifulSoup优先找article标签找不到就退化成整页文本这个退化逻辑在门户新闻页里很常见。落库用INSERT OR IGNORE配合url唯一约束实现天然的去重同一个RSS链接重复抓取时不会产生脏数据。抓取频率控制是另一个不能省的事。常见做法是在循环里加一个随机停顿比如time.sleep(random.uniform(0.5, 1.5))既避免给目标站点造成压力也符合基本的抓取礼仪。我见过不少新手在演示时把停顿删掉结果把对方站点打挂最后被屏蔽。新闻搜索项目的重点是检索不是爬虫语料能稳定增量更新就行。3.2 文本预处理jieba分词、领域词典与停用词表中文检索和英文检索最大的区别就是分词。英文按空格切分就是词中文必须依赖词典和算法。垂直领域的分词问题在于通用词典里没有行业词。比如新闻里常见的“量子计算”“开源鸿蒙”“车路协同”用默认词典一口气分不出来。处理办法是给jieba挂一份领域词典格式是每行“词语 词频 词性”词频可以不写让默认逻辑去猜。import jieba import re from collections import Counter STOPWORDS set() with open(stopwords.txt, r, encodingutf-8) as f: for line in f: word line.strip() if word: STOPWORDS.add(word) jieba.load_userdict(domain_dict.txt) def tokenize(text): text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9], , text.lower()) words jieba.lcut(text) return [w for w in words if w.strip() and w not in STOPWORDS and len(w) 1]tokenize做的事情分三块正则把标点、特殊符号替换成空格英文统一转小写jieba.lcut做中文分词过滤停用词和单字。单字过滤对新闻检索很重要中文单字词大量是“的、了、在、是”这类虚词检索时几乎不携带信息量。英文转小写保证了“Python”和“python”能命中同一个词项。停用词表我一般用公开的哈工大停用词表做底子再自己补新闻场景里出现的高频无义词比如“记者”“报道”“责任编辑”“点击查看”。domain_dict.txt是垂直领域定制最关键的文件把新闻主题相关的公司名、产品名、人名加进去比如“量化交易”“昇腾”“通义千问”每行一个词格式保持“自定义词 词频 词性”即可。分词质量直接决定倒排索引的质量这一步值得反复调试。3.3 倒排索引与统计量从分词结果到可检索结构倒排索引是搜索引擎的核心数据结构本质是“词到文档列表”的映射。构建它需要遍历全部新闻对每篇文档做分词然后统计每个词出现在哪些文档、在每篇文档里出现多少次。同时要记录文档的总词数因为BM25打分需要文档长度做归一化。下面是完整的索引构建函数。import sqlite3 from collections import Counter, defaultdict def build_index(conn, title_weight2.0, content_weight1.0): conn.execute(DELETE FROM term_stats) conn.execute(DELETE FROM inverted_index) conn.execute(DELETE FROM doc_stats) rows conn.execute(SELECT id, title, content FROM news ORDER BY id).fetchall() postings defaultdict(Counter) doc_stats {} for doc_id, title, content in rows: title_tokens tokenize(title or ) content_tokens tokenize(content or ) total_len len(title_tokens) len(content_tokens) doc_stats[doc_id] (len(title_tokens), total_len) # 标题里的词额外加权新闻检索里标题命中远比正文命中重要 for w, c in Counter(title_tokens).items(): postings[w][doc_id] title_weight * c for w, c in Counter(content_tokens).items(): postings[w][doc_id] content_weight * c with conn: for term, post in postings.items(): conn.execute(INSERT OR REPLACE INTO term_stats VALUES (?, ?), (term, len(post))) for doc_id, tf in post.items(): conn.execute( INSERT OR REPLACE INTO inverted_index VALUES (?, ?, ?), (term, doc_id, tf) ) for doc_id, (title_len, doc_len) in doc_stats.items(): conn.execute(INSERT OR REPLACE INTO doc_stats VALUES (?, ?, ?), (doc_id, doc_len, title_len))构建逻辑分两段。第一段遍历新闻表对每篇文档统计标题词和正文字的加权词频标题命中一个词记2.0分正文命中记1.0分这个比例在新闻检索里很实用因为用户搜索时更关心标题包含关键词的新闻。第二段把倒排表和统计量写回SQLite整个写入放在一个事务里避免中途出错留下半成品索引。注意如果新闻源是持续更新的不要每次全量重建索引。比较省事的做法是维护一个last_indexed字段只对新增记录跑单文档索引更新相当于自己实现一个轻量的数据库同步工具。SQLite对这类小规模增量写入支持得很好。title_weight和content_weight是两个值得单独抽出来的参数。想强化标题权重把title_weight调到3.0搜索结果会更偏向标题命中如果你的场景更看重正文相关度就调成1.0对1.5。垂直新闻检索里标题权重高一点几乎总是对的因为新闻标题本身就是编辑提炼过的核心信息。3.4 BM25检索实现打分、排序与分类过滤有了倒排索引检索其实就两步把查询词分出来逐个查倒排表然后把命中的文档按BM25公式打分排序。下面这个BM25Searcher类包含全部检索逻辑可以直接被后面的GUI和Web接口复用。import math import sqlite3 class BM25Searcher: def __init__(self, db_pathnews.db, k11.5, b0.75): self.conn sqlite3.connect(db_path) self.k1 k1 self.b b self.N self.conn.execute(SELECT COUNT(*) FROM news).fetchone()[0] row self.conn.execute(SELECT AVG(doc_len) FROM doc_stats).fetchone() self.avgdl row[0] or 0.0 self.doc_lens dict(self.conn.execute( SELECT doc_id, doc_len FROM doc_stats).fetchall()) def search(self, query, top_k10, categoryNone): terms set(tokenize(query)) if not terms: return [] scores {} for term in terms: rows self.conn.execute( SELECT ii.doc_id, ii.tf, ts.df FROM inverted_index ii JOIN term_stats ts ON ii.term ts.term WHERE ii.term ? , (term,)).fetchall() for doc_id, tf, df in rows: idf math.log((self.N - df 0.5) / (df 0.5) 1.0) doc_len self.doc_lens.get(doc_id, self.avgdl) denom tf self.k1 * (1 - self.b self.b * doc_len / self.avgdl) score idf * tf * (self.k1 1.0) / denom scores[doc_id] scores.get(doc_id, 0.0) score ranked sorted(((s, d) for d, s in scores.items()), reverseTrue) if category and category ! 全部: valid {r[0] for r in self.conn.execute( SELECT id FROM news WHERE category ?, (category,))} ranked [(s, d) for s, d in ranked if d in valid] return ranked[:top_k] def get_doc_meta(self, doc_id): row self.conn.execute( SELECT title, source, publish_time, content FROM news WHERE id ?, (doc_id,)).fetchone() if not row: return {title: , source: , publish_time: , content: } return {title: row[0], source: row[1], publish_time: row[2], content: row[3] or }search方法的流程是先对查询做和文档一致的tokenize处理用set去掉重复词项避免同一个词贡献两次分数然后逐个词查倒排表JOIN term_stats拿到文档频率df计算IDF和词频饱和分数最后把所有词的分数累加。category参数放在排序后过滤虽然效率不是最优但逻辑最清晰几万条语料里多扫一遍完全无感。k1和b的真实影响在这个类里能直接看到。k1越大词频对分数的贡献越接近线性小词频文档吃亏k1越小词频超过一定程度后分数增长越慢。b越大文档长度惩罚越强长文越吃亏。新闻场景我建议k11.5、b0.75起步如果发现搜索结果全是长文把b往0.85调。3.5 PyQt5搜索界面把检索函数接到搜索框上GUI层面不需要花哨设计一个输入框、一个分类下拉框、一个搜索按钮、一个结果表格就够了。PyQt5做这类桌面检索界面非常直接QLineEdit接收查询词QTableWidget展示结果回车触发搜索整个界面只需要一个类。import sys import sqlite3 import textwrap from PyQt5.QtWidgets import ( QApplication, QMainWindow, QWidget, QLineEdit, QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QComboBox, QLabel ) def make_summary(content, length120): if not content: return return textwrap.shorten(content, widthlength, placeholder...) def get_categories(): conn sqlite3.connect(news.db) rows conn.execute( SELECT DISTINCT category FROM news WHERE category ! ).fetchall() conn.close() return [r[0] for r in rows] class SearchWindow(QMainWindow): def __init__(self, searcher): super().__init__() self.searcher searcher self.setWindowTitle(垂直领域新闻搜索引擎 - 检索界面) self.resize(1000, 640) self._build_ui() def _build_ui(self): central QWidget() root QVBoxLayout() row QHBoxLayout() self.edit QLineEdit() self.edit.setPlaceholderText(输入关键词例如数据库 发布 大会) self.category QComboBox() self.category.addItems([全部] get_categories()) self.btn QPushButton(搜索) self.btn.clicked.connect(self.do_search) self.edit.returnPressed.connect(self.do_search) row.addWidget(QLabel(关键词)) row.addWidget(self.edit, 4) row.addWidget(QLabel(分类)) row.addWidget(self.category, 1) row.addWidget(self.btn) self.table QTableWidget(0, 4) self.table.setHorizontalHeaderLabels([标题, 来源, 发布时间, 摘要]) self.table.setColumnWidth(0, 260) self.table.setColumnWidth(3, 460) root.addLayout(row) root.addWidget(self.table) central.setLayout(root) self.setCentralWidget(central) def do_search(self): q self.edit.text().strip() if not q: return cat self.category.currentText() results self.searcher.search(q, top_k30, categorycat) self.table.setRowCount(len(results)) for r, (score, doc_id) in enumerate(results): meta self.searcher.get_doc_meta(doc_id) self.table.setItem(r, 0, QTableWidgetItem(meta[title])) self.table.setItem(r, 1, QTableWidgetItem(meta[source] or -)) self.table.setItem(r, 2, QTableWidgetItem(meta[publish_time] or -)) self.table.setItem(r, 3, QTableWidgetItem(make_summary(meta[content])))把查询事件绑定到returnPressed和按钮点击两个入口用户在输入框里按回车就能触发检索这是桌面搜索工具的基本交互习惯。结果表格固定四列标题列和摘要列预留更宽的宽度来源和发布时间放窄列。make_summary用textwrap.shorten把长正文截成120字符的摘要避免表格行被整篇正文撑爆。运行时入口很简单先构建索引再弹窗口searcher BM25Searcher(news.db, k11.5, b0.75) app QApplication(sys.argv) win SearchWindow(searcher) win.show() sys.exit(app.exec_())这里BM25Searcher在应用启动时初始化一次把doc_lens和avgdl加载进内存后续每次查询都不需要重新读全表统计量。如果爬虫在程序运行期间往news.db里写了新数据需要重启窗口让searcher重新加载或者给searcher加一个reload方法。对于课程设计和演示场景重启足够。4. 新闻搜索引擎的五处典型翻车现场复现、原因与修复4.1 搜索结果乱码编码探测与入库前的统一处理现象抓下来的新闻标题在控制台打印正常写入SQLite后变成“锘挎嫧”之类的乱码检索结果在GUI里也没法读。原因requests库的resp.text默认用HTTP头声明的编码解码很多新闻站点的HTTP头根本没写对编码或者声明的是utf-8实际是GBK。直接拿resp.text进数据库编码从源头就错了。解决统一在抓取时设置resp.encoding resp.apparent_encoding让requests根据页面内容自动探测编码。入库前再打印几条样本确认正常。另一个不起眼的坑是SQLite连接时指定text_factorystr避免某些情况下返回bytes类型。这个配置我一般放在连接初始化里一劳永逸。4.2 “database is locked”SQLite并发写冲突现象爬虫用多线程抓新闻落库时频繁抛sqlite3.OperationalError: database is locked程序跑几分钟就中断。原因SQLite同一时刻只允许一个写事务。多个线程同时执行INSERT后面的写请求直接报错而不是排队等待这是SQLite默认配置决定的。解决两个配合手段。第一连接时执行PRAGMA journal_modeWALWAL模式让读写可以并发写写依然互斥第二执行PRAGMA busy_timeout5000写锁被占用时等待5秒而不是立即失败。如果爬虫线程数很多更稳妥的做法是把所有写操作丢进同一个单线程队列爬虫只管抓、不管写。4.3 PyQt5启动即闪退Qt平台插件缺失的玄学现象GUI程序启动瞬间闪退命令行报could not find or load the Qt platform plugin windows代码本身没有任何语法错误。原因PyQt5的Qt运行时找不到platforms插件目录。这种情况在Python 3.9以上搭配某些PyQt5版本时特别常见C扩展和解释器位数不匹配也会触发同类问题属于环境问题里最典型的玄学。解决先确认Python版本和PyQt5版本。Python 3.10以上建议pip install --upgrade PyQt5到5.15以上版本然后检查site-packages/PyQt5/Qt/plugins/platforms目录是否存在。如果目录存在还是报错手动设置环境变量QT_QPA_PLATFORM_PLUGIN_PATH指向该目录。学会看这个报错信息比重装三次Python有用得多。4.4 零结果与词义混淆领域词典和同义词表的补法现象搜索“大模型 推理”返回零结果但语料里明明有十几篇相关新闻搜索“苹果”返回的全是水果新闻。原因零结果通常是查询词被切碎了比如“大模型”被分成“大”和“模型”单字又被停用词过滤掉倒排表里根本查不到完整词。词义混淆则是垂直领域里典型的语义问题检索系统不做消歧只能靠人工维护关系。解决第一步打印分词结果确认问题jieba.lcut(大模型 推理)看到什么就是什么。第二步把“大模型”加进domain_dict.txt。第三步维护同义词表检索时把同义词展开成OR查询比如{大模型: [大模型, LLM, 基础模型]}拦截零结果的效果立竿见影。这一步是垂直搜索里最花时间也最值钱的工作。4.5 排序把长文全部顶到前面BM25里的b参数现象搜索结果里深度报道永远排在前面几百字的短讯全部沉底用户翻半天找不到想要的快讯。原因文档长度归一化强度不够。新闻正文有长有短深度报道词数可能是短讯的十倍倒排索引里它命中的词项更多BM25原始得分天然偏高。解决调大b参数。b的默认值是0.75对新闻语料可以试到0.85甚至0.9。改完后长文命中的优势会被明显压制。如果调完还是长文占优就把标题权重从2.0提到3.0让标题命中的短新闻有机会靠前。这两个参数配合着调比在代码里写一堆规则更靠谱。5. 检索质量验证用标注集和查询日志给系统打分5.1 建一份查询标注集50条查询怎么标才可信搜索引擎做完怎么证明它效果好最常见的做法是手工建一份查询标注集。从search_log表里挑50条真实查询覆盖三类来源高频查询、零结果查询、业务方指定的关键查询。对每条查询把检索返回的前30条结果人工标为相关或不相关。标注时看标题和摘要就能判断不用打开全文。规模控制在50条查询乘以30条结果一个人一两小时能标完。关键点是标注不能只做一遍最好两个人各标一遍算一下标注一致率差异大的查询拿出来讨论统一口径。这一步容易被人跳过但它是后面所有指标计算的地基标注集不靠谱指标数字再漂亮也是自欺欺人。5.2 指标脚本Pk、Recall、MRR的朴素实现有了标注集评估就变成一段几十行的脚本。最常用的是Precisionk、Recall和MRR平均倒数排名。Pk看前k条结果里相关文档的比例MRR看第一条相关结果排到第几位这些指标对搜索引擎的日常体检足够了。def precision_at_k(ranked, relevant, k10): hit sum(1 for i, (s, d) in enumerate(ranked[:k], 1) if d in relevant) return hit / k def recall(ranked, relevant): hit sum(1 for s, d in ranked if d in relevant) return hit / len(relevant) if relevant else 0.0 def mrr(ranked, relevant): for rank, (s, d) in enumerate(ranked, start1): if d in relevant: return 1.0 / rank return 0.0这三个指标配合使用的逻辑是P10衡量用户第一屏看到的东西靠不靠谱Recall衡量相关文档有没有都被捞回来漏召回严重的系统P10再高也不能用MRR衡量“用户是否很快看到想要的结果”。跑完指标后我一般把每条查询的P1单独列出来P1低但P10高的说明相关文档存在但排序位置太靠后优先去调b参数和标题权重这类问题通常是排序问题不是召回问题。NDCG需要标注分级的relevance不相关、部分相关、强相关50条查询的教学习惯先看Pk和MRR就够。指标脚本固定成文件后每次调完词表或改完参数全量跑一遍半个月下来哪些改动有用一目了然。5.3 查询日志分析零结果和高频词里的词典缺口查询日志是比标注集更廉价的反馈来源。搜索引擎上线跑几天search_log里就积累了用户的真实输入。直接对零结果查询做聚合SQL一条命令就能找出词典缺口。SELECT query, COUNT(*) AS cnt FROM search_log WHERE result_count 0 GROUP BY query ORDER BY cnt DESC LIMIT 20;跑出来的结果通常会暴露两类问题高频词没进领域词典或者查询里带了无意义的符号。对应动作是补词和清洗。再跑一条高频查询聚合看用户总在搜什么这些词即使目前有结果也应该检查倒排索引里有没有覆盖到足够多的相关文档。查询日志分析应该变成固定动作每周跑一次把新增的领域词同步进domain_dict.txt和同义词表这比改任何排序公式都更能提升用户体感。6. 从桌面程序到服务接口FastAPI封装与近因加权桌面GUI适合演示和调试真要给同事或业务方用还是得把检索封装成HTTP服务。FastAPI做这件事很省事核心是把已经写好的BM25Searcher直接扔进去一行检索逻辑都不用改。from fastapi import FastAPI from datetime import datetime, timedelta searcher BM25Searcher(news.db) app FastAPI() app.get(/news/search) def news_search(q: str, category: str 全部, top_k: int 10): results searcher.search(q, top_ktop_k * 2, categorycategory) # 近因加权一周内的新闻加0.15分新闻场景里时效性值得给 week_ago datetime.now() - timedelta(days7) weighted [] for score, doc_id in results: ts searcher.get_publish_time(doc_id) bonus 0.15 if ts and ts week_ago else 0.0 weighted.append((score bonus, doc_id)) weighted.sort(reverseTrue) return { query: q, hits: [searcher.get_doc_meta(d) for s, d in weighted[:top_k]], }查询时先按BM25取2倍于top_k的候选集再对候选做近因加权。加分直接叠加在BM25分数上BM25分数通常落在0到10区间0.15是一个温和的时效性偏置想突出“最新”就把数值调到0.3这一类的业务规则建议集中放配置。publish_time解析失败时返回None降级成不加分不影响整体排序。get_publish_time可以放在BM25Searcher里用datetime.strptime解析常见的几种时间格式比如%Y-%m-%d %H:%M:%S和RSS里常见的RFC 822格式解析失败就返回None。FastAPI接这套接口时注意SQLite连接不能在多线程间共享常见做法是每次请求新建连接配WAL模式后单机支撑几十上百QPS没有压力。我做这类检索系统的习惯是先建标注集再写代码最后上线查日志。标注集让每次改动都有反馈日志让词典持续生长这两件事做到位搜索引擎的效果不会差到哪里去。希望帮到你。本文还有配套的精品资源点击获取
返回列表