Docs/Skills and code/src/
src/stages/12-embed-video-titles.js
import { filterQualifiedRows } from "../analysis/qualification-scope.js";
import crypto from "node:crypto";
import { getDb } from "../db.js";
import { ensureResearchSchema } from "../analysis/research-schema.js";
import { embedTexts, embeddingConfig } from "../analysis/embeddings.js";
const db = getDb();
ensureResearchSchema();
const embedding = embeddingConfig();
const inputVersion = "title-prefix-v1";
const allPending = process.argv.includes("--all");
const limitArg = process.argv.find(value => value.startsWith("--limit="));
const parsedLimit = limitArg ? Number(limitArg.split("=")[1]) : 250;
if (!allPending && (!Number.isFinite(parsedLimit) || parsedLimit < 1)) {
throw new Error("--limit must be a positive number, or use --all to process every pending title.");
}
const limit = allPending ? Infinity : Math.floor(parsedLimit);
const batchArg = process.argv.find(value => value.startsWith("--batch-size="));
const parsedBatchSize = batchArg ? Number(batchArg.split("=")[1]) : 24;
if (!Number.isFinite(parsedBatchSize) || parsedBatchSize < 1) {
throw new Error("--batch-size must be a positive number.");
}
const batchSize = Math.min(64, Math.floor(parsedBatchSize));
const force = process.argv.includes("--force");
const now = () => new Date().toISOString();
const titleHash = title => crypto.createHash("sha256").update(String(title ?? "").trim()).digest("hex");
const videos = db.prepare(`
SELECT v.video_id, v.title, v.channel_url, v.channel_name, v.discovered_at,
(SELECT COUNT(DISTINCT cv.channel_url) FROM channel_videos cv WHERE cv.video_id=v.video_id) AS linked_channel_count
FROM videos v
WHERE trim(COALESCE(v.title,'')) <> ''
ORDER BY v.discovered_at, v.video_id
`).all();
videos.splice(0, videos.length, ...filterQualifiedRows(db, videos, "video", { label: "12-embed-video-titles.js" }));
const existing = new Map(db.prepare(`
SELECT video_id, title_hash, input_version FROM video_embeddings WHERE model=?
`).all(embedding.model).map(row => [row.video_id, row]));
// Balance incremental work across the corpus instead of embedding one channel's titles first.
const coverageRows = db.prepare(`
SELECT cv.channel_url, COUNT(DISTINCT ve.video_id) AS embedded_count
FROM channel_videos cv
JOIN video_embeddings ve ON ve.video_id=cv.video_id AND ve.model=?
GROUP BY cv.channel_url
`).all(embedding.model);
const coverage = new Map(coverageRows.map(row => [row.channel_url, row.embedded_count]));
const channelByVideo = new Map();
for (const row of db.prepare(`SELECT video_id, MIN(channel_url) AS channel_url FROM channel_videos GROUP BY video_id`).all()) {
channelByVideo.set(row.video_id, row.channel_url);
}
const pending = videos.filter(video => {
const old = existing.get(video.video_id);
return force || !old || old.title_hash !== titleHash(video.title) || old.input_version !== inputVersion;
}).map(video => ({
...video,
balancingChannel: channelByVideo.get(video.video_id) ?? video.channel_url ?? "",
title_hash: titleHash(video.title)
}));
function selectBalancedPending(items, channelCoverage, maxItems) {
const groups = new Map();
for (const item of items) {
if (!groups.has(item.balancingChannel)) groups.set(item.balancingChannel, []);
groups.get(item.balancingChannel).push(item);
}
for (const group of groups.values()) {
group.sort((a, b) => String(a.discovered_at).localeCompare(String(b.discovered_at)) || a.video_id.localeCompare(b.video_id));
}
const offsets = new Map();
const selectedPerChannel = new Map();
const selected = [];
while (selected.length < maxItems) {
let bestChannel = null;
let bestCoverage = Infinity;
for (const [channel, group] of groups) {
if ((offsets.get(channel) ?? 0) >= group.length) continue;
const effectiveCoverage = (channelCoverage.get(channel) ?? 0) + (selectedPerChannel.get(channel) ?? 0);
if (effectiveCoverage < bestCoverage || (effectiveCoverage === bestCoverage && String(channel).localeCompare(String(bestChannel)) < 0)) {
bestCoverage = effectiveCoverage;
bestChannel = channel;
}
}
if (bestChannel == null) break;
const offset = offsets.get(bestChannel) ?? 0;
selected.push(groups.get(bestChannel)[offset]);
offsets.set(bestChannel, offset + 1);
selectedPerChannel.set(bestChannel, (selectedPerChannel.get(bestChannel) ?? 0) + 1);
}
return selected;
}
const selected = selectBalancedPending(pending, coverage, limit);
console.log("[video-embeddings] Starting title embedding", {
totalVideos: videos.length,
alreadyCurrent: videos.length - pending.length,
pending: pending.length,
selectedThisRun: selected.length,
limit,
batchSize,
model: embedding.model,
endpoint: `${embedding.baseUrl}/api/embed`,
force,
allPending,
inputVersion
});
if (!selected.length) {
console.log("[video-embeddings] No new or changed titles need embeddings.");
process.exit(0);
}
const save = db.prepare(`
INSERT INTO video_embeddings (video_id, model, dimensions, vector_json, title_hash, input_version, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(video_id, model) DO UPDATE SET
dimensions=excluded.dimensions,
vector_json=excluded.vector_json,
title_hash=excluded.title_hash,
input_version=excluded.input_version,
created_at=excluded.created_at
`);
let embedded = 0;
for (let start = 0; start < selected.length; start += batchSize) {
const batch = selected.slice(start, start + batchSize);
const batchNo = Math.floor(start / batchSize) + 1;
const batchCount = Math.ceil(selected.length / batchSize);
console.log(`[video-embeddings] Batch ${batchNo}/${batchCount}: requesting ${batch.length} titles.`, {
firstVideoId: batch[0]?.video_id,
lastVideoId: batch.at(-1)?.video_id
});
let vectors;
try {
vectors = await embedTexts(batch.map(video => `YouTube video title: ${video.title}`), embedding);
} catch (error) {
console.error("[video-embeddings] Ollama request failed. Earlier batches remain saved; rerun to resume.", {
batch: batchNo,
model: embedding.model,
endpoint: `${embedding.baseUrl}/api/embed`,
videoIds: batch.map(video => video.video_id),
error: error instanceof Error ? error.message : String(error)
});
process.exitCode = 1;
break;
}
if (vectors.some(vector => vector.length !== vectors[0].length)) {
throw new Error(`[video-embeddings] Inconsistent vector dimensions in batch ${batchNo}. Check the configured model.`);
}
const timestamp = now();
db.transaction(() => {
batch.forEach((video, index) => {
save.run(video.video_id, embedding.model, vectors[index].length,
JSON.stringify(vectors[index]), video.title_hash, inputVersion, timestamp);
});
})();
embedded += batch.length;
console.log(`[video-embeddings] Saved batch ${batchNo}/${batchCount}.`, {
embeddedThisRun: embedded,
remainingInSelectedBatch: selected.length - embedded,
dimensions: vectors[0]?.length
});
}
const summary = db.prepare(`
SELECT COUNT(*) AS rows, COUNT(DISTINCT video_id) AS videos,
MIN(dimensions) AS min_dimensions, MAX(dimensions) AS max_dimensions
FROM video_embeddings WHERE model=?
`).get(embedding.model);
console.log("[video-embeddings] Run complete", {
embeddedThisRun: embedded,
model: embedding.model,
summary,
next: pending.length > selected.length
? `Rerun to process the next ${Math.min(limit, pending.length - selected.length)} pending titles.`
: "Run npm run niches:build to cluster titles and score channels."
});
Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.