fix 分词bug,添加重爬机制
This commit is contained in:
+28
-8
@@ -44,18 +44,22 @@ type CrawlerConfig struct {
|
|||||||
ExpectedProsperRatio float64 `yaml:"expected_prosper_ratio"`
|
ExpectedProsperRatio float64 `yaml:"expected_prosper_ratio"`
|
||||||
EntryURL string `yaml:"entry_url"`
|
EntryURL string `yaml:"entry_url"`
|
||||||
MaxPageSize int `yaml:"max_page_size"` // 单个页面最大抓取字节数(0=不限,默认 5MB)
|
MaxPageSize int `yaml:"max_page_size"` // 单个页面最大抓取字节数(0=不限,默认 5MB)
|
||||||
|
RecrawlMaxAge int `yaml:"recrawl_max_age"` // URL 过期时间(秒),超过此时间的 URL 允许被重爬,默认 30 天
|
||||||
|
RecrawlCheckInterval int `yaml:"recrawl_check_interval"` // 运行期间检查过期 URL 的间隔(秒),默认 1 小时
|
||||||
|
RecrawlBatchSize int `yaml:"recrawl_batch_size"` // 每次检查最多释放多少个过期 URL,默认 500
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchConfig 搜索结果排序权重配置
|
// SearchConfig 搜索结果排序权重配置
|
||||||
type SearchConfig struct {
|
type SearchConfig struct {
|
||||||
UseOnlineSnippet bool `yaml:"use_online_snippet"`
|
UseOnlineSnippet bool `yaml:"use_online_snippet"`
|
||||||
OnlineSnippetTimeout int `yaml:"online_snippet_timeout"`
|
OnlineSnippetTimeout int `yaml:"online_snippet_timeout"`
|
||||||
WeightDailyDecay float64 `yaml:"weight_daily_decay"`
|
WeightDailyDecay float64 `yaml:"weight_daily_decay"`
|
||||||
LanguageWeight float64 `yaml:"language_weight"`
|
LanguageWeight float64 `yaml:"language_weight"`
|
||||||
ConsecutiveKeyWeight float64 `yaml:"consecutive_key_weight"`
|
ConsecutiveKeyWeight float64 `yaml:"consecutive_key_weight"`
|
||||||
BacklinkWeight float64 `yaml:"backlink_weight"`
|
BacklinkWeight float64 `yaml:"backlink_weight"`
|
||||||
ServerPort int `yaml:"server_port"`
|
ServerPort int `yaml:"server_port"`
|
||||||
FlushIntervalSeconds int `yaml:"flush_interval_seconds"`
|
FlushIntervalSeconds int `yaml:"flush_interval_seconds"`
|
||||||
|
MissPenalty float64 `yaml:"miss_penalty"` // 缺词惩罚系数(0=不惩罚,1=完全忽略缺词URL),默认 0.15
|
||||||
}
|
}
|
||||||
|
|
||||||
// BacklinkConfig 反向链接计算相关配置
|
// BacklinkConfig 反向链接计算相关配置
|
||||||
@@ -120,6 +124,9 @@ func GetDefaultConfig() Config {
|
|||||||
ExpectedProsperRatio: 0.6,
|
ExpectedProsperRatio: 0.6,
|
||||||
EntryURL: "https://zh.wikipedia.org/",
|
EntryURL: "https://zh.wikipedia.org/",
|
||||||
MaxPageSize: 5 * 1024 * 1024,
|
MaxPageSize: 5 * 1024 * 1024,
|
||||||
|
RecrawlMaxAge: 30 * 86400, // 30 天
|
||||||
|
RecrawlCheckInterval: 3600, // 1 小时
|
||||||
|
RecrawlBatchSize: 500,
|
||||||
},
|
},
|
||||||
Search: SearchConfig{
|
Search: SearchConfig{
|
||||||
UseOnlineSnippet: true,
|
UseOnlineSnippet: true,
|
||||||
@@ -130,6 +137,7 @@ func GetDefaultConfig() Config {
|
|||||||
BacklinkWeight: 1.0,
|
BacklinkWeight: 1.0,
|
||||||
ServerPort: 50082,
|
ServerPort: 50082,
|
||||||
FlushIntervalSeconds: 300,
|
FlushIntervalSeconds: 300,
|
||||||
|
MissPenalty: 0.15,
|
||||||
},
|
},
|
||||||
Backlink: BacklinkConfig{
|
Backlink: BacklinkConfig{
|
||||||
Baseline: 200000,
|
Baseline: 200000,
|
||||||
@@ -208,6 +216,15 @@ func EntryURL() string { return Global.Crawler.EntryURL }
|
|||||||
// MaxPageSize 返回单个页面最大抓取字节数(0=不限)。
|
// MaxPageSize 返回单个页面最大抓取字节数(0=不限)。
|
||||||
func MaxPageSize() int { return Global.Crawler.MaxPageSize }
|
func MaxPageSize() int { return Global.Crawler.MaxPageSize }
|
||||||
|
|
||||||
|
// RecrawlMaxAge 返回 URL 过期时间(秒),超过此时间的 URL 允许被重爬。
|
||||||
|
func RecrawlMaxAge() int { return Global.Crawler.RecrawlMaxAge }
|
||||||
|
|
||||||
|
// RecrawlCheckInterval 返回运行期间检查过期 URL 的间隔(秒)。
|
||||||
|
func RecrawlCheckInterval() int { return Global.Crawler.RecrawlCheckInterval }
|
||||||
|
|
||||||
|
// RecrawlBatchSize 返回每次检查最多释放的过期 URL 数量。
|
||||||
|
func RecrawlBatchSize() int { return Global.Crawler.RecrawlBatchSize }
|
||||||
|
|
||||||
// UseOnlineSnippet 返回配置值
|
// UseOnlineSnippet 返回配置值
|
||||||
func UseOnlineSnippet() bool { return Global.Search.UseOnlineSnippet }
|
func UseOnlineSnippet() bool { return Global.Search.UseOnlineSnippet }
|
||||||
|
|
||||||
@@ -232,6 +249,9 @@ func SearchServerPort() int { return Global.Search.ServerPort }
|
|||||||
// FlushIntervalSeconds 返回配置值
|
// FlushIntervalSeconds 返回配置值
|
||||||
func FlushIntervalSeconds() int { return Global.Search.FlushIntervalSeconds }
|
func FlushIntervalSeconds() int { return Global.Search.FlushIntervalSeconds }
|
||||||
|
|
||||||
|
// MissPenalty 返回缺词惩罚系数(0~1),值越大对缺少查询词的 URL 惩罚越重。
|
||||||
|
func MissPenalty() float64 { return Global.Search.MissPenalty }
|
||||||
|
|
||||||
// BacklinkBaseline 返回配置值
|
// BacklinkBaseline 返回配置值
|
||||||
func BacklinkBaseline() int { return Global.Backlink.Baseline }
|
func BacklinkBaseline() int { return Global.Backlink.Baseline }
|
||||||
|
|
||||||
|
|||||||
+83
-12
@@ -7,6 +7,7 @@ import (
|
|||||||
"context" // context 超时控制
|
"context" // context 超时控制
|
||||||
"encoding/json" // JSON 序列化(发送关键词数据到收获服务)
|
"encoding/json" // JSON 序列化(发送关键词数据到收获服务)
|
||||||
"fmt" // 格式化(构造目标地址)
|
"fmt" // 格式化(构造目标地址)
|
||||||
|
"hash/fnv" // FNV 哈希(内容变化检测)
|
||||||
"log" // 日志输出
|
"log" // 日志输出
|
||||||
"math" // 数学运算(指数衰减、质量评分)
|
"math" // 数学运算(指数衰减、质量评分)
|
||||||
"math/rand" // 随机数(加权采样、队列打乱)
|
"math/rand" // 随机数(加权采样、队列打乱)
|
||||||
@@ -93,14 +94,58 @@ func New(db *storage.DB, a *analyzer.Analyzer, prosperMap map[string]float64) *C
|
|||||||
}
|
}
|
||||||
|
|
||||||
// warmVisited 从 DB 的 gate bucket 加载所有已缓存的 URL 到 visited set。
|
// warmVisited 从 DB 的 gate bucket 加载所有已缓存的 URL 到 visited set。
|
||||||
|
// 超过 RecrawlMaxAge 的 URL 不加入 visited,使其可以被重新爬取。
|
||||||
func (c *Crawler) warmVisited() {
|
func (c *Crawler) warmVisited() {
|
||||||
count := 0
|
count := 0
|
||||||
|
expired := 0
|
||||||
|
maxAge := int64(config.RecrawlMaxAge())
|
||||||
|
now := time.Now().Unix()
|
||||||
_ = c.db.ForEachSnippet(func(u string, entry *storage.SnippetEntry) error {
|
_ = c.db.ForEachSnippet(func(u string, entry *storage.SnippetEntry) error {
|
||||||
c.visited[u] = true
|
if now-entry.Timestamp < maxAge {
|
||||||
count++
|
c.visited[u] = true // 未过期,仍然跳过
|
||||||
|
count++
|
||||||
|
} else {
|
||||||
|
expired++
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
log.Printf("[crawler] visited set warmed: %d URLs loaded", count)
|
log.Printf("[crawler] visited set warmed: %d active, %d expired (eligible for recrawl)", count, expired)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startRecrawlTicker 启动后台定时任务,定期扫描并释放过期 URL 回到候选池。
|
||||||
|
// 已过期的 URL 从 visited map 中移除,使其可以在后续 BFS 轮次中被重新发现和爬取。
|
||||||
|
func (c *Crawler) startRecrawlTicker() {
|
||||||
|
interval := config.RecrawlCheckInterval()
|
||||||
|
if interval <= 0 {
|
||||||
|
return // 未配置或禁用
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(time.Duration(interval) * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
maxAge := int64(config.RecrawlMaxAge())
|
||||||
|
batchSize := config.RecrawlBatchSize()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
removed := 0
|
||||||
|
|
||||||
|
c.visitedMu.Lock()
|
||||||
|
_ = c.db.ForEachSnippet(func(u string, entry *storage.SnippetEntry) error {
|
||||||
|
if removed >= batchSize {
|
||||||
|
return fmt.Errorf("batch full") // 提前终止遍历
|
||||||
|
}
|
||||||
|
if now-entry.Timestamp >= maxAge && c.visited[u] {
|
||||||
|
delete(c.visited, u)
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
c.visitedMu.Unlock()
|
||||||
|
|
||||||
|
if removed > 0 {
|
||||||
|
log.Printf("[crawler] recrawl ticker: released %d expired URLs back to pool", removed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// markVisited 将 URL 标记为已访问(线程安全)。
|
// markVisited 将 URL 标记为已访问(线程安全)。
|
||||||
@@ -154,6 +199,9 @@ func (c *Crawler) Run(entryURL string, maxEpoch int) {
|
|||||||
c.markVisited(entryURL)
|
c.markVisited(entryURL)
|
||||||
queue := []string{entryURL}
|
queue := []string{entryURL}
|
||||||
|
|
||||||
|
// 启动后台重爬定时器:定期释放过期 URL 到候选池
|
||||||
|
c.startRecrawlTicker()
|
||||||
|
|
||||||
for ep := 0; ep < maxEpoch; ep++ {
|
for ep := 0; ep < maxEpoch; ep++ {
|
||||||
// 每轮 epoch 从 config 读取最新 workers 值,支持运行时动态调整
|
// 每轮 epoch 从 config 读取最新 workers 值,支持运行时动态调整
|
||||||
workers := config.CrawlerWorkers()
|
workers := config.CrawlerWorkers()
|
||||||
@@ -263,6 +311,17 @@ func (c *Crawler) visitURL(rawURL string) (hrefs []string) {
|
|||||||
// 解析 HTML:提取标题、描述、正文和所有超链接
|
// 解析 HTML:提取标题、描述、正文和所有超链接
|
||||||
title, desc, text, hrefs := parser.ParseHTML(res.Body, res.FinalURL)
|
title, desc, text, hrefs := parser.ParseHTML(res.Body, res.FinalURL)
|
||||||
|
|
||||||
|
// 计算正文内容哈希(FNV-1a),用于增量重爬检测
|
||||||
|
contentHash := fnvHash(text)
|
||||||
|
|
||||||
|
// 增量重爬检测:查询上次爬取的哈希,内容未变则跳过关键词提取
|
||||||
|
isRecrawl := false
|
||||||
|
oldEntry, _ := c.db.GetSnippet(res.FinalURL)
|
||||||
|
if oldEntry != nil && oldEntry.ContentHash != "" && oldEntry.ContentHash == contentHash {
|
||||||
|
isRecrawl = true
|
||||||
|
log.Printf("[crawler] unchanged (recrawl skip): %s", res.FinalURL)
|
||||||
|
}
|
||||||
|
|
||||||
// 缓存 URL 摘要(仅对短 URL 缓存,防止超长 URL 浪费空间)
|
// 缓存 URL 摘要(仅对短 URL 缓存,防止超长 URL 浪费空间)
|
||||||
if len(res.FinalURL) < 250 {
|
if len(res.FinalURL) < 250 {
|
||||||
_ = c.db.SetSnippet(res.FinalURL, &storage.SnippetEntry{
|
_ = c.db.SetSnippet(res.FinalURL, &storage.SnippetEntry{
|
||||||
@@ -270,20 +329,24 @@ func (c *Crawler) visitURL(rawURL string) (hrefs []string) {
|
|||||||
Description: truncate(desc, 256),
|
Description: truncate(desc, 256),
|
||||||
Text: truncate(text, 256),
|
Text: truncate(text, 256),
|
||||||
Timestamp: time.Now().Unix(),
|
Timestamp: time.Now().Unix(),
|
||||||
|
ContentHash: contentHash,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关键词提取:将标题/描述/正文交给 analyzer 计算关键词权重
|
// 关键词提取:将标题/描述/正文交给 analyzer 计算关键词权重
|
||||||
kws := c.analyzer.Analyze(title, desc, text)
|
// 增量优化:如果内容未变化(重爬),跳过关键词提取和索引更新
|
||||||
if len(kws) > 0 {
|
if !isRecrawl {
|
||||||
// 限制每个页面最多发送的关键词数量
|
kws := c.analyzer.Analyze(title, desc, text)
|
||||||
maxKws := config.MaxKeywordsPerPage()
|
if len(kws) > 0 {
|
||||||
if len(kws) > maxKws {
|
// 限制每个页面最多发送的关键词数量
|
||||||
kws = kws[:maxKws]
|
maxKws := config.MaxKeywordsPerPage()
|
||||||
|
if len(kws) > maxKws {
|
||||||
|
kws = kws[:maxKws]
|
||||||
|
}
|
||||||
|
atomic.AddInt64(&c.stats.KeywordsFetched, int64(len(kws)))
|
||||||
|
// 异步发送到收获服务器写入倒排索引(不阻塞爬取流程)
|
||||||
|
go c.sendToHarvester(res.FinalURL, kws)
|
||||||
}
|
}
|
||||||
atomic.AddInt64(&c.stats.KeywordsFetched, int64(len(kws)))
|
|
||||||
// 异步发送到收获服务器写入倒排索引(不阻塞爬取流程)
|
|
||||||
go c.sendToHarvester(res.FinalURL, kws)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新网站元信息(成功访问)
|
// 更新网站元信息(成功访问)
|
||||||
@@ -671,6 +734,14 @@ func truncate(s string, n int) string {
|
|||||||
return s[:n]
|
return s[:n]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fnvHash 使用 FNV-1a 算法计算字符串的哈希值(十六进制字符串)。
|
||||||
|
// 用于增量重爬时检测页面正文是否发生变化。
|
||||||
|
func fnvHash(s string) string {
|
||||||
|
h := fnv.New128a()
|
||||||
|
h.Write([]byte(s))
|
||||||
|
return fmt.Sprintf("%x", h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
// sampleStrings 从字符串切片中随机不重复抽取 n 条。
|
// sampleStrings 从字符串切片中随机不重复抽取 n 条。
|
||||||
func sampleStrings(s []string, n int) []string {
|
func sampleStrings(s []string, n int) []string {
|
||||||
if len(s) <= n {
|
if len(s) <= n {
|
||||||
|
|||||||
Vendored
+6
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>SESE 爬取管理</title>
|
<title>SESE 爬取管理</title>
|
||||||
<script type="module" crossorigin src="/assets/index-w20XarNx.js"></script>
|
<script type="module" crossorigin src="/assets/index-CQNkho4R.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CLWukEE8.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D_FJuGDF.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ crawler:
|
|||||||
max_epoch: 100 # BFS 爬取的最大轮次上限
|
max_epoch: 100 # BFS 爬取的最大轮次上限
|
||||||
expected_prosper_ratio: 0.6 # 队列中预期"繁荣"域名(高反向链接)的占比,用于调度决策
|
expected_prosper_ratio: 0.6 # 队列中预期"繁荣"域名(高反向链接)的占比,用于调度决策
|
||||||
entry_url: "https://zh.wikipedia.org/" # BFS 爬取的起始入口 URL
|
entry_url: "https://zh.wikipedia.org/" # BFS 爬取的起始入口 URL
|
||||||
|
max_page_size: 5242880 # 单个页面最大抓取字节数(0=不限,默认 5MB)
|
||||||
|
recrawl_max_age: 2592000 # URL 过期时间(秒),超过此时间的 URL 允许被重爬,默认 30 天
|
||||||
|
recrawl_check_interval: 3600 # 运行期间检查过期 URL 的间隔(秒),默认 1 小时
|
||||||
|
recrawl_batch_size: 500 # 每次检查最多释放多少个过期 URL
|
||||||
|
|
||||||
# 搜索结果排序权重配置
|
# 搜索结果排序权重配置
|
||||||
search:
|
search:
|
||||||
|
|||||||
+80
-10
@@ -265,12 +265,18 @@ func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
langCount := make(map[string]int)
|
langCount := make(map[string]int)
|
||||||
totalWords := 0
|
totalWords := 0
|
||||||
total := 0
|
total := 0
|
||||||
|
recrawlEligible := 0
|
||||||
|
now := time.Now().Unix()
|
||||||
|
maxAge := int64(config.RecrawlMaxAge())
|
||||||
|
|
||||||
s.db.ForEachSnippet(func(url string, snippet *storage.SnippetEntry) error {
|
s.db.ForEachSnippet(func(url string, snippet *storage.SnippetEntry) error {
|
||||||
total++
|
total++
|
||||||
domain := netloc(url)
|
domain := netloc(url)
|
||||||
domainCount[domain]++
|
domainCount[domain]++
|
||||||
totalWords += len(snippet.Text)
|
totalWords += len(snippet.Text)
|
||||||
|
if now-snippet.Timestamp >= maxAge {
|
||||||
|
recrawlEligible++
|
||||||
|
}
|
||||||
|
|
||||||
siteInfo, _ := s.db.GetSiteInfo(domain)
|
siteInfo, _ := s.db.GetSiteInfo(domain)
|
||||||
if siteInfo != nil {
|
if siteInfo != nil {
|
||||||
@@ -315,12 +321,13 @@ func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resp := map[string]any{
|
resp := map[string]any{
|
||||||
"total_urls": total,
|
"total_urls": total,
|
||||||
"total_words": totalWords,
|
"total_words": totalWords,
|
||||||
"total_domains": len(domainCount), // 真实的域名总数(非Top 20)
|
"total_domains": len(domainCount), // 真实的域名总数(非Top 20)
|
||||||
"domains": domainsMap,
|
"domains": domainsMap,
|
||||||
"languages": langsMap,
|
"languages": langsMap,
|
||||||
"pending": atomic.LoadInt64(&s.rowCount), // 内存中未刷盘的索引条目数
|
"pending": atomic.LoadInt64(&s.rowCount), // 内存中未刷盘的索引条目数
|
||||||
|
"recrawl_eligible": recrawlEligible, // 已过期、可被重爬的 URL 数量
|
||||||
}
|
}
|
||||||
|
|
||||||
json.NewEncoder(w).Encode(resp)
|
json.NewEncoder(w).Encode(resp)
|
||||||
@@ -575,7 +582,8 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
if m := siteRe.FindStringSubmatch(part); len(m) > 1 {
|
if m := siteRe.FindStringSubmatch(part); len(m) > 1 {
|
||||||
siteFilter = m[1] // site:example.com 提取目标主机名
|
siteFilter = m[1] // site:example.com 提取目标主机名
|
||||||
} else {
|
} else {
|
||||||
segs := s.analyzer.Segment(part, false)
|
// 搜索模式分词(CutForSearch):更细粒度,"气象局" → ["气象", "局", "气象局"]
|
||||||
|
segs := s.analyzer.Segment(part, true)
|
||||||
for _, t := range segs {
|
for _, t := range segs {
|
||||||
if !s.infoSvc.IsBlocked(t) { // 过滤屏蔽词
|
if !s.infoSvc.IsBlocked(t) { // 过滤屏蔽词
|
||||||
tokens = append(tokens, t)
|
tokens = append(tokens, t)
|
||||||
@@ -584,6 +592,11 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 智能去重:当一个词是另一个词的子串时,保留两者但标记子词关系。
|
||||||
|
// 这样评分时可以用长词(精确匹配)加分,同时不因缺少短词而过度惩罚。
|
||||||
|
// 算法:对于每一对词 (a, b),如果 a 是 b 的子串且 a != b,则 a 是 b 的子词。
|
||||||
|
tokens = deduplicateSubstrings(tokens)
|
||||||
|
|
||||||
// 最多保留 20 个词(避免查询过于宽泛)
|
// 最多保留 20 个词(避免查询过于宽泛)
|
||||||
if len(tokens) > 20 {
|
if len(tokens) > 20 {
|
||||||
tokens = tokens[:20]
|
tokens = tokens[:20]
|
||||||
@@ -691,10 +704,15 @@ func (s *Server) query(tokens []string, from, to int, siteFilter string) ([]sear
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 计算每个 URL 的相关性和初始分数
|
// 计算每个 URL 的相关性和初始分数
|
||||||
|
// 评分策略:部分匹配加权和 + 缺词软惩罚(替代原来的全词乘积)
|
||||||
|
// 全词乘积问题:一个 URL 只要缺少任何一个查询词,rel 就接近 0,
|
||||||
|
// 导致"气象局"拆成 ["气象局","局"] 后,只有"气象"没有"气象局"的页面被淹没。
|
||||||
|
missPenalty := config.MissPenalty()
|
||||||
candidates := make([]candidate, 0, len(urlWeights))
|
candidates := make([]candidate, 0, len(urlWeights))
|
||||||
for u, vs := range urlWeights {
|
for u, vs := range urlWeights {
|
||||||
// 词权重相乘(贝叶斯概率近似),缺省权重填充缺失词
|
// 统计实际匹配的词数和权重总和
|
||||||
rel := 1.0
|
matchedCount := 0
|
||||||
|
sumWeight := 0.0
|
||||||
for _, ti := range tokenIndexes {
|
for _, ti := range tokenIndexes {
|
||||||
vp := vs[ti.token]
|
vp := vs[ti.token]
|
||||||
if vp == 0 {
|
if vp == 0 {
|
||||||
@@ -703,8 +721,23 @@ func (s *Server) query(tokens []string, from, to int, siteFilter string) ([]sear
|
|||||||
if vp > 0.06 {
|
if vp > 0.06 {
|
||||||
vp = math.Log((vp-0.06)*40+1)/40 + 0.06
|
vp = math.Log((vp-0.06)*40+1)/40 + 0.06
|
||||||
}
|
}
|
||||||
rel *= vp
|
sumWeight += vp
|
||||||
|
// 只有权重超过默认值才算真正匹配(排除了 defVal 填充的假匹配)
|
||||||
|
if vs[ti.token] > 0 {
|
||||||
|
matchedCount++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
totalTokens := len(tokenIndexes)
|
||||||
|
// 部分匹配相关性 = 加权平均 × 匹配覆盖率加成
|
||||||
|
// matchRatio:匹配词占比,全部匹配=1,全部缺失=0
|
||||||
|
matchRatio := float64(matchedCount) / float64(totalTokens)
|
||||||
|
// avgWeight:匹配词的平均权重
|
||||||
|
avgWeight := sumWeight / float64(totalTokens)
|
||||||
|
// rel = 平均权重 × (匹配率 + 未匹配部分的软惩罚)
|
||||||
|
// missPenalty 控制未匹配词的惩罚力度:
|
||||||
|
// 0 = 完全不惩罚(纯加权和)
|
||||||
|
// 1 = 缺词的权重取 0(等同于全词乘积的极端情况)
|
||||||
|
rel := avgWeight * (matchRatio + (1-matchRatio)*missPenalty)
|
||||||
// 反向链接繁荣加分
|
// 反向链接繁荣加分
|
||||||
prosper := 1 + s.infoSvc.Prosper(u)*config.BacklinkWeight()
|
prosper := 1 + s.infoSvc.Prosper(u)*config.BacklinkWeight()
|
||||||
bad := badURL(u)
|
bad := badURL(u)
|
||||||
@@ -969,6 +1002,43 @@ func badURL(u string) float64 {
|
|||||||
return math.Min(s, 0.9)
|
return math.Min(s, 0.9)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deduplicateSubstrings 对分词结果进行智能去重。
|
||||||
|
// 当词 A 是词 B 的子串时(A ≠ B),移除较短的 A。
|
||||||
|
// 例如 ["气象", "局", "气象局"] → ["气象局", "局"]
|
||||||
|
// 保留最长词以确保精确匹配优先,同时短词作为兜底召回。
|
||||||
|
func deduplicateSubstrings(tokens []string) []string {
|
||||||
|
if len(tokens) <= 1 {
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
// 按长度降序排列,等长按字典序
|
||||||
|
sort.Slice(tokens, func(i, j int) bool {
|
||||||
|
if len(tokens[i]) != len(tokens[j]) {
|
||||||
|
return len(tokens[i]) > len(tokens[j])
|
||||||
|
}
|
||||||
|
return tokens[i] < tokens[j]
|
||||||
|
})
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var result []string
|
||||||
|
for _, t := range tokens {
|
||||||
|
if seen[t] {
|
||||||
|
continue // 完全重复的词跳过
|
||||||
|
}
|
||||||
|
seen[t] = true
|
||||||
|
// 检查是否已被更长的词包含(t 是某个已保留词的子串)
|
||||||
|
isSubstr := false
|
||||||
|
for _, kept := range result {
|
||||||
|
if strings.Contains(kept, t) && kept != t {
|
||||||
|
isSubstr = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isSubstr {
|
||||||
|
result = append(result, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// netloc 从 URL 提取主机名。
|
// netloc 从 URL 提取主机名。
|
||||||
func netloc(rawURL string) string {
|
func netloc(rawURL string) string {
|
||||||
parts := strings.SplitN(rawURL, "/", 4)
|
parts := strings.SplitN(rawURL, "/", 4)
|
||||||
|
|||||||
+1
-1
Submodule sese-engine-ui updated: 12d93d0208...969b9a7c7e
+2
-1
@@ -30,12 +30,13 @@ type IndexEntry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SnippetEntry 是 URL 对应的摘要信息缓存。
|
// SnippetEntry 是 URL 对应的摘要信息缓存。
|
||||||
// 包含页面标题、描述、正文片段和抓取时间戳。
|
// 包含页面标题、描述、正文片段、抓取时间戳和内容哈希(用于增量重爬检测)。
|
||||||
type SnippetEntry struct {
|
type SnippetEntry struct {
|
||||||
Title string `json:"title"` // 网页标题
|
Title string `json:"title"` // 网页标题
|
||||||
Description string `json:"desc"` // meta description 或自动生成的描述
|
Description string `json:"desc"` // meta description 或自动生成的描述
|
||||||
Text string `json:"text"` // 正文前 N 字符的文本片段
|
Text string `json:"text"` // 正文前 N 字符的文本片段
|
||||||
Timestamp int64 `json:"ts"` // 抓取该页面时的 Unix 时间戳
|
Timestamp int64 `json:"ts"` // 抓取该页面时的 Unix 时间戳
|
||||||
|
ContentHash string `json:"hash"` // 正文内容的 FNV-1a 哈希(用于增量重爬判断内容是否变化)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 四个 bbolt bucket 的名称(以字节数组存储,bbolt 要求 key/value 均为字节)
|
// 四个 bbolt bucket 的名称(以字节数组存储,bbolt 要求 key/value 均为字节)
|
||||||
|
|||||||
Reference in New Issue
Block a user