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.
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:
npm install. .npmrc sets legacy-peer-deps, because an optional peer of @langchain/community (@getzep/zep-cloud) still pins @langchain/core 0.3..env.example to .env and add an OpenRouter key. Stages 1 and 2 run without one.npm run chroma in a second terminal. It stores vectors in ./chroma-data and listens on port 8001, so it doesn’t clash with another Chroma server on the default port 8000.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. |
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
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.
Artist: X (continued) header, neither the embedding nor the LLM knows whose albums they are.gender, country) plus a semantic query.Artist: X (continued) header ties them together. Genre shows up a level above that: on the map, artists with hip hop tags occupy one region.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.
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).
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 |
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 |
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 |
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.