<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Yusuf Öztoprak — Backend & AI Notes]]></title><description><![CDATA[Yusuf Öztoprak — Backend & AI Notes]]></description><link>https://yusufoztoprak.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Mon, 02 Nov 2026 12:03:54 GMT</lastBuildDate><atom:link href="https://yusufoztoprak.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Swapping OpenAI for a Fake Embedder With One Environment Variable]]></title><description><![CDATA[A NestJS factory refactor that swaps OpenAI embeddings for a fake one with one env var, and three gotchas: DI tokens, TS1272 and pgvector.]]></description><link>https://yusufoztoprak.hashnode.dev/swapping-openai-for-a-fake-embedder-with-one-environment-variable</link><guid isPermaLink="true">https://yusufoztoprak.hashnode.dev/swapping-openai-for-a-fake-embedder-with-one-environment-variable</guid><category><![CDATA[nestjs]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[RAG ]]></category><category><![CDATA[design patterns]]></category><dc:creator><![CDATA[Yusuf Öztoprak]]></dc:creator><pubDate>Tue, 27 Oct 2026 11:18:21 GMT</pubDate><content:encoded><![CDATA[<p>There is a standard story about design patterns. You learn them from a catalogue, you memorize the diagrams, and later, in an interview, someone asks you to explain the Factory Method and you draw boxes labelled <code>Creator</code> and <code>ConcreteProduct</code>. The story is not exactly false. But it hides the more interesting question, which is not <em>what</em> a pattern is but <em>what problem in your own code it actually removes</em>. If you cannot answer that, you have learned a vocabulary, not a technique.</p>
<p>This week I tried to answer it for my own project. I am building <a href="https://github.com/YusufOztoprak/rag-service">rag-service</a>, a retrieval-augmented generation API in NestJS with PostgreSQL and pgvector. I had just read the creational patterns on refactoring.guru, written the toy examples, and felt reasonably confident. Then I tried to apply one of them to real code, and it took me much longer than the toy version did. That gap is what this post is about.</p>
<h2>The problem, stated plainly</h2>
<p>Here is what my embedding code looked like:</p>
<pre><code class="language-ts">@Injectable()
export class EmbeddingService {
  private readonly openai: OpenAI;

  constructor(private readonly configService: ConfigService) {
    this.openai = new OpenAI({
      apiKey: this.configService.get&lt;string&gt;('OPENAI_API_KEY'),
    });
  }

  async embed(text: string): Promise&lt;number[]&gt; {
    const response = await this.openai.embeddings.create({
      model: 'text-embedding-3-small',
      input: text,
    });
    return response.data[0].embedding;
  }
}
</code></pre>
<p>Nothing is wrong with this code in isolation. It works. The issue appears only when you look at who depends on it. Both <code>DocumentService</code> (which ingests documents) and <code>SearchService</code> (which answers questions) import this concrete class directly. So every time I ingest a test document during development, I pay OpenAI. Without an API key, the app cannot do anything useful at all. And if I ever want to try a local embedding model, I have to open both services and change them.</p>
<p>Notice that none of these are hypothetical. The first one costs me money every day. This is the "<code>new Truck()</code> everywhere" problem from the textbook, except the truck is a paid API.</p>
<h2>Step 1: say what you need, not who provides it</h2>
<p>The first move is to describe what the services actually need from an embedder. Not a name, not a model, just a capability: give me a text, I give you a vector.</p>
<pre><code class="language-ts">// embedding-provider.ts
export interface EmbeddingProvider {
  embed(text: string): Promise&lt;number[]&gt;;
}

export const EMBEDDING_PROVIDER = 'EMBEDDING_PROVIDER';
</code></pre>
<p>My first attempt at this interface had <code>name</code> and <code>description</code> fields. That was a mistake worth noting, because I think it is a common one. An interface should describe <em>behaviour</em>, what the thing does, not <em>properties</em>, what the thing is. The services never ask the embedder for its name. They ask it for vectors.</p>
<p>The second line, the string constant, looks strange at first. Why do we need it? I will come back to this, because it turned out to be the most instructive part of the whole exercise.</p>
<h2>Step 2: two implementations</h2>
<p>The real one is the old service, moved almost unchanged:</p>
<pre><code class="language-ts">// openai-embedding.provider.ts
export class OpenAIEmbeddingProvider implements EmbeddingProvider {
  private readonly openai: OpenAI;

  constructor(apiKey: string) {
    this.openai = new OpenAI({ apiKey });
  }

  async embed(text: string): Promise&lt;number[]&gt; {
    const response = await this.openai.embeddings.create({
      model: 'text-embedding-3-small',
      input: text,
    });
    return response.data[0].embedding;
  }
}
</code></pre>
<p>One change: the constructor takes a plain <code>apiKey</code> string instead of <code>ConfigService</code>. The provider no longer knows that NestJS exists. Reading configuration is somebody else's job, and as a side effect the class becomes trivial to construct in a test.</p>
<p>The fake one is a single line:</p>
<pre><code class="language-ts">// fake-embedding.provider.ts
export class FakeEmbeddingProvider implements EmbeddingProvider {
  async embed(text: string): Promise&lt;number[]&gt; {
    return new Array(1536).fill(0.01);
  }
}
</code></pre>
<p>That line hides two decisions, and both came from mistakes. More on that below.</p>
<h2>Step 3: the factory</h2>
<p>Now something has to decide which implementation to use. In NestJS, that place is the module, through <code>useFactory</code>:</p>
<pre><code class="language-ts">// embedding.module.ts
@Module({
  providers: [
    {
      provide: EMBEDDING_PROVIDER,
      inject: [ConfigService],
      useFactory: (config: ConfigService): EmbeddingProvider =&gt; {
        if (config.get&lt;string&gt;('EMBEDDING_PROVIDER') === 'fake') {
          return new FakeEmbeddingProvider();
        }
        return new OpenAIEmbeddingProvider(
          config.getOrThrow&lt;string&gt;('OPENAI_API_KEY'),
        );
      },
    },
  ],
  exports: [EMBEDDING_PROVIDER],
})
export class EmbeddingModule {}
</code></pre>
<p>I used <code>getOrThrow</code> for the API key on purpose. <code>config.get</code> returns <code>undefined</code> silently when a variable is missing, which means a typo in <code>.env</code> shows up much later as a confusing error from OpenAI. <code>getOrThrow</code> fails at startup, where the cause is obvious.</p>
<p>The services now ask for the token, not the class:</p>
<pre><code class="language-ts">constructor(
  @Inject(PG_POOL) private readonly pool: Pool,
  @Inject(EMBEDDING_PROVIDER) private readonly embedder: EmbeddingProvider,
) {}
</code></pre>
<p>After this change, the word <code>OpenAI</code> does not appear anywhere in <code>DocumentService</code> or <code>SearchService</code>. Setting <code>EMBEDDING_PROVIDER=fake</code> in <code>.env</code> makes the whole ingestion pipeline run without a single network call. No code changes, only configuration.</p>
<h2>A note on honesty: this is not the Factory Method</h2>
<p>I should be careful about naming here, because it is easy to inflate what one has done. The GoF Factory Method relies on inheritance: an abstract creator declares a factory method, and subclasses override it. What I wrote above has no inheritance at all. It is one function with an <code>if</code>. This is usually called a <em>Simple Factory</em>.</p>
<p>I wrote the inheritance version too, as an exercise, and it works. But in a framework with a dependency injection container, the container already does the job that subclassing was invented for. So the simpler form is the one you will actually meet in backend code. I think it is more useful to know why the two differ than to pretend the simpler one is the textbook one.</p>
<p>Incidentally, the same file that holds my database pool uses exactly this shape:</p>
<pre><code class="language-ts">{
  provide: PG_POOL,
  inject: [ConfigService],
  useFactory: (config: ConfigService) =&gt;
    new Pool({ connectionString: config.get&lt;string&gt;('DATABASE_URL') }),
}
</code></pre>
<p>Nest runs that factory once and hands every service the same <code>Pool</code>. That is a Singleton, managed by the container rather than written by hand. I had been using two patterns for weeks without noticing.</p>
<h2>Three things the tutorials did not mention</h2>
<h3>1. Interfaces do not exist at runtime</h3>
<p>Back to the string constant. In a toy example you write <code>new FakeIngestion()</code> and everything is visible. In NestJS, dependencies are resolved at runtime, and TypeScript interfaces are erased when the code is compiled to JavaScript. The container cannot look up <code>EmbeddingProvider</code> because, at runtime, there is no such thing. A string token is the only handle that survives compilation.</p>
<p>I had already met this idea in a different form. In my Singleton exercise, I marked the constructor <code>private</code> and then called <code>new AppConfig()</code> anyway, to see the error. With plain <code>node</code>, which strips types without checking them, it ran without complaint and created a second instance. TypeScript's <code>private</code> is a compile-time promise, not a runtime wall. It is the same lesson twice: what the type system says and what the program does are two different things.</p>
<h3>2. TS1272</h3>
<p>Once I injected the interface, the compiler stopped with this:</p>
<pre><code class="language-plaintext">TS1272: A type referenced in a decorated signature must be imported with
'import type' or a namespace import when 'isolatedModules' and
'emitDecoratorMetadata' are enabled.
</code></pre>
<p>It is the same issue again, from another angle. Nest asks the compiler to emit type metadata for decorated constructor parameters. For a class that works, because the class exists at runtime. For an interface there is nothing to emit, so TypeScript asks you to state explicitly that this import is only a type:</p>
<pre><code class="language-ts">import { type EmbeddingProvider, EMBEDDING_PROVIDER } from '../embeddings/embedding-provider';
</code></pre>
<p><code>type</code> applies only to the interface. The token is a real value and stays a normal import.</p>
<h3>3. The fake vector has to respect the database</h3>
<p>My first fake embedder returned three numbers, as in my exercise. It compiled. It would have failed on the first insert, because the column is defined as <code>VECTOR(1536)</code> and pgvector rejects anything of another dimension. The type <code>number[]</code> says nothing about length, so the compiler cannot help you here. The database schema is part of the contract, even if it is not written in TypeScript.</p>
<p>My second attempt filled the array with zeros, which is also wrong in a quieter way. Cosine distance divides by the length of the vector, and a zero vector has length zero, so the distance is undefined. Hence <code>0.01</code>.</p>
<p>One more caveat, so nobody misreads what the fake is for. Every fake vector is identical, so every chunk is equally "similar" to every question. The fake lets you test that the pipeline runs: chunking, inserting, querying, error handling. It tells you nothing about retrieval quality. And if you ingest documents with the fake and later switch back to OpenAI, those rows still hold fake vectors and should be re-embedded.</p>
<h2>What I take from this</h2>
<p>The pattern itself was the easy part. I understood the diagram in an afternoon. What took time was everything around it: the runtime model of the framework, the gap between types and values, a constraint that lives in SQL rather than in TypeScript. None of that is in the catalogue, and all of it is what an actual codebase forces you to confront.</p>
<p>So when someone asks me now what the Factory pattern is for, I hope I will not draw boxes. I will say: it is what let me stop paying for API calls I did not need, and it taught me that interfaces disappear at runtime. That answer is less elegant, but I think it is more true.</p>
<hr />
<p><em>A note on how this was written: the refactor, the code and every mistake described here are mine. I used Claude as a writing assistant to draft the post from my notes and code, then reviewed and edited every line.</em></p>
<p><em>The code is on GitHub:</em> <a href="https://github.com/YusufOztoprak/rag-service"><em>YusufOztoprak/rag-service</em></a><em>. My weekly study notes, including the exercise versions of Factory Method, Singleton and Builder, are in</em> <a href="https://github.com/YusufOztoprak/learning-notes"><em>learning-notes</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Counting OpenAI Tokens per Request in FastAPI — Without Touching My Use Cases]]></title><description><![CDATA[Structured logging for a Clean Architecture RAG service, and the ContextVar trap I had to design around]]></description><link>https://yusufoztoprak.hashnode.dev/counting-openai-tokens-per-request-in-fastapi-without-touching-my-use-cases</link><guid isPermaLink="true">https://yusufoztoprak.hashnode.dev/counting-openai-tokens-per-request-in-fastapi-without-touching-my-use-cases</guid><dc:creator><![CDATA[Yusuf Öztoprak]]></dc:creator><pubDate>Sat, 17 Oct 2026 13:10:46 GMT</pubDate><content:encoded><![CDATA[<p>I'm rebuilding <strong>evorag</strong>, a Python retrieval service: it chunks documents, embeds them with OpenAI's <code>text-embedding-3-small</code>, stores vectors in PostgreSQL + pgvector, and returns the most relevant chunks for a query. Answer generation is out of scope. The plan is to connect it to my NestJS <a href="https://github.com/YusufOztoprak/rag-service">rag-service</a>, with evorag handling retrieval and rag-service handling the API and generation, but today the two run independently.</p>
<p>This week's goal was observability. For every request I wanted to know three things:</p>
<ol>
<li><p><strong>Which request</strong> a log line belongs to (a request ID that also works across both services)</p>
</li>
<li><p><strong>How long</strong> it took — and how much of that was OpenAI vs. my own code</p>
</li>
<li><p><strong>How many embedding tokens</strong> it consumed, because tokens are money</p>
</li>
</ol>
<p>The catch: evorag follows Clean Architecture, and I didn't want any of this to leak into the domain or use-case layers.</p>
<h2>The constraint: the dependency rule</h2>
<p>The project is laid out like this:</p>
<pre><code class="language-plaintext">src/evorag/
  domain/          Chunk entity (plain frozen dataclass)
  ingestion/       IngestDocument use case + ports
  retrieval/       SearchChunks use case + ports
  shared/          EmbeddingProvider port
  observability/   JSON formatter, per-request context
  adapters/        FastAPI, OpenAI, pgvector
  main.py          composition root
</code></pre>
<p>Source code dependencies point inward only. A use case knows an <code>EmbeddingProvider</code> <em>protocol</em>, not OpenAI:</p>
<pre><code class="language-python">class EmbeddingProvider(Protocol):
    async def embed(self, texts: list[str]) -&gt; list[list[float]]: ...
</code></pre>
<p>Here's the problem. The token count is only known inside the OpenAI adapter (<code>response.usage.total_tokens</code>). The per-request log line is written in the HTTP middleware. Between them sits the use case.</p>
<p>The naive fix is to make <code>embed()</code> return <code>(vectors, tokens)</code> and thread the number up through <code>IngestDocument.execute()</code> back to the route. That would change the port, the use case, every fake in my tests, and the use case would suddenly "know" about billing metrics. That's exactly what the dependency rule is supposed to prevent.</p>
<p>So I needed a side channel: something request-scoped that the adapter can write to and the middleware can read from, with the use case in the middle never noticing.</p>
<h2>Step 1: one JSON line per event</h2>
<p>First, logs. I stuck to the standard library and wrote a small formatter that emits <a href="https://jsonlines.org/">JSON Lines</a>:</p>
<pre><code class="language-python">class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -&gt; str:
        payload = {
            "ts": datetime.fromtimestamp(record.created, UTC).isoformat(),
            "level": record.levelname,
            "logger": record.name,
            "event": record.getMessage(),
            "request_id": get_request_id(),
        }
        payload.update(getattr(record, "fields", {}))
        return json.dumps(payload)
</code></pre>
<p>The message is an event name (<code>request.completed</code>, <code>embedding.created</code>), and structured data goes in via <code>extra={"fields": {...}}</code>. Any log tool can then filter by <code>event</code> or aggregate <code>latency_ms</code> without regex.</p>
<h2>Step 2: request ID with <code>contextvars</code></h2>
<p><code>contextvars</code> is the async-safe way to hold "current request" state in Python. Each request gets its own value, even when hundreds are interleaved on one event loop.</p>
<pre><code class="language-python">_request_id: ContextVar[str | None] = ContextVar("request_id", default=None)

def set_request_id(request_id: str) -&gt; None:
    _request_id.set(request_id)

def get_request_id() -&gt; str | None:
    return _request_id.get()
</code></pre>
<p>The middleware reuses an incoming <code>X-Request-ID</code> header if there is one (so once rag-service calls evorag, the same ID can follow a request across both services), otherwise it generates one, and it echoes the ID back in the response:</p>
<pre><code class="language-python">async def log_requests(request: Request, call_next) -&gt; Response:
    request_id = request.headers.get("x-request-id") or uuid.uuid4().hex
    set_request_id(request_id)
    ...
    response = await call_next(request)
    response.headers["x-request-id"] = request_id
</code></pre>
<p>Because the formatter calls <code>get_request_id()</code> itself, every log line from anywhere in the app gets the ID automatically. No logger needs to be passed around.</p>
<h2>Step 3: token counting — and why it's not a <code>ContextVar[int]</code></h2>
<p>The obvious approach for tokens would be the same pattern as the request ID: a <code>ContextVar[int]</code>, reset to 0 in the middleware, incremented in the adapter with <code>.set(current + tokens)</code>, read back in the middleware after <code>call_next</code>.</p>
<p>That looks right, but it doesn't work. A minimal reproduction:</p>
<pre><code class="language-python">counter: ContextVar[int] = ContextVar("c", default=0)
holder: ContextVar[dict | None] = ContextVar("h", default=None)

@app.middleware("http")
async def mw(request, call_next):
    counter.set(0)
    h = {"n": 0}
    holder.set(h)
    response = await call_next(request)
    print(counter.get(), h["n"])
    return response

@app.get("/x")
async def x():
    counter.set(counter.get() + 42)
    holder.get()["n"] += 42
    return {}
</code></pre>
<p>Output:</p>
<pre><code class="language-plaintext">0 42
</code></pre>
<p>The <code>int</code> version reads back <strong>0</strong>. The object version reads back <strong>42</strong>.</p>
<p>Why: <code>@app.middleware("http")</code> is Starlette's <code>BaseHTTPMiddleware</code>, and <code>call_next</code> runs the rest of the app in a separate task. A new task gets a <strong>copy</strong> of the current context. The endpoint can <em>read</em> everything the middleware set (that's why the request ID works), but a <code>.set()</code> inside the endpoint only changes the copy. The middleware never sees it.</p>
<p>What <em>does</em> cross the boundary is a mutable object. The copied context still points to the same object, so mutating it in place is visible on both sides.</p>
<p>So the fix is to store a small holder object in the ContextVar and mutate it, never re-set it:</p>
<pre><code class="language-python">@dataclass
class RequestMetrics:
    embedding_tokens: int = 0

_metrics: ContextVar[RequestMetrics | None] = ContextVar("metrics", default=None)

def start_metrics() -&gt; RequestMetrics:
    metrics = RequestMetrics()
    _metrics.set(metrics)
    return metrics

def add_embedding_tokens(tokens: int) -&gt; None:
    metrics = _metrics.get()
    if metrics is None:   # e.g. called outside a request (scripts, tests)
        return
    metrics.embedding_tokens += tokens
</code></pre>
<p>The middleware creates the holder before <code>call_next</code> and keeps a reference to it:</p>
<pre><code class="language-python">    metrics = start_metrics()
    started = time.perf_counter()
    response = await call_next(request)
    latency_ms = round((time.perf_counter() - started) * 1000, 2)

    logger.info("request.completed", extra={"fields": {
        "method": request.method,
        "path": request.url.path,
        "status": response.status_code,
        "latency_ms": latency_ms,
        "embedding_tokens": metrics.embedding_tokens,
    }})
</code></pre>
<p>And the OpenAI adapter, the only place that knows about tokens, reports them:</p>
<pre><code class="language-python">        started = time.perf_counter()
        response = await self._client.embeddings.create(...)
        latency_ms = round((time.perf_counter() - started) * 1000, 2)

        tokens = response.usage.total_tokens
        add_embedding_tokens(tokens)
        logger.info("embedding.created", extra={"fields": {
            "model": self._model,
            "inputs": len(texts),
            "tokens": tokens,
            "latency_ms": latency_ms,
        }})
</code></pre>
<p>The <code>EmbeddingProvider</code> port didn't change. <code>IngestDocument</code> and <code>SearchChunks</code> didn't change. The test fakes didn't change. Observability lives entirely in the outer ring: the HTTP adapter and the OpenAI adapter, both talking to a small <code>observability</code> module.</p>
<h2>What the logs showed</h2>
<p>One <code>POST /documents</code> now produces two lines (some fields trimmed):</p>
<pre><code class="language-json">{"event": "embedding.created", "request_id": "2a2c1c38...", "model": "text-embedding-3-small", "inputs": 1, "tokens": 18, "latency_ms": 390.19}
{"event": "request.completed", "request_id": "2a2c1c38...", "method": "POST", "path": "/documents", "status": 201, "latency_ms": 439.52, "embedding_tokens": 18}
</code></pre>
<p>Measuring the OpenAI call separately from the whole request turned out to be the most useful part. In my early measurements the embedding call took <strong>89–97% of total request time</strong>, while evorag's own work (chunking, DB write, serialization) stayed around <strong>50 ms</strong>.</p>
<p>That changes what I'll work on next. Optimizing my own code is pointless right now; the real levers are around the external call: timeouts, retries, and moving ingestion off the request path (a RabbitMQ consumer is on the roadmap).</p>
<h2>Testing it</h2>
<p>The observability tests use a tiny FastAPI app with the real middleware and a fake endpoint, so no database or API key is needed:</p>
<pre><code class="language-python">def make_app():
    app = FastAPI()
    app.middleware("http")(log_requests)

    @app.get("/fake")
    async def fake():
        add_embedding_tokens(30)
        add_embedding_tokens(12)
        return {"ok": "yes"}

    return app

def test_middleware_sums_embedding_tokens(caplog):
    caplog.set_level(logging.INFO, logger="evorag")
    TestClient(make_app()).get("/fake")

    [record] = [r for r in caplog.records if r.getMessage() == "request.completed"]
    assert record.fields["embedding_tokens"] == 42
</code></pre>
<p>This test is also a regression guard for the trap above: if someone "simplifies" <code>RequestMetrics</code> back into a plain <code>ContextVar[int]</code>, it fails with <code>0 != 42</code>.</p>
<p>Other tests cover generating a request ID, reusing an incoming <code>X-Request-ID</code>, and the formatter with and without extra fields.</p>
<h2>Takeaways</h2>
<ul>
<li><p><strong>Clean Architecture makes cross-cutting concerns a design question, not an afterthought.</strong> "Where does this number get produced, and where is it consumed?" decided the whole solution.</p>
</li>
<li><p><code>contextvars</code> <strong>is the right tool for request-scoped data in async Python</strong>, but values set in a child task don't flow back to the parent. If you need to collect data, store a mutable holder.</p>
</li>
<li><p><strong>Measure external calls separately.</strong> One extra <code>latency_ms</code> field told me more about where to spend effort than any amount of profiling my own code would have.</p>
</li>
<li><p><strong>The standard library is enough to start.</strong> No logging framework, no tracing stack yet; JSON Lines on stdout can be picked up by any tool later.</p>
</li>
</ul>
<p>Next up for evorag: timeouts and retries around the OpenAI calls, a RabbitMQ consumer for asynchronous ingestion, and a small evaluation set to calibrate chunk size and the similarity threshold properly.</p>
<p>The code is on GitHub: <a href="https://github.com/YusufOztoprak/evorag">YusufOztoprak/evorag</a></p>
]]></content:encoded></item><item><title><![CDATA[Building a RAG Service from Scratch with NestJS + pgvector]]></title><description><![CDATA[How I built a Retrieval-Augmented Generation (RAG) service from scratch using NestJS, PostgreSQL, and pgvector — covering chunking, embeddings, and…]]></description><link>https://yusufoztoprak.hashnode.dev/building-a-rag-service-from-scratch-with-nestjs-pgvector</link><guid isPermaLink="true">https://yusufoztoprak.hashnode.dev/building-a-rag-service-from-scratch-with-nestjs-pgvector</guid><dc:creator><![CDATA[Yusuf Öztoprak]]></dc:creator><pubDate>Mon, 06 Jul 2026 18:00:51 GMT</pubDate><content:encoded><![CDATA[<p>Large language models are powerful, but they don't know what they don't know. Ask one about your company's internal docs or a niche research paper, and it will confidently make something up.</p>
<p>Retrieval-Augmented Generation (RAG) fixes this by giving the model real, relevant context to work with before it answers.</p>
<p>I recently built a RAG service from scratch using NestJS, PostgreSQL with the pgvector extension, and OpenAI's APIs. Here's how it works, and what I learned along the way.</p>
<h2>Architecture Overview</h2>
<p>The service is built with a layered NestJS architecture, split into focused modules:</p>
<p>database — PostgreSQL connection and pgvector setup</p>
<p>embeddings — chunking documents and generating vectors via OpenAI</p>
<p>documents — ingesting and storing source documents</p>
<p>query — handling incoming questions and returning grounded answers</p>
<p>llm — prompting the model with retrieved context</p>
<p>Each module has a single responsibility, which made the system easy to reason about and test independently.</p>
<h2>Chunking and Embedding</h2>
<p>Before a document can be searched semantically, it has to be split into smaller pieces — chunks — and each chunk needs its own vector representation.</p>
<p>I chunk documents into overlapping pieces rather than clean, non-overlapping blocks. Without overlap, a chunk boundary can land in the middle of a sentence, splitting its meaning across two chunks that are each embedded independently — neither one captures the full idea. A small overlap (a few hundred characters) means that sentence near a boundary is likely to appear whole in at least one chunk.</p>
<p>Each chunk is embedded using OpenAI's text-embedding-3-small model and stored in PostgreSQL as a vector, using the pgvector extension. Rather than sending one API request per chunk, I batch multiple chunks into a single embeddings request — the API returns vectors in the same order as the input, so mapping each result back to its chunk is straightforward and reliable.</p>
<h2>Retrieval</h2>
<p>When a question comes in, it's embedded using the same model, then compared against every stored chunk using cosine distance — a measure of how close two vectors are in meaning, not just in wording.</p>
<p>This comparison happens inside PostgreSQL itself, via pgvector's distance operator, rather than pulling every chunk into the application and comparing them in code. For a handful of chunks the difference is negligible, but it matters enormously at scale: computing distances in the database avoids transferring thousands of large vectors over the network, and lets a highly optimized engine do the sorting instead of a slow loop in application code.</p>
<p>The closest matches are passed to the LLM as context, and the model is prompted to answer using only that context — which is what keeps the answers grounded instead of hallucinated.</p>
<h2>What I Learned</h2>
<p>A few things stood out while building this:</p>
<p>Chunking strategy matters more than it seems at first. It's tempting to treat it as a trivial preprocessing step, but the boundaries you choose directly affect retrieval quality later on.</p>
<p>Most of the real debugging wasn't in the AI logic — it was in the surrounding infrastructure: container port conflicts, environment variable caching, and making sure async code actually awaits what it needs to. RAG systems are, underneath the AI layer, still ordinary backend systems that need to be built carefully.</p>
<p>One bug stuck with me: a SQL statement silently broke because a Turkish "ı" (dotless i) had slipped into an uppercase SQL keyword through my keyboard's autocomplete — PostgreSQL rejected it as invalid syntax, and the error message alone didn't make the cause obvious. Small reminder that "write code in English" isn't just a style preference.</p>
<p>Grounding matters. An LLM given the right context produces noticeably more reliable answers than one relying on its training data alone.</p>
<h2>What's Next</h2>
<p>I'm currently building a second RAG service, this time in Python with FastAPI, async SQLAlchemy, and pgvector, focused on scientific literature. It's a chance to rebuild the same core ideas in a different stack and see what changes. I'll be writing about that next.</p>
]]></content:encoded></item></channel></rss>