Add link-graph authority: record edges + PageRank recompute
Crawler now records the deduped cross-domain link graph (edges table). recompute.js runs real iterative PageRank over that graph, blends it with accumulated in-degree + the Majestic seed prior, and patches every doc's `score` (Meili's authority tie-breaker) via partial updates — preserving titles/bodies/embeddings and triggering no re-embedding. systemd timer runs it every 3h so ranking sharpens as the 1.5M-page crawl densifies the graph. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
538f7a55bc
commit
3aa08e4a4f
5 changed files with 193 additions and 1 deletions
|
|
@ -18,6 +18,9 @@ db.exec(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_frontier_status ON frontier(status);
|
CREATE INDEX IF NOT EXISTS idx_frontier_status ON frontier(status);
|
||||||
CREATE TABLE IF NOT EXISTS domain_pages (domain TEXT PRIMARY KEY, n INTEGER NOT NULL DEFAULT 0);
|
CREATE TABLE IF NOT EXISTS domain_pages (domain TEXT PRIMARY KEY, n INTEGER NOT NULL DEFAULT 0);
|
||||||
CREATE TABLE IF NOT EXISTS indeg (domain TEXT PRIMARY KEY, n INTEGER NOT NULL DEFAULT 0);
|
CREATE TABLE IF NOT EXISTS indeg (domain TEXT PRIMARY KEY, n INTEGER NOT NULL DEFAULT 0);
|
||||||
|
-- Cross-domain link graph for PageRank. Deduped src->dst (unique pairs), so a
|
||||||
|
-- single authoritative edge isn't over-counted by many links between two sites.
|
||||||
|
CREATE TABLE IF NOT EXISTS edges (src TEXT NOT NULL, dst TEXT NOT NULL, PRIMARY KEY(src,dst)) WITHOUT ROWID;
|
||||||
`)
|
`)
|
||||||
|
|
||||||
const q = {
|
const q = {
|
||||||
|
|
@ -35,6 +38,7 @@ const q = {
|
||||||
incPages: db.prepare('INSERT INTO domain_pages(domain,n) VALUES(?,1) ON CONFLICT(domain) DO UPDATE SET n=n+1'),
|
incPages: db.prepare('INSERT INTO domain_pages(domain,n) VALUES(?,1) ON CONFLICT(domain) DO UPDATE SET n=n+1'),
|
||||||
incIndeg: db.prepare('INSERT INTO indeg(domain,n) VALUES(?,1) ON CONFLICT(domain) DO UPDATE SET n=n+1'),
|
incIndeg: db.prepare('INSERT INTO indeg(domain,n) VALUES(?,1) ON CONFLICT(domain) DO UPDATE SET n=n+1'),
|
||||||
indeg: db.prepare('SELECT n FROM indeg WHERE domain=?'),
|
indeg: db.prepare('SELECT n FROM indeg WHERE domain=?'),
|
||||||
|
addEdge: db.prepare('INSERT OR IGNORE INTO edges(src,dst) VALUES(?,?)'),
|
||||||
}
|
}
|
||||||
|
|
||||||
export function enqueue(url, domain, depth, rank = 999999999) { q.add.run(url, domain, depth, rank) }
|
export function enqueue(url, domain, depth, rank = 999999999) { q.add.run(url, domain, depth, rank) }
|
||||||
|
|
@ -47,6 +51,8 @@ export function domainPages(domain) { return q.pages.get(domain)?.n ?? 0 }
|
||||||
export function incDomainPages(domain) { q.incPages.run(domain) }
|
export function incDomainPages(domain) { q.incPages.run(domain) }
|
||||||
export function incIndeg(domain) { q.incIndeg.run(domain) }
|
export function incIndeg(domain) { q.incIndeg.run(domain) }
|
||||||
export function indegOf(domain) { return q.indeg.get(domain)?.n ?? 0 }
|
export function indegOf(domain) { return q.indeg.get(domain)?.n ?? 0 }
|
||||||
|
// Record a cross-domain link src->dst for the PageRank graph (skip self-loops).
|
||||||
|
export function addEdge(src, dst) { if (src && dst && src !== dst) q.addEdge.run(src, dst) }
|
||||||
|
|
||||||
// Atomically claim up to n queued URLs (single-threaded JS => select+update is atomic).
|
// Atomically claim up to n queued URLs (single-threaded JS => select+update is atomic).
|
||||||
export function claimBatch(n) {
|
export function claimBatch(n) {
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ async function crawlOne(row) {
|
||||||
if (F.domainPages(l.domain) < MAX_PAGES_PER_DOMAIN) F.enqueue(l.url, l.domain, row.depth + 1, row.rank)
|
if (F.domainPages(l.domain) < MAX_PAGES_PER_DOMAIN) F.enqueue(l.url, l.domain, row.depth + 1, row.rank)
|
||||||
} else {
|
} else {
|
||||||
F.incIndeg(l.domain) // inbound cross-domain link = authority signal
|
F.incIndeg(l.domain) // inbound cross-domain link = authority signal
|
||||||
|
F.addEdge(doc.domain, l.domain) // record edge for PageRank recompute
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
152
crawler/src/recompute.js
Normal file
152
crawler/src/recompute.js
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
// recompute.js — domain-level authority recompute → push scores to Meilisearch.
|
||||||
|
//
|
||||||
|
// Runs real iterative PageRank over the crawl's cross-domain link graph (the
|
||||||
|
// `edges` table), blends it with accumulated in-degree and the Majestic seed
|
||||||
|
// rank (a trustworthy global prior), then patches every indexed document's
|
||||||
|
// `score` field — Meili's authority tie-breaker (last ranking rule) — via
|
||||||
|
// PARTIAL updates (PUT), so titles/bodies/embeddings are left untouched and no
|
||||||
|
// re-embedding is triggered.
|
||||||
|
//
|
||||||
|
// Safe to run while the crawler is live; offset pagination self-heals on the
|
||||||
|
// next run. Intended to run periodically (systemd timer) as the crawl matures:
|
||||||
|
// early on the graph is sparse and in-degree carries the signal; as edges
|
||||||
|
// accumulate toward the 1.5M-page target, PageRank takes over.
|
||||||
|
//
|
||||||
|
// node --experimental-sqlite src/recompute.js
|
||||||
|
//
|
||||||
|
// Env: FRONTIER_DB, MEILI_URL, MEILI_KEY, DAMPING, ITERS, PR_WEIGHT, INDEG_WEIGHT
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
|
||||||
|
const DB_PATH = process.env.FRONTIER_DB || './frontier.db'
|
||||||
|
const MEILI_URL = process.env.MEILI_URL || 'http://localhost:7700'
|
||||||
|
const MEILI_KEY = process.env.MEILI_KEY || 'masterKey'
|
||||||
|
const INDEX = 'pages'
|
||||||
|
const DAMPING = parseFloat(process.env.DAMPING || '0.85')
|
||||||
|
const ITERS = parseInt(process.env.ITERS || '40')
|
||||||
|
const AUTH_SCALE = 6_000_000 // max authority bonus layered on baseScore
|
||||||
|
const PR_WEIGHT = parseFloat(process.env.PR_WEIGHT || '0.6')
|
||||||
|
const INDEG_WEIGHT = parseFloat(process.env.INDEG_WEIGHT || '0.4')
|
||||||
|
const PAGE = 1000
|
||||||
|
|
||||||
|
// Seed authority from Majestic rank (rank 1 => ~10M). Mirrors indexer.js so the
|
||||||
|
// recomputed score stays on the same scale as freshly-crawled docs.
|
||||||
|
function baseScore(rank) {
|
||||||
|
return Math.max(1, 10_000_000 - Math.min(rank ?? 9_999_999, 9_999_999))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function meili(method, path, body) {
|
||||||
|
const res = await fetch(`${MEILI_URL}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { Authorization: `Bearer ${MEILI_KEY}`, 'Content-Type': 'application/json' },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`${method} ${path} -> ${res.status} ${await res.text()}`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull the domain graph + priors out of the frontier DB.
|
||||||
|
function loadGraph(db) {
|
||||||
|
const nodes = new Set()
|
||||||
|
const out = new Map() // src -> [dst,...] (deduped by PK)
|
||||||
|
for (const { src, dst } of db.prepare('SELECT src,dst FROM edges').all()) {
|
||||||
|
nodes.add(src); nodes.add(dst)
|
||||||
|
if (!out.has(src)) out.set(src, [])
|
||||||
|
out.get(src).push(dst)
|
||||||
|
}
|
||||||
|
const indeg = new Map() // accumulated cross-domain in-degree
|
||||||
|
for (const { domain, n } of db.prepare('SELECT domain,n FROM indeg').all()) {
|
||||||
|
indeg.set(domain, n); nodes.add(domain)
|
||||||
|
}
|
||||||
|
const baseRank = new Map() // domain -> best (lowest) Majestic rank seen
|
||||||
|
for (const { domain, r } of db.prepare('SELECT domain, MIN(rank) r FROM frontier GROUP BY domain').all()) {
|
||||||
|
baseRank.set(domain, r); nodes.add(domain)
|
||||||
|
}
|
||||||
|
return { nodes: [...nodes], out, indeg, baseRank }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard PageRank with damping + dangling-node redistribution.
|
||||||
|
function pagerank(nodes, out) {
|
||||||
|
const N = nodes.length
|
||||||
|
if (N === 0) return new Map()
|
||||||
|
const idx = new Map(nodes.map((d, i) => [d, i]))
|
||||||
|
const outdeg = new Float64Array(N)
|
||||||
|
const inlinks = Array.from({ length: N }, () => []) // i -> [src indices]
|
||||||
|
for (const [src, dsts] of out) {
|
||||||
|
const si = idx.get(src)
|
||||||
|
outdeg[si] = dsts.length
|
||||||
|
for (const dst of dsts) inlinks[idx.get(dst)].push(si)
|
||||||
|
}
|
||||||
|
let pr = new Float64Array(N).fill(1 / N)
|
||||||
|
const teleport = (1 - DAMPING) / N
|
||||||
|
for (let it = 0; it < ITERS; it++) {
|
||||||
|
let dangling = 0
|
||||||
|
for (let i = 0; i < N; i++) if (outdeg[i] === 0) dangling += pr[i]
|
||||||
|
const danglingShare = DAMPING * dangling / N
|
||||||
|
const next = new Float64Array(N)
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
let s = 0
|
||||||
|
const ins = inlinks[i]
|
||||||
|
for (let k = 0; k < ins.length; k++) { const j = ins[k]; s += pr[j] / outdeg[j] }
|
||||||
|
next[i] = teleport + danglingShare + DAMPING * s
|
||||||
|
}
|
||||||
|
pr = next
|
||||||
|
}
|
||||||
|
const m = new Map()
|
||||||
|
for (let i = 0; i < N; i++) m.set(nodes[i], pr[i])
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const db = new DatabaseSync(DB_PATH, { readOnly: true })
|
||||||
|
console.log(`Loading link graph from ${DB_PATH} ...`)
|
||||||
|
const { nodes, out, indeg, baseRank } = loadGraph(db)
|
||||||
|
const edgeCount = [...out.values()].reduce((a, v) => a + v.length, 0)
|
||||||
|
console.log(`Graph: ${nodes.length} domains, ${edgeCount} unique edges, ${indeg.size} with in-degree`)
|
||||||
|
|
||||||
|
console.log(`PageRank: damping=${DAMPING} iters=${ITERS} ...`)
|
||||||
|
const pr = pagerank(nodes, out)
|
||||||
|
|
||||||
|
let maxPr = 0
|
||||||
|
for (const v of pr.values()) if (v > maxPr) maxPr = v
|
||||||
|
let maxLogIndeg = 0
|
||||||
|
for (const v of indeg.values()) { const l = Math.log1p(v); if (l > maxLogIndeg) maxLogIndeg = l }
|
||||||
|
|
||||||
|
// Per-domain final score: Majestic base + blended (PageRank, log-in-degree)
|
||||||
|
// authority. Both authority terms normalized to [0,1] so the blend is stable
|
||||||
|
// whether the graph is sparse (early) or dense (mature).
|
||||||
|
function domainScore(d) {
|
||||||
|
const prN = maxPr > 0 ? (pr.get(d) || 0) / maxPr : 0
|
||||||
|
const inN = maxLogIndeg > 0 ? Math.log1p(indeg.get(d) || 0) / maxLogIndeg : 0
|
||||||
|
const authority = AUTH_SCALE * (PR_WEIGHT * prN + INDEG_WEIGHT * inN)
|
||||||
|
return Math.round(baseScore(baseRank.get(d)) + authority)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the top domains this recompute promotes — a quick sanity check.
|
||||||
|
const top = nodes
|
||||||
|
.filter(d => pr.has(d))
|
||||||
|
.sort((a, b) => domainScore(b) - domainScore(a))
|
||||||
|
.slice(0, 15)
|
||||||
|
console.log('Top domains by recomputed authority:')
|
||||||
|
for (const d of top) {
|
||||||
|
console.log(` ${domainScore(d).toString().padStart(9)} ${d} (pr=${(pr.get(d) / (maxPr || 1)).toFixed(3)}, indeg=${indeg.get(d) || 0})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch every indexed document's score via partial PUT updates.
|
||||||
|
console.log('Patching document scores in Meili ...')
|
||||||
|
let offset = 0, updated = 0, lastTask = null
|
||||||
|
for (;;) {
|
||||||
|
const res = await meili('GET', `/indexes/${INDEX}/documents?fields=id,domain&limit=${PAGE}&offset=${offset}`)
|
||||||
|
const results = res.results || []
|
||||||
|
if (results.length === 0) break
|
||||||
|
const patch = results.map(r => ({ id: r.id, score: domainScore(r.domain) }))
|
||||||
|
lastTask = await meili('PUT', `/indexes/${INDEX}/documents`, patch) // partial: only `score` changes
|
||||||
|
updated += patch.length
|
||||||
|
offset += results.length
|
||||||
|
process.stdout.write(`\r[recompute] queued score updates for ${updated} docs...`)
|
||||||
|
if (results.length < PAGE) break
|
||||||
|
}
|
||||||
|
db.close()
|
||||||
|
console.log(`\nDone. Queued authority scores for ${updated} documents. Last Meili task: ${lastTask?.taskUid ?? 'n/a'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error(e); process.exit(1) })
|
||||||
21
scripts/search-recompute.service
Normal file
21
scripts/search-recompute.service
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
[Unit]
|
||||||
|
Description=RADICAL_SEARCH authority recompute (PageRank -> Meili scores)
|
||||||
|
After=network.target meilisearch.service
|
||||||
|
Requires=meilisearch.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/opt/search-engine/crawler
|
||||||
|
ExecStart=/usr/bin/node --experimental-sqlite src/recompute.js
|
||||||
|
# Keep the recompute from contending with the live crawl for CPU.
|
||||||
|
CPUQuota=120%
|
||||||
|
MemoryMax=1500M
|
||||||
|
|
||||||
|
Environment=FRONTIER_DB=/opt/search-engine/crawler/frontier.db
|
||||||
|
Environment=MEILI_URL=http://127.0.0.1:7700
|
||||||
|
# MEILI_KEY is injected via an override drop-in on the box (keeps the key out of git).
|
||||||
|
Environment=DAMPING=0.85
|
||||||
|
Environment=ITERS=40
|
||||||
|
Environment=PR_WEIGHT=0.6
|
||||||
|
Environment=INDEG_WEIGHT=0.4
|
||||||
12
scripts/search-recompute.timer
Normal file
12
scripts/search-recompute.timer
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Run RADICAL_SEARCH authority recompute periodically
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# First run 20 min after boot, then every 3 hours. As the crawl matures the
|
||||||
|
# link graph densifies and each recompute sharpens ranking.
|
||||||
|
OnBootSec=20min
|
||||||
|
OnUnitActiveSec=3h
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
Loading…
Add table
Reference in a new issue