<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Part 3: Memory and Retrieval | Garage Education</title><link>https://www.garageeducation.org/docs/agent-harness-spring-ai/03-memory-and-retrieval/</link><atom:link href="https://www.garageeducation.org/docs/agent-harness-spring-ai/03-memory-and-retrieval/index.xml" rel="self" type="application/rss+xml"/><description>Part 3: Memory and Retrieval</description><generator>HugoBlox Kit (https://hugoblox.com)</generator><language>en-US</language><copyright>©</copyright><lastBuildDate>Sat, 10 Oct 2026 00:00:00 +0000</lastBuildDate><image><url>https://www.garageeducation.org/media/logo.svg</url><title>Part 3: Memory and Retrieval</title><link>https://www.garageeducation.org/docs/agent-harness-spring-ai/03-memory-and-retrieval/</link></image><item><title>Chapter 10: Short-term memory with Spring AI Session</title><link>https://www.garageeducation.org/docs/agent-harness-spring-ai/03-memory-and-retrieval/10-short-term-memory-with-spring-ai-session/</link><pubDate>Sat, 10 Oct 2026 00:00:00 +0000</pubDate><guid>https://www.garageeducation.org/docs/agent-harness-spring-ai/03-memory-and-retrieval/10-short-term-memory-with-spring-ai-session/</guid><description>&lt;p&gt;Spring AI Session stores every message and tool call in PostgreSQL. Spring AI&amp;rsquo;s JDBC chat memory drops tool call messages, so this course does not use it. The workflow id is the session id.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;appendMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;UserMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;figure class="arch"&gt;
&lt;script type="application/json"&gt;
{
"title": "Full history in the database, a chosen window to the model.",
"nodes": [
{"id": "pgb", "type": "boundary", "x": 260, "y": 0, "w": 260, "h": 260, "label": "PostgreSQL"},
{"id": "harness", "x": 0, "y": 40, "w": 200, "label": "Harness", "detail": "session id = workflow id", "tone": "platform"},
{"id": "session", "x": 280, "y": 40, "w": 220, "label": "Spring AI Session", "detail": "every message and tool call", "tone": "data"},
{"id": "jdbc", "x": 280, "y": 170, "w": 220, "label": "JDBC chat memory", "detail": "drops tool calls · not used", "tone": "isolated"},
{"id": "window", "x": 580, "y": 40, "w": 190, "label": "Context window", "detail": "a separate choice", "tone": "platform"},
{"id": "model", "x": 580, "y": 170, "w": 190, "label": "Model", "tone": "ai"}
],
"edges": [
{"from": "harness", "to": "session", "label": "appendMessage"},
{"from": "session", "to": "window", "label": "getMessages"},
{"from": "window", "to": "model", "label": "prompt", "animated": true}
]
}
&lt;/script&gt;
&lt;figcaption&gt;Figure 10.1. The database keeps everything. The harness decides how much of it to send. The figure leaves out how the window is chosen; the chapter leaves that open.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The full history stays in the database. The window sent to the model is a separate choice. Verify the bean name and the &lt;code&gt;create&lt;/code&gt; signature in the Session README.&lt;/p&gt;</description></item><item><title>Chapter 11: Embeddings and hybrid storage in PostgreSQL</title><link>https://www.garageeducation.org/docs/agent-harness-spring-ai/03-memory-and-retrieval/11-embeddings-and-hybrid-storage/</link><pubDate>Sat, 10 Oct 2026 00:00:00 +0000</pubDate><guid>https://www.garageeducation.org/docs/agent-harness-spring-ai/03-memory-and-retrieval/11-embeddings-and-hybrid-storage/</guid><description>&lt;p&gt;Use one PostgreSQL database with VectorChord for vectors, and its BM25 extension for keywords. The table holds the text, the metadata, the embedding, and the keyword vector.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IF&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;EXISTS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;corpus&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;doc_type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;emb&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bm25&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bm25vector&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;BGE-M3 runs in LM Studio and produces 1024-dimension vectors. Split documents into chunks of about 1200 characters with 150 overlapping. Embed each chunk, then insert it. A startup check confirms the vector size matches the table.&lt;/p&gt;
&lt;figure class="arch"&gt;
&lt;script type="application/json"&gt;
{
"title": "Ingest: chunk, embed, insert.",
"nodes": [
{"id": "docs", "x": 0, "y": 40, "w": 150, "label": "Documents", "detail": "for example runbooks", "tone": "external"},
{"id": "chunk", "x": 220, "y": 40, "w": 170, "label": "Chunker", "detail": "~1200 chars, 150 overlap", "tone": "platform"},
{"id": "bge", "x": 460, "y": 40, "w": 170, "label": "BGE-M3", "detail": "in LM Studio · 1024 dims", "tone": "ai"},
{"id": "corpus", "x": 700, "y": 40, "w": 200, "label": "corpus table", "detail": "text, metadata, emb, bm25", "tone": "data"},
{"id": "check", "x": 700, "y": 170, "w": 200, "label": "Startup check", "detail": "vector size = vector(1024)", "tone": "muted"}
],
"edges": [
{"from": "docs", "to": "chunk", "label": "split"},
{"from": "chunk", "to": "bge", "label": "each chunk"},
{"from": "bge", "to": "corpus", "label": "insert"},
{"from": "check", "to": "corpus", "label": "compares", "dashed": true}
]
}
&lt;/script&gt;
&lt;figcaption&gt;Figure 11.1. The ingest path for one document. The figure leaves out how the &lt;code&gt;bm25vector&lt;/code&gt; column is filled; see the Verify note below.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class="sketch"&gt;
&lt;svg viewBox="0 0 880 150" role="img" aria-label="Three chunks of about 1200 characters, each overlapping the next by 150"&gt;
&lt;style&gt;.ov{fill:rgba(46,196,182,.22);stroke:none}.chunk{fill:#fff;stroke:#0d2238;stroke-width:1.5}.sk-dim{fill:#526577;font:12px "Public Sans",system-ui,sans-serif}&lt;/style&gt;
&lt;rect class="chunk" x="20" y="20" width="320" height="36" rx="6"/&gt;
&lt;rect class="chunk" x="280" y="66" width="320" height="36" rx="6"/&gt;
&lt;rect class="chunk" x="540" y="20" width="320" height="36" rx="6"/&gt;
&lt;rect class="ov" x="280" y="20" width="60" height="82"/&gt;
&lt;rect class="ov" x="540" y="20" width="60" height="82"/&gt;
&lt;text class="sk-label" x="34" y="43"&gt;chunk 1 · ~1200 chars&lt;/text&gt;
&lt;text class="sk-label" x="400" y="89"&gt;chunk 2 · ~1200 chars&lt;/text&gt;
&lt;text class="sk-label" x="620" y="43"&gt;chunk 3 · ~1200 chars&lt;/text&gt;
&lt;text class="sk-dim" x="282" y="125"&gt;150 shared&lt;/text&gt;
&lt;text class="sk-dim" x="542" y="125"&gt;150 shared&lt;/text&gt;
&lt;/svg&gt;
&lt;figcaption&gt;Figure 11.2. Overlap keeps a sentence that crosses a chunk border searchable from both sides. Not to scale.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;&lt;strong&gt;Verify:&lt;/strong&gt; Create the BM25 index with the statement in the VectorChord-BM25 repository. The reference article does not show it.&lt;/p&gt;
&lt;/blockquote&gt;</description></item><item><title>Chapter 12: Hybrid retrieval inside the loop</title><link>https://www.garageeducation.org/docs/agent-harness-spring-ai/03-memory-and-retrieval/12-hybrid-retrieval-inside-the-loop/</link><pubDate>Sat, 10 Oct 2026 00:00:00 +0000</pubDate><guid>https://www.garageeducation.org/docs/agent-harness-spring-ai/03-memory-and-retrieval/12-hybrid-retrieval-inside-the-loop/</guid><description>&lt;p&gt;Run two searches and merge them. The vector search returns the 20 closest chunks. The keyword search returns the 20 best BM25 matches. Reciprocal Rank Fusion merges the two lists by rank. Each chunk scores the sum of 1 divided by 60 plus its rank in each list.&lt;/p&gt;
&lt;figure class="arch"&gt;
&lt;script type="application/json"&gt;
{
"title": "Two searches, one fused list.",
"nodes": [
{"id": "q", "x": 0, "y": 90, "w": 150, "label": "Search tool call", "detail": "from the model", "tone": "platform"},
{"id": "vec", "x": 220, "y": 20, "w": 190, "label": "Vector search", "detail": "20 closest chunks", "tone": "data"},
{"id": "kw", "x": 220, "y": 160, "w": 190, "label": "Keyword search", "detail": "20 best BM25 matches", "tone": "data"},
{"id": "rrf", "x": 470, "y": 90, "w": 200, "label": "Reciprocal Rank Fusion", "detail": "score = Σ 1 / (60 + rank)", "tone": "platform"},
{"id": "model", "x": 720, "y": 90, "w": 150, "label": "Model", "detail": "cites each claim", "tone": "ai"}
],
"edges": [
{"from": "q", "to": "vec", "label": "embed query", "fromSide": "right", "toSide": "left"},
{"from": "q", "to": "kw", "label": "keywords", "fromSide": "right", "toSide": "left"},
{"from": "vec", "to": "rrf", "label": "ranks", "fromSide": "right", "toSide": "left"},
{"from": "kw", "to": "rrf", "label": "ranks", "fromSide": "right", "toSide": "left"},
{"from": "rrf", "to": "model", "label": "tool result"}
]
}
&lt;/script&gt;
&lt;figcaption&gt;Figure 12.1. Hybrid retrieval. RRF uses rank positions, not raw scores, so a vector distance and a BM25 score never need to be compared. Chapter 13 adds filters and a reranker.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;&lt;strong&gt;Worked example of the RRF formula.&lt;/strong&gt; A chunk ranked 1st in the vector list and 3rd in the keyword list scores 1/61 + 1/63 ≈ 0.0323. A chunk ranked 2nd in the vector list only scores 1/62 ≈ 0.0161. Appearing in both lists wins.&lt;/p&gt;
&lt;p&gt;Expose the merged search as a tool. Require a citation for each claim. If the answer has no citation and does not say no match, send one correction. Stop if it still has none.&lt;/p&gt;
&lt;figure class="arch"&gt;
&lt;script type="application/json"&gt;
{
"title": "The citation check, with one correction.",
"nodes": [
{"id": "ans", "x": 0, "y": 60, "w": 150, "label": "Model answer", "tone": "ai"},
{"id": "c1", "x": 190, "y": 60, "w": 190, "label": "Cited, or says no match?", "tone": "platform"},
{"id": "fix", "x": 190, "y": 190, "w": 190, "label": "Send one correction", "tone": "platform"},
{"id": "c2", "x": 420, "y": 190, "w": 190, "label": "Cited, or says no match?", "tone": "platform"},
{"id": "ok", "x": 650, "y": 60, "w": 150, "label": "Accept the answer", "tone": "platform"},
{"id": "stop", "x": 650, "y": 190, "w": 150, "label": "Stop the run", "tone": "muted"}
],
"edges": [
{"from": "ans", "to": "c1"},
{"from": "c1", "to": "ok", "label": "yes"},
{"from": "c1", "to": "fix", "label": "no"},
{"from": "fix", "to": "c2", "label": "new answer"},
{"from": "c2", "to": "ok", "label": "yes", "fromSide": "top", "toSide": "bottom"},
{"from": "c2", "to": "stop", "label": "no"}
]
}
&lt;/script&gt;
&lt;figcaption&gt;Figure 12.2. One correction, never a second. The chapter does not name the stop reason for an uncited answer.&lt;/figcaption&gt;
&lt;/figure&gt;</description></item><item><title>Chapter 13: Retrieval quality</title><link>https://www.garageeducation.org/docs/agent-harness-spring-ai/03-memory-and-retrieval/13-retrieval-quality/</link><pubDate>Sat, 10 Oct 2026 00:00:00 +0000</pubDate><guid>https://www.garageeducation.org/docs/agent-harness-spring-ai/03-memory-and-retrieval/13-retrieval-quality/</guid><description>&lt;p&gt;Use metadata filters for service and document type. Return &lt;code&gt;NO_MATCH&lt;/code&gt; when the best vector distance is above a threshold you set from the eval set. Then rerank the fused top 20 with the cross-encoder &lt;code&gt;bge-reranker-v2-m3&lt;/code&gt;, served over REST, and keep the top 4.&lt;/p&gt;
&lt;figure class="arch"&gt;
&lt;script type="application/json"&gt;
{
"title": "The full retrieval pipeline.",
"nodes": [
{"id": "filter", "x": 0, "y": 40, "w": 170, "label": "Metadata filters", "detail": "service, doc_type", "tone": "platform"},
{"id": "hybrid", "x": 210, "y": 40, "w": 170, "label": "Hybrid search + RRF", "detail": "Chapter 12", "tone": "data"},
{"id": "thr", "x": 420, "y": 40, "w": 190, "label": "Best distance ≤ threshold?", "detail": "set from the eval set", "tone": "platform"},
{"id": "nomatch", "x": 420, "y": 180, "w": 190, "label": "NO_MATCH", "tone": "muted"},
{"id": "rerank", "x": 650, "y": 40, "w": 190, "label": "Reranker", "detail": "bge-reranker-v2-m3 · REST", "tone": "ai"},
{"id": "top4", "x": 650, "y": 180, "w": 190, "label": "Top 4 chunks", "detail": "to the model", "tone": "platform"},
{"id": "log", "x": 880, "y": 40, "w": 140, "label": "Step log", "detail": "rerank duration", "tone": "muted"}
],
"edges": [
{"from": "filter", "to": "hybrid"},
{"from": "hybrid", "to": "thr", "label": "fused top 20"},
{"from": "thr", "to": "nomatch", "label": "no"},
{"from": "thr", "to": "rerank", "label": "yes"},
{"from": "rerank", "to": "top4", "label": "reorder"},
{"from": "rerank", "to": "log", "dashed": true}
]
}
&lt;/script&gt;
&lt;figcaption&gt;Figure 13.1. Retrieval from filter to the four chunks the model reads. The reranker adds latency, so its duration goes to the step log from Chapter 19.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The reference article reports that the cross-encoder was best on all four datasets it tested (Source: VectorChord blog, 2025). Test the gain on your own eval set. The reranker adds latency, so log its duration.&lt;/p&gt;
&lt;p&gt;PostgreSQL cannot run a neural reranker. A ColBERT rerank can run in SQL, but it needs a separate token encoder service, so this course does not use it.&lt;/p&gt;</description></item></channel></rss>