tc-ai-experiments

Semantic search + RAG prototype (LangChain)

A learning prototype for the parts of a semantic search engine, built on a music dataset of 51 artists and about 4.9k releases from MusicBrainz. The data is in data/; see data/README.md for its source and licence.

See the results

These pages come from the latest run. They’re committed in output/ and published with GitHub Pages, so you can open them without running anything:

Setup

Stages

Run the stages in order. Each one reuses the earlier stages from src/lib/pipeline.ts and prints what its own step produces.

Script Concept What you see
npm run 1-load Document loaders CSVLoader returns one Document per row (4,931 release rows). These are reshaped into 51 artist profile Documents.
npm run 2-split Text splitters RecursiveCharacterTextSplitter (800 characters, 100 overlap) turns the 51 profiles into 179 chunks. Metadata is copied to every chunk. Chunks that lost the artist name get it added back.
npm run 3-search Embeddings + vector store One raw 1536-number vector, then Chroma similarity search (cosine distance, lower is closer), a check against music_ground_truths.json, and a where-filtered search.
npm run 4-rag -- "question" RAG Retrieve 6 chunks, fill a ChatPromptTemplate, then gpt-5-mini answers using only that context.
npm run 5-visualize Looking at embeddings Writes output/embedding-map.html: every chunk on a 2D t-SNE map, with genre or artist highlights, each query’s top 6 retrieved chunks, and a nearest-neighbour check on the full vectors.
npm run 6-walkthrough -- "question" The whole pipeline, one question Writes output/walkthrough.html. It follows one real question through all 12 steps, with the data each step produced: CSV row, Document, chunks (overlap marked), the vector as a colour strip, the search with the cut-off, how the #1 score adds up, the filled prompt, token counts and the answer.
npm run 7-evaluate Evaluating the pipeline Runs the 13 questions in data/eval_questions.json through the pipeline and scores retrieval (context recall and precision, no LLM) and answers (faithfulness, answer relevancy, correctness, refusal) with gemini-2.5-flash as judge. Choose how chunks are picked with -- --strategy top-k, two-per-artist or one-per-artist, re-rank with rerank, or skip retrieval with long-context, and name the run with -- --name. Each run is saved as output/eval-runs/<name>.json with its own page. -- --render rebuilds the pages without API calls.
npm run 8-compare Comparing runs Writes output/eval-runs/comparison.html from every saved run: average scores as a dot plot with a noise band, a question-by-question grid, and which artists each strategy retrieved. No API calls.
npm run 9-rerank-consistency Re-ranker repeatability Scores the same 30 candidates 3 times per scale and temperature, and the expected artists’ profile chunks on their own. Writes output/rerank-consistency.json. About 60 re-ranker calls.
npm run 9-rerank-report The re-ranking experiment Writes output/eval-runs/rerank-experiment.html from the eight re-rank variant runs and the repeatability check: chunks scored alone and in the batch, repeatability, averages, the soul candidates’ scores in each run, and which expected artists were missing. No API calls.
npm run reindex Rebuild the index Drops the collection and embeds everything again.

How it flows

The pipeline runs in two phases. Indexing turns the CSVs into vectors in Chroma. It runs on the first stage 3/4 run that finds the collection empty, and again on npm run reindex. Query time runs for every question. The numbers on the boxes are the stage scripts.

flowchart TB
  subgraph indexing["Indexing"]
    direction TB
    csv[("artists.csv + releases.csv<br/>51 + 4,931 rows")]
    load["<b>1 · Load</b><br/>CSVLoader: one Document per row"]
    shape["<b>Reshape</b><br/>one profile Document per artist: 51"]
    split["<b>2 · Split</b><br/>RecursiveCharacterTextSplitter<br/>800 chars, 100 overlap: 179 chunks"]
    embedChunks["<b>3 · Embed</b><br/>OpenAIEmbeddings, text-embedding-3-small<br/>each chunk becomes 1,536 numbers"]
    store[("<b>Chroma</b> collection music-artists<br/>vector + text + metadata per chunk<br/>saved in ./chroma-data")]
    csv --> load --> shape --> split --> embedChunks --> store
  end

  subgraph querying["Query time"]
    direction TB
    question["Question"]
    embedQuestion["<b>Embed the question</b><br/>same model: 1 vector"]
    search["<b>Similarity search</b><br/>6 nearest chunks by cosine distance<br/>optional where filter on metadata"]
    prompt["<b>4 · Prompt</b><br/>ChatPromptTemplate:<br/>rules + numbered chunks + question"]
    llm["<b>LLM</b><br/>gpt-5-mini via OpenRouter"]
    answer["Answer, naming the artists it used"]
    question --> embedQuestion --> search --> prompt --> llm --> answer
    question -.-> prompt
  end

  store --> search
  store -. "5 · Visualize: all vectors, t-SNE to 2D" .-> map["output/embedding-map.html"]

The same thing as a UML sequence diagram for one npm run 4-rag run: who calls whom, and in what order. The alt block only happens on the first run.

sequenceDiagram
  actor You
  participant Script as 4-rag.ts
  participant Chroma as Chroma server :8001
  participant Embed as OpenRouter embeddings
  participant LLM as OpenRouter gpt-5-mini

  You->>Script: npm run 4-rag -- "question"
  Script->>Chroma: get or create collection, count chunks
  alt collection is empty (first run)
    Script->>Script: load CSVs, reshape, split into 179 chunks
    Script->>Embed: embed 179 chunk texts
    Embed-->>Script: 179 vectors of 1,536 numbers
    Script->>Chroma: upsert ids, vectors, texts, metadata
  end
  Script->>Embed: embed the question
  Embed-->>Script: 1 vector of 1,536 numbers
  Script->>Chroma: query 6 nearest vectors
  Chroma-->>Script: 6 chunks with distances
  Script->>Script: fill the prompt template
  Script->>LLM: system message (rules + chunks) and question
  LLM-->>Script: answer text
  Script-->>You: print chunks, prompt and answer

Persistence

The first stage 3 or 4 run that finds the music-artists collection empty embeds the 179 chunks and stores them. Later runs reuse the stored vectors and only embed the query. Chunk ids are stable (<artist_mbid>-<n>), so re-adding a chunk updates it instead of duplicating it.

Stored vectors don’t change when the code does. After changing the loader, splitter or embedding model, run npm run reindex. Otherwise you’re searching vectors from the old pipeline.

Models: openai/text-embedding-3-small and openai/gpt-5-mini, both through OpenRouter.

Things the output shows

The genre families on the map are regex matches on MusicBrainz tags (GENRES in src/5-visualize.ts), so they’re approximate. An artist can be in several families, and the tags are crowd-sourced.

What the first evaluation showed

From the run on 2026-10-08 (13 questions; answers by gpt-5-mini, judged by gemini-2.5-flash): context recall 0.90, context precision 1.00, faithfulness 0.97, answer relevancy 0.63, correctness 0.83. It refused exactly when it should on 13 of 13.

The judge would ideally be Claude, a different model family from the answering model. This OpenRouter workspace’s guardrails block Anthropic models, so the judge is Gemini (JUDGE_MODEL in src/lib/evaluate.ts).

Capping chunks per artist

The first evaluation showed that every retrieval miss came from one artist filling several of the 6 slots. Stage 7 can now cap that: fetch 30 candidates, then keep the nearest ones while allowing at most 2 (two-per-artist) or 1 (one-per-artist) chunks per artist. All four runs below were made on 2026-10-08 with the same 13 questions and the same judge. top-k was run twice to see how much the scores move with nothing changed.

Run Recall Faithfulness Relevancy Correctness Refusal right
top-k (a) 0.90 0.97 0.63 0.83 13/13
top-k (b) 0.90 1.00 0.66 0.83 13/13
two-per-artist 0.96 1.00 0.69 0.89 12/13
one-per-artist 1.00 0.90 0.66 0.83 12/13

Re-ranking

The rerank strategy fetches the 30 nearest chunks, the same pool the per-artist caps use, and has an LLM (gpt-4o-mini, a different model from both the answering model and the judge) score each one 0–3 for how useful it is for the question. The 6 highest-scoring chunks are kept; ties keep their search order. Two runs on 2026-10-09:

Run Recall Faithfulness Relevancy Correctness Refusal right
top-k (a) 0.90 0.97 0.63 0.83 13/13
two-per-artist 0.96 1.00 0.69 0.89 12/13
rerank (a) 0.93 1.00 0.67 0.89 13/13
rerank (b) 0.93 1.00 0.63 0.89 11/13

Re-ranking: scale, cap, temperature, or the batch?

Open the experiment report

To test the ties explanation, runs on 2026-10-09 crossed two score scales (0–3 and 0–100) with and without a cap of 2 chunks per artist after re-ranking, first at the re-ranker’s default temperature (1), then at 0. Every run saved the re-ranker’s score for all 30 candidates. npm run 9-rerank-consistency then scored the same candidates 3 times per setting, and scored the expected artists’ profile chunks on their own. Before all this, yes/no token probabilities were tried as a continuous score, in the spirit of probability-based re-rankers, but gpt-4o-mini answered “true” with near-certainty for every relevant chunk, so they tied too.

Variant Recall (temp 1 / 0) Correctness (temp 1 / 0) Stevie Wonder kept (temp 1 / 0)
0–3 0.93 / 0.90 0.89 / 0.83 no / no
0–3, cap 2 0.93 / 0.96 0.89 / 0.89 no / yes
0–100 0.93 / 0.93 0.89 / 0.89 no / no
0–100, cap 2 0.96 / 0.96 0.94 / 0.89 yes / yes
two-per-artist (no re-ranking) 0.96 0.89 yes

Long context: no retrieval at all

All 51 artist profiles come to about 111,000 characters (roughly 28k tokens), which fits easily in gpt-5-mini’s context. The long-context strategy skips the search and puts every profile in the prompt, in the CSV’s order, with the same prompt, model and judge. It’s the baseline: what does retrieval add? Two runs on 2026-10-09, next to the earlier four:

Run Recall Faithfulness Relevancy Correctness Refusal right
top-k (a) 0.90 0.97 0.63 0.83 13/13
top-k (b) 0.90 1.00 0.66 0.83 13/13
two-per-artist 0.96 1.00 0.69 0.89 12/13
one-per-artist 1.00 0.90 0.66 0.83 12/13
long-context (a) – 1.00 0.58 1.00 12/13
long-context (b) – 1.00 0.78 0.89 13/13

Retrieval isn’t scored for long-context: with every artist in the prompt, recall is 1 by definition.