Back to Projects
KNOWLEDGE MINING

Ask any YouTube creator anything. Get answers from their actual words.

3-hour videos. Brilliant insights buried at minute 47. No way to search, no way to find them again. So I built a system that turns every spoken word into searchable, queryable knowledge.

View on GitHub
THE PIPELINE

From YouTube URL to searchable knowledge.

Ingest

Paste a YouTube URL. Transcript fetched automatically.

Chunk

Content split into searchable segments automatically.

Embed

Each segment converted to a searchable representation - locally, no external APIs.

Search

Search that understands meaning and catches exact words.

YouTube Transcript API pulls the full spoken content. Metadata via oEmbed. Background job queue with retry logic.

Sentence-boundary aware splitting preserves meaning. Overlapping segments ensure nothing is lost between sections.

The model runs locally - zero external API calls, zero cost per query. Batch processing with automatic caching on cold start.

Two search strategies - semantic similarity and exact keyword matching - fused into one ranked result set. Optional temporal decay for recency bias.

SEARCH SYSTEM

Search that understands what you mean - and catches what you typed.

Two search strategies work in parallel. One understands meaning - find results about “state management” even if those exact words never appear. The other catches exact terms - find every mention of “useEffect.” Results found by both methods get ranked higher.

Here's the actual code. I broke it into the three decisions that made it work.

1 both searches, in parallel
search.ts
// Run both search strategies in parallel
const [semanticResults, keywordResults] = await Promise.all([
searchByMeaning(query, limit * 2),
searchByKeywords(query, limit * 2),
])
the tradeoff

Vector search alone kept whiffing on exact matches. Search "useEffect" and you want that literal string, not the nearest-meaning thing. Both run in parallel, so it's one extra await, not double the wait.

src/lib/db/search.ts
2 rank by position, not by score
mergeAndRank()
// Each result gets a position-based score
// Results appearing in BOTH lists get boosted
results.forEach((result, position) => {
const score = scoreByPosition(position)
const existing = combined.get(result.id)
if (existing) existing.score += score
else combined.set(result.id, { result, score })
})
the tradeoff

You can't average two scores that live on different scales, and cosine similarity and keyword relevance don't. So I rank by position in each list instead. No wrestling two scoring systems onto the same axis.

src/lib/db/search.ts
3 trim to what you asked for
search.ts
return mergeAndRank(semanticResults, keywordResults)
.slice(0, limit)
the tradeoff

I pull 2x the limit from each search before merging. That gives the fusion step room to actually reorder things. Ask for exactly the limit and there's nothing spare to boost - you starve the merge.

src/lib/db/search.ts
ZERO EXTERNAL API CALLS

The model runs locally. No OpenAI. No Cohere. No API keys.

A lightweight model runs directly on the server - no external API calls, no per-query costs. Batch processing handles large content libraries. ~23MB footprint, cached on cold start.

4 every query costs nothing
local-model.ts
// Local model - no external API calls, no usage costs
const model = await loadModel({ cache: true })
// Process text into a searchable representation
const representation = await model.encode(text, {
strategy: 'mean-pooling',
normalize: true,
})
// Store directly in the database
await db.insert(representation)
the tradeoff

Hosted embedding APIs charge you per call, so you start rationing - you don't re-embed a whole library on a whim when the meter's running. This model is tiny, like 23MB, and it runs right on the server. For chunked YouTube transcripts a small model is plenty, and every query being free means I can re-embed everything or run a batch backfill whenever I want.

src/lib/embeddings/service.ts
CREATOR PERSONAS

Ask the creator. Get answers grounded in their content.

AI-generated personas capture each YouTube channel’s expertise and communication style. Answers are always grounded in actual spoken content - not hallucinated, not generic.

Auto-generated at 5+ videos per creator
Expertise profile built from all ingested content
"Who's best?" routing matches questions to the right creator
Ensemble mode: top 3 personas stream in parallel via SSE
Every answer grounded in actual spoken content - not hallucinated, not generic
11database tables
100%local processing
4MCP tools
100+searchable resources
MCP INTEGRATION

Your knowledge bank, inside Claude Code.

4 MCP tools expose the entire knowledge bank to Claude Code workflows. Search, list creators, chat with personas, or ask the panel - all from your terminal.

search_knowledge

Dual-mode search with semantic and keyword matching, optional creator filtering

topic, creator?, limit?
get_list_of_creators

List all YouTube channels in knowledge bank with video counts

(no input)
chat_with_persona

Query a specific creator persona with responses grounded in their actual content

personaName, question
ensemble_query

Ask multiple personas simultaneously, top 3 parallel responses

question
5 meet the AI where it already lives
mcp.json
{
"mcpServers": {
"gold-miner": {
"type": "sse",
"url": "http://localhost:3001/api/mcp/sse"
}
}
}
the tradeoff

I could've built a chat UI into the app, but then you're copy-pasting transcripts in and out all day. So I exposed the whole knowledge bank as an MCP server instead. Now Claude searches it, lists creators, and talks to personas right from the terminal - I brought the knowledge to where the AI already lives instead of making it come to me.

docs/mcp-tools.md
UNDER THE HOOD
Next.js 16React 19TypeScriptPostgreSQLDrizzle ORMLocal ML ModelClaude APIMCP SDKTailwind CSS v4Vitest
github.com/devobsessed/sluice