Build: The Brute-Force Retriever + Wiring it as a DAG Node
Maps to: Task 5.2 (the search half) + 5.3. Kind: Build — spec and test names only. No implementation.
Objective
Implement the read side of semantic retrieval — cosine, EmbeddedRow's index, and VectorIndex::top_k — and then wire the embed step into the pipeline as something the orchestrator schedules: the embed subcommand today, a DAG node downstream of spacedevs in a fuller build. This is where the vectors written last chapter become answers to queries.
Scaffold
Create/extend: crates/etl-core/src/vector.rs (add cosine, VectorIndex, top_k, VectorIndex::from_jsonl). Modify: crates/panoptes-etl/src/cli.rs (add the Embed subcommand) and crates/panoptes-etl/src/main.rs (the embed_launches wiring).
Dependencies: none new. serde/serde_json, tokio, clap are all present from earlier arcs.
Expected result: cargo test -p etl-core cosine / top_k → the index tests pass; cargo test -p panoptes-etl → the CLI parses the new subcommand.
The spec (givens) — the index
pub fn cosine(a: &[f32], b: &[f32]) -> f32— dot product over the L2 norms; return0.0if either norm is zero (never divide by zero). Identical vectors score1.0; orthogonal vectors score0.0.VectorIndex { pub rows: Vec<EmbeddedRow> }—new(rows),Default, andfrom_jsonl(text)(oneEmbeddedRowper non-blank line; a parse failure maps toEtlError::Parse). This is the whole "database": aVec, in memory.top_k(&self, query: &[f32], k: usize) -> Vec<(String, f32)>— score every row's vector againstquerywithcosine, sort descending by score (usef32::total_cmp—f32is notOrd), truncate tok, return(id, score)pairs. Highest similarity first.
total_cmp, not partial_cmp().unwrap()
f32 has no total order because of NaN, so it is not Ord — sort_by needs a total comparator. partial_cmp().unwrap() works right up until a NaN score panics the sort mid-run. total_cmp is a genuine total order over all f32 bit patterns, so the sort is always well-defined. Reach for it any time you sort floats.
The spec (givens) — wiring the step
Embedsubcommand — add anEmbedvariant to theclapCommandenum with a doc comment ("Embed the prose fields of already-loaded launches into a vector index"), and a match arm inmainthat callsembed_launches(&cli.out_dir)and prints the count of new prose fields embedded.embed_launches(out_dir)— readout_dir/launches.jsonlintoVec<Row>(error out if empty — "runpanoptes-etl runfirst"); read the existingout_dir/embeddings.jsonlfor already-embedded ids; buildVoyageEmbedder::from_env("https://api.voyageai.com"); constructEmbedSink::with_seen(embedder, index_path, seen_ids);loadthe rows and return the count.read_embedded_ids(path)— parse each line asEmbeddedRow, collect theids; a missing file yields an emptyVec(a first run has nothing embedded yet).
spacedevs having run. Shipping it as its own subcommand keeps the dependency explicit and manual for now: you run run, then embed. Promoting it to a real node — a Pipeline whose "source" reads launches.jsonl and whose "sink" is the EmbedSink, added to the executor after spacedevs — changes no seam: the Sink trait already fits. The order (sources first, embed after) is the edge; the executor's topological sort is what would enforce it.
Test names to hit
cosine_of_identical_is_one_orthogonal_is_zero— assertcosineof a vector with itself is1.0(within a small epsilon) and of two orthogonal vectors is~0.0. Pins the two anchor points of the metric.top_k_ranks_by_similarity— build aVectorIndexof three rows (one near the query, one opposite, one in between);top_k(query, 2); assert the result has length2, the first id is the nearest row, and scores are non-increasing (hits[0].1 >= hits[1].1).embed_node_runs_after_sources(integration-style) /cli_parses_embed_subcommand— assertCli::try_parse_from(["panoptes-etl", "embed"])yieldsCommand::Embed, and (fuller build) that the executor schedules the embed node only afterspacedevssucceeds.
top_k_ranks_by_similarity, with query [1.0, 0.0] and rows near=[1.0, 0.1], far=[-1.0, 0.0], mid=[0.5, 0.5]: write down the order top_k(query, 2) returns before running. If far appears in your top 2, you sorted ascending — the highest cosine must come first.
The build loop (you drive)
- Write
cosine_of_identical_is_one_orthogonal_is_zero, implementcosinewith the zero-norm guard. - Write
top_k_ranks_by_similarity. Predict the ranking, then implementtop_kwithtotal_cmp+truncate. - Add the
Embedsubcommand andcli_parses_embed_subcommand; wireembed_launches/read_embedded_idsinmain. - Run green, commit. Optionally: promote the embed step to a real DAG node downstream of
spacedevsand prove ordering.
Done when
cargo test -p etl-core covers cosine/top_k and cargo test -p panoptes-etl covers the subcommand. You can now run panoptes-etl run to populate launches.jsonl, then panoptes-etl embed to turn its prose into a deduplicated vector index, and VectorIndex::top_k answers "what launches are most like this query vector?" — the semantic half of retrieval, standing beside the structured filters, exactly as the split promised. That completes the four seams and the RAG arc: the pipeline now extracts, transforms, loads, schedules, and retrieves.