<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Langchain4j on Vitalii Sukach</title><link>https://sukachvitally.github.io/tags/langchain4j/</link><description>Recent content in Langchain4j on Vitalii Sukach</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Sat, 17 Oct 2026 13:01:45 +0000</lastBuildDate><atom:link href="https://sukachvitally.github.io/tags/langchain4j/index.xml" rel="self" type="application/rss+xml"/><item><title>RAG in Java with LangChain4j: Teaching a Local LLM About a Company That Doesn’t Exist</title><link>https://sukachvitally.github.io/posts/rag-in-java-with-langchain4j-teaching-a-local-llm-about-a-company-that-doesnt-exist/</link><pubDate>Sat, 17 Oct 2026 13:01:45 +0000</pubDate><guid>https://sukachvitally.github.io/posts/rag-in-java-with-langchain4j-teaching-a-local-llm-about-a-company-that-doesnt-exist/</guid><description>&lt;p>&lt;em>Chunks, vectors, a bit of geometry, and no magic. Everything runs on your laptop.&lt;/em>&lt;/p>
&lt;p>&lt;img loading="lazy" src="https://sukachvitally.github.io/posts/rag-in-java-with-langchain4j-teaching-a-local-llm-about-a-company-that-doesnt-exist/image-01.png">&lt;/p>
&lt;p>Ask an LLM “When does the nightly settlement batch run at Harbourline Payments?” and one of two things happens.&lt;/p>
&lt;p>The polite model says it doesn’t know. The confident model says “Usually around midnight!” with the energy of a student who didn’t read the book but really wants a good mark.&lt;/p>
&lt;p>Both are fair, because Harbourline Payments doesn’t exist. I made it up. It’s a fictional company with a fictional engineering handbook: founders, services, deploy rules, on-call pay, and even a famous incident called “the Double Tap”. No model has seen this text during training.&lt;/p></description><content:encoded><![CDATA[<p><em>Chunks, vectors, a bit of geometry, and no magic. Everything runs on your laptop.</em></p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/rag-in-java-with-langchain4j-teaching-a-local-llm-about-a-company-that-doesnt-exist/image-01.png"></p>
<p>Ask an LLM “When does the nightly settlement batch run at Harbourline Payments?” and one of two things happens.</p>
<p>The polite model says it doesn’t know. The confident model says “Usually around midnight!” with the energy of a student who didn’t read the book but really wants a good mark.</p>
<p>Both are fair, because Harbourline Payments doesn’t exist. I made it up. It’s a fictional company with a fictional engineering handbook: founders, services, deploy rules, on-call pay, and even a famous incident called “the Double Tap”. No model has seen this text during training.</p>
<p>That’s exactly why it’s a good test. If the model answers “02:30 UK time” correctly, it didn’t remember it. It <strong>found</strong> it. That’s RAG.</p>
<p>In this article we’ll build a small RAG service in Java with Spring Boot, LangChain4j, PostgreSQL with pgvector, and Ollama. No cloud, no API keys, no bill at the end of the month.</p>
<p>The full code is on GitHub: <a href="https://github.com/SukachVitally/rag-demo">SukachVitally/rag-demo</a>.</p>
<h3 id="what-is-rag-in-oneexam">What is RAG, in one exam</h3>
<p>RAG stands for <strong>Retrieval-Augmented Generation</strong>. Long name, simple idea.</p>
<p>Think about two exams:</p>
<ul>
<li><strong>Closed-book exam.</strong> The student answers from memory. This is a normal LLM. It knows a lot, but only what it learned during training. It knows nothing about your company’s wiki, last week’s incident, or your internal rules. If it doesn’t know, it may still try to sound smart.</li>
<li><strong>Open-book exam.</strong> Before answering, the student finds the right pages in the book and reads them. This is RAG.</li>
</ul>
<p>So RAG doesn’t make the model smarter. It gives the model a cheat sheet for each question: “Here are 5 pieces of text that look relevant. Answer using only these.”</p>
<p>Why not just put the whole handbook into the prompt? For one small file you can. But real knowledge bases have thousands of documents. They don’t fit into the context window, and even when they do, you pay for every token on every question. RAG sends only the few pieces that matter.</p>
<h3 id="the-bigidea">The big idea</h3>
<p>The whole thing is two flows.</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/rag-in-java-with-langchain4j-teaching-a-local-llm-about-a-company-that-doesnt-exist/image-02.png"></p>
<p><strong>Ingest (once per document):</strong></p>
<ol>
<li>Take the text and split it into small pieces called <strong>chunks</strong>.</li>
<li>Ask an <strong>embedding model</strong> to turn each chunk into a vector: a long list of numbers.</li>
<li>Save each chunk together with its vector in the database.</li>
</ol>
<p><strong>Ask (every question):</strong></p>
<ol>
<li>Ask the <strong>same</strong> embedding model to turn the question into a vector.</li>
<li>Find the chunks whose vectors are <strong>closest</strong> to the question vector.</li>
<li>Send the question and those chunks to a <strong>chat model</strong> and ask it to answer using only these chunks.</li>
</ol>
<p>That’s it. The rest of this article is details.</p>
<p>Notice that there are <strong>two different models</strong>:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/rag-in-java-with-langchain4j-teaching-a-local-llm-about-a-company-that-doesnt-exist/image-03.png"></p>
<p>The embedding model is small and fast. The chat model is the big one. Both run locally in Ollama.</p>
<h3 id="wait-what-is-thisvector">Wait, what is this vector?</h3>
<p>This is the most interesting part, so let’s slow down a little.</p>
<p>An embedding model reads a piece of text and returns a fixed-size array of numbers. nomic-embed-text returns <strong>768</strong> numbers for any text, whether it&rsquo;s one word or a full paragraph:</p>
<pre tabindex="0"><code>&#34;settlement batch runs at 02:30&#34;  →  [-0.064, 0.142, 0.009, ... 765 more ...]
</code></pre><p>The model is trained so that texts with <strong>similar meaning</strong> get vectors that point in a <strong>similar direction</strong>. “When does the nightly batch run?” and “settlement-service runs the nightly batch at 02:30” share almost no grammar, but their meaning is close, so their vectors are close too.</p>
<p>Now the fun part. It’s natural to think that each number means something: dimension 17 is “money”, dimension 302 is “time of day”, dimension 555 is “how angry the on-call engineer is”. It doesn’t work like that.</p>
<p><strong>No single dimension has a physical meaning.</strong> Nobody designed these axes. The model learned the layout by itself during training. Meaning is spread across all 768 numbers at once, a bit like a smell is spread across many different molecules. You can’t point at one number and say “this is the time part”. Only the vector as a whole means something, and only when you compare it with other vectors.</p>
<p>Here’s a 2D sketch of what that looks like. The real space has 768 dimensions, and I have only two axes on my screen:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/rag-in-java-with-langchain4j-teaching-a-local-llm-about-a-company-that-doesnt-exist/image-04.png"></p>
<p>The question vector points almost in the same direction as the “settlement batch runs at 02:30” chunk. The sourdough recipe chunk points somewhere else entirely. So for a computer, “similar meaning” becomes simple geometry: <strong>a small angle between two vectors</strong>.</p>
<p>The usual way to measure this is <strong>cosine similarity</strong>, the cosine of that angle:</p>
<ul>
<li>1: same direction, very similar</li>
<li>0: unrelated</li>
<li>-1: opposite direction</li>
</ul>
<p>LangChain4j turns it into a friendlier score from 0 to 1: (1 + cos θ) / 2. That&rsquo;s the score you&rsquo;ll see in the API response later.</p>
<p>One important rule: vectors from different embedding models are <strong>not compatible</strong>. Each model builds its own space with its own layout. If you change the embedding model, you have to re-embed all your documents. It’s like changing the map projection: the old coordinates don’t match the new map.</p>
<h3 id="the-stack">The stack</h3>
<ul>
<li><strong>Java 25</strong> and <strong>Spring Boot 4.1</strong>: the usual REST app.</li>
<li><strong>LangChain4j 1.22</strong>: the Java library that does the AI work.</li>
<li><strong>Ollama</strong>: runs both models locally.</li>
<li><strong>PostgreSQL 17 + pgvector</strong>: stores chunks and vectors, and searches by similarity.</li>
<li><strong>Testcontainers</strong>: for functional tests with a real database.</li>
</ul>
<p>Run the infrastructure with one command:</p>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/rag-demo/blob/main/docker-compose.yaml"><em>docker-compose.yaml</em></a></p>
<pre tabindex="0"><code>services:
  db:
    image: pgvector/pgvector:pg17
    environment:
      POSTGRES_DB: rag
      POSTGRES_USER: rag
      POSTGRES_PASSWORD: rag
    ports: [&#34;5432:5432&#34;]
  ollama:
    image: ollama/ollama
    ports: [&#34;11434:11434&#34;]
    volumes:
      - ollama-models:/root/.ollama   # keeps downloaded models between restarts
  ollama-init:
    image: ollama/ollama
    depends_on: [ollama]
    environment:
      OLLAMA_HOST: ollama:11434
    entrypoint: [&#34;sh&#34;, &#34;-c&#34;, &#34;sleep 5 &amp;&amp; ollama pull nomic-embed-text &amp;&amp; ollama pull llama3.1:8b&#34;]
volumes:
  ollama-models:
</code></pre><p>The ollama-init container is a one-off job that downloads the models. The first time it pulls a few GB, so it&rsquo;s a good moment to make some tea.</p>
<h3 id="langchain4j-ai-lego-forjava">LangChain4j: AI LEGO for Java</h3>
<p>LangChain4j is to AI roughly what Spring Data is to databases. It gives you small interfaces with many implementations, so your code doesn’t depend on a specific vendor.</p>
<p>The demo uses four building blocks, plus one helper that glues three of them together:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/rag-in-java-with-langchain4j-teaching-a-local-llm-about-a-company-that-doesnt-exist/image-05.png"></p>
<ul>
<li><strong>DocumentSplitter</strong> cuts text into chunks.</li>
<li><strong>EmbeddingModel</strong> turns text into a vector. Here it&rsquo;s OllamaEmbeddingModel.</li>
<li><strong><code>EmbeddingStore&lt;TextSegment&gt;</code></strong> saves vectors and finds the nearest ones. Here it&rsquo;s PgVectorEmbeddingStore.</li>
<li><strong>ChatModel</strong> sends a prompt and gets an answer. Here it&rsquo;s OllamaChatModel.</li>
<li><strong>EmbeddingStoreIngestor</strong> runs split → embed → store in one call.</li>
</ul>
<p>The nice part is the bottom of that picture. Want OpenAI instead of Ollama? Change one bean. Want Qdrant instead of Postgres? Change one bean. The services don’t care.</p>
<h3 id="dependencies">Dependencies</h3>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/rag-demo/blob/main/build.gradle"><em>build.gradle</em></a></p>
<pre tabindex="0"><code>dependencies {
    implementation &#39;org.springframework.boot:spring-boot-starter-jdbc&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-webmvc&#39;
    implementation &#39;dev.langchain4j:langchain4j:1.22.0&#39;
    implementation &#39;dev.langchain4j:langchain4j-ollama:1.22.0&#39;
    implementation &#39;dev.langchain4j:langchain4j-pgvector:1.22.0-beta32&#39;
    runtimeOnly &#39;org.postgresql:postgresql&#39;
}
</code></pre><p>Three LangChain4j modules: the core, the Ollama integration and the pgvector store. That’s all.</p>
<h3 id="wiring-thebeans">Wiring the beans</h3>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/rag-demo/blob/main/src/main/java/org/example/ragdemo/config/LangChain4jConfig.java"><em>LangChain4jConfig.java</em></a></p>
<pre tabindex="0"><code>@Configuration
public class LangChain4jConfig {
    public static final String CHUNK_TABLE = &#34;chunk_embedding&#34;;
    @Bean
    EmbeddingModel embeddingModel(OllamaClientProperty property) {
        return OllamaEmbeddingModel.builder()
                .baseUrl(property.baseUrl())
                .modelName(property.embedModel())
                .build();
    }
    @Bean
    ChatModel chatModel(OllamaClientProperty property) {
        return OllamaChatModel.builder()
                .baseUrl(property.baseUrl())
                .modelName(property.chatModel())
                .build();
    }
    @Bean
    DocumentSplitter documentSplitter(ChunkProperty property) {
        return DocumentSplitters.recursive(property.size(), property.overlap());
    }
    @Bean
    EmbeddingStore&lt;TextSegment&gt; embeddingStore(DataSource dataSource) {
        return PgVectorEmbeddingStore.datasourceBuilder()
                .datasource(dataSource)
                .table(CHUNK_TABLE)
                .createTable(false)
                .skipCreateVectorExtension(true)
                .metadataStorageConfig(DefaultMetadataStorageConfig.builder()
                        .storageMode(MetadataStorageMode.COMBINED_JSONB)
                        .columnDefinitions(List.of(&#34;metadata JSONB NULL&#34;))
                        .build())
                .build();
    }
}
</code></pre><p>The embedding store reuses Spring’s DataSource, so there&rsquo;s one connection pool and one config. I also told it <strong>not</strong> to create the table (createTable(false)) because I prefer to own the schema myself:</p>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/rag-demo/blob/main/src/main/resources/schema.sql"><em>schema.sql</em></a></p>
<pre tabindex="0"><code>CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE IF NOT EXISTS chunk_embedding (
    embedding_id UUID PRIMARY KEY,
    embedding    vector(768) NOT NULL,
    text         TEXT NULL,
    metadata     JSONB NULL
);
CREATE INDEX IF NOT EXISTS chunk_embedding_hnsw
    ON chunk_embedding USING hnsw (embedding vector_cosine_ops);
</code></pre><p>Two things to notice:</p>
<ul>
<li>vector(768) is the pgvector column type. 768 must match the embedding model&rsquo;s output. If you switch to a model with 1024 dimensions, this column has to change too.</li>
<li>The <strong>HNSW index</strong> with vector_cosine_ops makes nearest-neighbour search fast. Without it, Postgres compares the question with every row. That&rsquo;s fine for 9 chunks, but not for 9 million. HNSW is an approximate search: it trades a tiny bit of accuracy for a lot of speed.</li>
</ul>
<p>Everything is configurable in application.yaml:</p>
<pre tabindex="0"><code>app:
  olama-client:
    base-url: http://localhost:11434
    embed-model: nomic-embed-text
    chat-model: llama3.1:8b
  chunk:
    size: 1000
    overlap: 150
</code></pre><h3 id="part-1-ingest-or-feeding-thedatabase">Part 1: Ingest, or feeding the database</h3>
<p>Here’s what happens to a document on the way in:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/rag-in-java-with-langchain4j-teaching-a-local-llm-about-a-company-that-doesnt-exist/image-06.png"></p>
<h3 id="why-chunks">Why chunks?</h3>
<p>Why not embed the whole document as one vector? Because one vector for a whole handbook would be an average of everything: founders, deploy rules, on-call pay, the sourdough recipe someone accidentally uploaded. It would be “kind of about everything”, and that means it’s close to nothing.</p>
<p>Small chunks keep the meaning sharp. One chunk is about the settlement batch, another is about deploy windows. Now the search can find the right one.</p>
<p>The demo uses DocumentSplitters.recursive(1000, 150):</p>
<ul>
<li><strong>1000</strong>: the maximum chunk size in characters.</li>
<li><strong>150</strong>: overlap. Each chunk repeats about 150 characters from the end of the previous one. So if an important sentence lands on a border, it’s still complete in at least one chunk.</li>
<li><strong>Recursive</strong> means it tries to cut nicely. First by paragraphs. If a paragraph is too big, then by lines, then by sentences, then by words. It cuts the text like a polite butcher, not with a chainsaw.</li>
</ul>
<h3 id="the-ingestservice">The ingest service</h3>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/rag-demo/blob/main/src/main/java/org/example/ragdemo/service/IngestService.java"><em>IngestService.java</em></a></p>
<pre tabindex="0"><code>@Service
@RequiredArgsConstructor
public class IngestService {
    public static final String SOURCE = &#34;source&#34;;
    private final DocumentSplitter splitter;
    private final EmbeddingModel embeddingModel;
    private final EmbeddingStore&lt;TextSegment&gt; store;
    
    public int ingest(String source, String text) {
        if (text.isBlank()) return 0;
        var chunks = new AtomicInteger();
        EmbeddingStoreIngestor.builder()
                .documentSplitter(splitter)
                .textSegmentTransformer(segment -&gt; { chunks.incrementAndGet(); return segment; })
                .embeddingModel(embeddingModel)
                .embeddingStore(store)
                .build()
                .ingest(Document.from(text, Metadata.from(SOURCE, source)));
        return chunks.get();
    }
}
</code></pre><p>EmbeddingStoreIngestor does all three steps for us: split, embed, store. The textSegmentTransformer is a hook that runs for every chunk. Here I only use it to count chunks for the API response, but it&rsquo;s also the right place to clean text or add extra metadata.</p>
<p>The source metadata (the file name) travels with every chunk into the metadata JSONB column. Later it lets the answer say <em>where</em> the information came from.</p>
<p>The controller is a plain multipart upload:</p>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/rag-demo/blob/main/src/main/java/org/example/ragdemo/controller/RagController.java"><em>RagController.java</em></a></p>
<pre tabindex="0"><code>@PostMapping(value = &#34;/ingest&#34;, consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Map&lt;String, Object&gt; upload(@RequestParam MultipartFile file) throws IOException {
    String text = new String(file.getBytes(), StandardCharsets.UTF_8);
    return Map.of(&#34;chunks&#34;, ingest.ingest(file.getOriginalFilename(), text));
}
</code></pre><p>Let’s feed it the Harbourline handbook:</p>
<pre tabindex="0"><code>curl -F file=@docs/notes.md http://localhost:8080/api/ingest
</code></pre><pre tabindex="0"><code>{ &#34;chunks&#34;: 9 }
</code></pre><p>Nine chunks, nine vectors, nine rows. The database now knows about a company that doesn’t exist.</p>
<h3 id="part-2-ask-or-the-open-book-exam">Part 2: Ask, or the open-book exam</h3>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/rag-demo/blob/main/src/main/java/org/example/ragdemo/service/RagService.java"><em>RagService.java</em></a></p>
<pre tabindex="0"><code>@Service
@RequiredArgsConstructor
public class RagService {
    private final EmbeddingModel embeddingModel;
    private final EmbeddingStore&lt;TextSegment&gt; store;
    private final ChatModel chatModel;
    private static final String SYSTEM = &#34;&#34;&#34;
        Answer using ONLY the provided context. If the answer is not in the
        context, say you don&#39;t know. Cite sources as [1], [2]...&#34;&#34;&#34;;
    
public record Answer(String answer, List&lt;Hit&gt; sources) {}
    public Answer ask(String question) {
        var q = embeddingModel.embed(question).content();
        var hits = store.search(EmbeddingSearchRequest.builder().queryEmbedding(q).maxResults(5).build())
                .matches().stream()
                .map(m -&gt; new Hit(m.embedded().metadata().getString(IngestService.SOURCE), m.embedded().text(), m.score()))
                .toList();
        var ctx = new StringBuilder();
        for (int i = 0; i &lt; hits.size(); i++)
            ctx.append(&#34;[&#34;).append(i + 1).append(&#34;] (&#34;).append(hits.get(i).source()).append(&#34;)\n&#34;)
                    .append(hits.get(i).content()).append(&#34;\n\n&#34;);
        String prompt = &#34;Context:\n&#34; + ctx + &#34;\nQuestion: &#34; + question;
        return new Answer(chatModel.chat(SystemMessage.from(SYSTEM), UserMessage.from(prompt)).aiMessage().text(), hits);
    }
}
</code></pre><p>This is the heart of the project, and it follows the plan exactly:</p>
<ol>
<li><strong>Embed the question</strong> with the same model we used for the chunks. Same model means same space, so the distances make sense.</li>
<li><strong>Search</strong> the store for the 5 nearest vectors. Under the hood, pgvector uses the HNSW index and cosine distance.</li>
<li><strong>Build the context</strong>: number each chunk and add its source.</li>
<li><strong>Ask the chat model</strong> with a strict system message: use only the context, say “I don’t know” if the answer isn’t there, and cite sources.</li>
</ol>
<p>This is roughly what llama actually receives:</p>
<pre tabindex="0"><code>SYSTEM:
Answer using ONLY the provided context. If the answer is not in the
context, say you don&#39;t know. Cite sources as [1], [2]...
</code></pre><pre tabindex="0"><code>USER:
Context:
[1] (notes.md)
... settlement-service — runs the nightly settlement batch at 02:30 UK time ...
</code></pre><pre tabindex="0"><code>[2] (notes.md)
...
</code></pre><pre tabindex="0"><code>Question: When does the nightly settlement batch run?
</code></pre><p>The chat model never sees the database, the vectors or the other chunks. It sees a small, focused cheat sheet and a question. Its job is now easy: read and summarise.</p>
<p>Let’s try it:</p>
<pre tabindex="0"><code>curl -H &#39;Content-Type: application/json&#39; \
     -d &#39;{&#34;question&#34;: &#34;When does the nightly settlement batch run?&#34;}&#39; \
     http://localhost:8080/api/ask
</code></pre><pre tabindex="0"><code>{
  &#34;answer&#34;: &#34;According to the context in [3] (notes.md), the nightly settlement batch runs at 02:30 UK time.&#34;,
  &#34;sources&#34;: [
    { &#34;source&#34;: &#34;notes.md&#34;, &#34;content&#34;: &#34;...&#34;, &#34;score&#34;: 0.79 }
  ]
}
</code></pre><p>02:30 UK time. Correct, and with a source. Open-book exam passed. 🎓</p>
<p>The sources list matters as much as the answer. You can show it to users, so they can check where the answer came from instead of trusting the model blindly. Trust, but verify, especially with an 8B model.</p>
<h3 id="things-to-keep-inmind">Things to keep in mind</h3>
<p>The demo is small on purpose. Before you take it to production, think about these:</p>
<ul>
<li><strong>Chunk size is a trade-off.</strong> Too small, and a chunk loses context (“it runs at 02:30”, but what is “it”?). Too big, and the meaning gets blurry again. 1000/150 is a reasonable start, not a law.</li>
<li><strong>The top 5 are always returned,</strong> even when nothing is relevant. Ask about pizza, and you’ll still get the 5 “least irrelevant” chunks. The strict system prompt helps the model say “I don’t know”. In a real system I’d also add a minimum score (EmbeddingSearchRequest supports minScore).</li>
<li><strong>Changing the embedding model means re-ingesting everything.</strong> Different model, different space, different vector(N) column.</li>
<li><strong>Re-uploading the same file creates duplicates.</strong> The demo only appends. A real system needs to update or delete chunks by source.</li>
<li><strong>Garbage in, garbage out.</strong> RAG is only as good as the documents you give it. If the wiki is wrong, the answer will be wrong, but now with a citation.</li>
</ul>
<h3 id="summary">Summary</h3>
<p>RAG sounds like a fancy AI term, but in code it’s a short pipeline:</p>
<ul>
<li><strong>Ingest:</strong> split text into chunks → embed each chunk into a vector → store the text and vector in pgvector.</li>
<li><strong>Ask:</strong> embed the question → find the closest chunk vectors → give the question and those chunks to the chat model → answer with sources.</li>
</ul>
<p>The vectors are the only “magic” part. They are 768 numbers with no human meaning in any single dimension, but their direction captures meaning. Similar text means a small angle, and a small angle is something a database can search.</p>
<p>LangChain4j turns each step into one small interface, so the whole service is a handful of Spring beans. You can swap Ollama for OpenAI, or pgvector for Qdrant, without touching the business code.</p>
<p>And now a local 8B model knows when Harbourline’s settlement batch runs. Harbourline still doesn’t exist, but at least its batch is on time. ⏰</p>
<p>The full code is on GitHub: <a href="https://github.com/SukachVitally/rag-demo">SukachVitally/rag-demo</a>. Clone it, run docker compose up -d, upload your own notes and ask them questions.</p>
]]></content:encoded></item></channel></rss>