Context Selection Pipeline
Context Selection Pipeline
Before every agent turn, SideCar assembles a system prompt that includes retrieved context relevant to the user’s query. The retrieval layer fuses three independent sources — documentation, agent memory, and the workspace itself — under a single shared budget using reciprocal-rank fusion (RRF). This keeps one noisy source from dominating the limited context window.
Assembly flow
flowchart TD
Turn[New agent turn] --> Inject[injectSystemContext<br/>src/webview/handlers/systemPrompt.ts]
Inject --> Assemble[Assemble retrievers array]
Assemble --> DR[DocRetriever<br/>DocumentationIndexer]
Assemble --> MR[MemoryRetriever<br/>AgentMemory]
Assemble --> SR[SemanticRetriever<br/>WorkspaceIndex]
DR --> Fuse[fuseRetrievers query, topK, perSourceK]
MR --> Fuse
SR --> Fuse
Fuse --> ReadyFilter[filter r.isReady]
ReadyFilter --> Parallel[Promise.all<br/>retrieve per source]
Parallel --> RRF[reciprocalRankFusion<br/>1 / k+rank per list, sum]
RRF --> Slice[.slice 0, topK]
Slice --> Render[renderFusedContext<br/>markdown block]
Render --> Prompt[system prompt<br/>streamed to client]
classDef retrieverStyle fill:#dbeafe,stroke:#2563eb
classDef fuseStyle fill:#fef3c7,stroke:#d97706
class DR,MR,SR retrieverStyle
class RRF,Slice fuseStyle
Each retriever implements the Retriever interface in src/agent/retrieval/retriever.ts: isReady() and retrieve(query, k) → RetrievalHit[]. fuseRetrievers silently skips retrievers that aren’t ready, Promise.all’s the rest, catches per-retriever throws (so one failing source doesn’t kill the others), then feeds the ranked lists into RRF. The output is capped at topK hits and rendered as a single markdown block prepended to the system prompt.
Inside SemanticRetriever (the workspace source)
The workspace retriever has two paths — a v0.62+ symbol-level path that ships with PKI, and a legacy file-level path for when PKI isn’t wired. Knowing which path you’re on matters for understanding why a query surfaced what it did.
flowchart TD
Q[SemanticRetriever.retrieve query, k] --> PkiCheck{symbolEmbeddings<br/>wired + ready +<br/>count > 0?}
PkiCheck -- no --> FileLegacy[rankFiles query, activeFilePath<br/>heuristic + file-level embedding]
FileLegacy --> FileSlice[slice 0, k]
FileSlice --> FileLoad[loadFileContent each]
FileLoad --> FileTrunc[truncate to<br/>maxCharsPerFile default 3000]
FileTrunc --> FileHit["emit RetrievalHit<br/>id: workspace:path<br/>score: file.score"]
FileHit --> Done[return hits]
PkiCheck -- yes --> SymSearch[SymbolEmbeddingIndex.search<br/>query, k]
SymSearch --> Merkle{merkleTree<br/>attached?}
Merkle -- yes --> Descend[tree.descend queryVec,<br/>max 10, 3×k<br/>pick candidate file subtrees]
Descend --> ScoreLeaves[cosine-score only<br/>candidate leaves]
Merkle -- no --> ScoreAll[cosine-score every leaf]
ScoreLeaves --> TopK[top-k by similarity]
ScoreAll --> TopK
TopK --> SymLoad[loadFileContent per hit]
SymLoad --> SymSlice["sliceSymbolBody<br/>startLine-endLine"]
SymSlice --> SymTrunc[truncate to<br/>maxCharsPerSymbol default 1500]
SymTrunc --> SymHit["emit RetrievalHit<br/>id: workspace-sym:path::qname<br/>score: similarity"]
SymHit --> Done
classDef pkiStyle fill:#dcfce7,stroke:#16a34a
classDef legacyStyle fill:#fef3c7,stroke:#d97706
class SymSearch,Merkle,Descend,ScoreLeaves,TopK,SymLoad,SymSlice,SymTrunc,SymHit pkiStyle
class FileLegacy,FileSlice,FileLoad,FileTrunc,FileHit legacyStyle
Symbol path (PKI enabled, v0.62+):
- Every parsed symbol’s body is embedded (MiniLM, 384-dim) and stored in a
FlatVectorStorekeyed byfilePath::qualifiedName. - When
sidecar.merkleIndex.enabled(defaulttrue), a content-addressed Merkle tree sits over the vector store. Aggregated file-node embeddings letdescend(queryVec, k)pick candidate subtrees before scoring leaves, turningO(total symbols)cosine scans intoO(picked files × avg symbols per file). - Hit IDs use a
workspace-sym:prefix so the RRF fusion layer dedupes symbol hits independently from any legacy file-level hit that might still arrive from a parallel retriever in hybrid test setups. - Hit content renders as a fenced code block with the symbol’s line-range slice — a tighter “evidence unit” than a file head.
File path (legacy, pre-PKI or PKI unavailable):
WorkspaceIndex.rankFilesblends heuristic scoring (path-locality to the active file, file-name token overlap, recent-edit boost) with a file-level embedding over first-N-bytes MiniLM vectors.- Top-k file contents load, truncate to
maxCharsPerFile, and emit with aworkspace:ID prefix.
The retriever prefers symbol-level when available (PKI wired + ready + non-empty). Empty symbol search returns [] (not null) — that’s a PKI result, just a negative one, and the caller doesn’t fall through to file-level. The fall-through triggers only when PKI genuinely isn’t usable yet (still warming up, disabled, or getCount() === 0).
Reciprocal-rank fusion
flowchart LR
subgraph lists ["Per-retriever ranked lists"]
direction TB
Dl[Doc: D1, D2, D3]
Ml[Mem: M1, M2, M3]
Wl[Work: W1, W2, W3]
end
lists --> RRF["score hit = Σ 1 / k + rank_in_list <br/>k = 60"]
RRF --> Sort[sort desc by score]
Sort --> Dedup[dedupe by hit.id<br/>keep highest score]
Dedup --> Out[topK hits across all sources]
classDef formulaStyle fill:#fef3c7,stroke:#d97706
class RRF formulaStyle
RRF works well for this problem because it only needs ordinal rank from each source, not comparable score distributions. A doc retriever scoring in [0, 1] cosine similarity and a memory retriever scoring in arbitrary BM25 units both contribute the same way — rank 1 in each list is worth 1 / (60 + 1), rank 2 is 1 / (60 + 2), and so on. The k=60 constant is standard (from the IR literature) and is not tunable from config; changing it would require re-baselining the RAG-eval suite.
Budget + sizing knobs
- Per-source K — each retriever is asked for
perSourceKitems (default:topK). The fusion layer then pickstopKacross the union. Giving each source extra headroom means a weaker retriever can still contribute lower-ranked items when the stronger one also has matches. - Char caps per hit:
- Symbol hits:
sidecar.projectKnowledge.maxCharsPerSymbol(default 1500). - File hits:
SemanticRetriever.maxCharsPerFile(default 3000). - Doc hits + memory hits: each source owns its own truncation upstream.
- Symbol hits:
- System-prompt budget — injected context is capped at the system block’s overall byte cap so it can’t crowd out the conversation history. When the combined hits exceed the cap, the fusion output is sliced in order — highest-ranked hits survive.
Observability
The RAG-eval harness at src/test/retrieval-eval/ runs every golden case through the metrics suite (contextPrecisionAtK, contextRecallAtK, f1ScoreAtK, reciprocalRank) and a CI ratchet in baseline.test.ts gates retrieval quality against floor thresholds. An LLM-as-judge layer at tests/llm-eval/retrieval.eval.ts runs under npm run eval:llm and adds Faithfulness + AnswerRelevancy scoring.
Source layout
| File | Role |
|---|---|
src/agent/retrieval/index.ts |
fuseRetrievers + renderFusedContext — the public fusion entrypoint |
src/agent/retrieval/retriever.ts |
Retriever interface + RetrievalHit type |
src/agent/retrieval/fusion.ts |
reciprocalRankFusion — pure function, side-effect-free |
src/agent/retrieval/docRetriever.ts |
Wraps DocumentationIndexer |
src/agent/retrieval/memoryRetriever.ts |
Wraps AgentMemory |
src/agent/retrieval/semanticRetriever.ts |
Wraps WorkspaceIndex; branches on PKI readiness |
src/config/symbolEmbeddingIndex.ts |
SymbolEmbeddingIndex — symbol-level vector store + optional Merkle descent |
src/config/merkleTree.ts |
Content-addressed hash tree over symbol leaves |
src/config/vectorStore.ts |
VectorStore<M> interface + FlatVectorStore<M> |
src/config/workspaceIndex.ts |
File-level index used by the legacy path |
src/webview/handlers/systemPrompt.ts |
injectSystemContext — the caller that assembles retrievers and renders into the prompt |