<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: José Henrique Oliveira de Carvalho</title>
    <description>The latest articles on DEV Community by José Henrique Oliveira de Carvalho (@jose15000).</description>
    <link>https://dev.to/jose15000</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4143641%2Fa23018c0-4157-4da9-b871-6dc4205c0481.jpg</url>
      <title>DEV Community: José Henrique Oliveira de Carvalho</title>
      <link>https://dev.to/jose15000</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jose15000"/>
    <language>en</language>
    <item>
      <title>I Built a Local RAG Pipeline with TypeScript, PostgreSQL and pgvector</title>
      <dc:creator>José Henrique Oliveira de Carvalho</dc:creator>
      <pubDate>Sat, 26 Sep 2026 16:09:29 +0000</pubDate>
      <link>https://dev.to/jose15000/i-built-a-local-rag-pipeline-with-typescript-postgresql-and-pgvector-4hi2</link>
      <guid>https://dev.to/jose15000/i-built-a-local-rag-pipeline-with-typescript-postgresql-and-pgvector-4hi2</guid>
      <description>&lt;p&gt;Originally published on my personal website:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://josehenriquedev.com/blog/como-implementei-o-backend-do-meu-site-pessoal-rag-local-com-bun-elysia-e-pgvector" rel="noopener noreferrer"&gt;https://josehenriquedev.com/blog/como-implementei-o-backend-do-meu-site-pessoal-rag-local-com-bun-elysia-e-pgvector&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I wanted my personal portfolio to be more than a collection of static pages.&lt;/p&gt;

&lt;p&gt;The idea was to build an AI assistant capable of answering questions about my background, projects, experience, and technical decisions — while keeping the answers grounded in my actual data.&lt;/p&gt;

&lt;p&gt;Instead of sending everything to a commercial embedding API and adding a dedicated vector database, I decided to build the retrieval pipeline myself using TypeScript.&lt;/p&gt;

&lt;p&gt;The final architecture looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Markdown
   ↓
Parsing + Chunking
   ↓
Question enrichment
   ↓
Local embeddings
   ↓
PostgreSQL + pgvector
   ↓
Similarity search
   ↓
Relevance threshold
   ↓
LLM via Groq
   ↓
Response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Stack
&lt;/h2&gt;

&lt;p&gt;The backend uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bun&lt;/li&gt;
&lt;li&gt;Elysia.js&lt;/li&gt;
&lt;li&gt;TypeScript&lt;/li&gt;
&lt;li&gt;PostgreSQL&lt;/li&gt;
&lt;li&gt;pgvector&lt;/li&gt;
&lt;li&gt;Drizzle ORM&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@huggingface/transformers&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Xenova/multilingual-e5-small&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Groq&lt;/li&gt;
&lt;li&gt;&lt;code&gt;openai/gpt-oss-120b&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The interesting part is that the embedding generation happens locally.&lt;/p&gt;

&lt;p&gt;There is no external embedding API in the retrieval pipeline.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Markdown as the knowledge base
&lt;/h2&gt;

&lt;p&gt;Instead of storing my professional information directly in the database, I keep the source knowledge in versioned Markdown files.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;profile.md
experience.md
projects.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each document contains frontmatter with structured metadata.&lt;/p&gt;

&lt;p&gt;Before generating embeddings, the Markdown is split into smaller chunks using LangChain's &lt;code&gt;RecursiveCharacterTextSplitter&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;splitter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;RecursiveCharacterTextSplitter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromLanguage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;markdown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;chunkSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;chunkOverlap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The goal is to avoid embedding huge documents as a single vector.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Enriching the documents with probable questions
&lt;/h2&gt;

&lt;p&gt;A technically correct document does not necessarily have the same semantic representation as the question a visitor will ask.&lt;/p&gt;

&lt;p&gt;For example, a document might contain:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;José has experience with React, Next.js, Node.js and PostgreSQL.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But the visitor may ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What technologies does José use?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To improve retrieval, I added probable questions to the text before generating the embedding.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;questionsText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;probable_questions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`Perguntas Frequentes Relacionadas:
- &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;probable_questions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;
- &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;

`&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;textToEmbbed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Documento: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
Tipo: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
Locale: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;itemLocale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;questionsText&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;Conteúdo:
&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cleanContent&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives the embedding model additional semantic signals that are closer to the kinds of queries users are likely to make.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Generating embeddings locally
&lt;/h2&gt;

&lt;p&gt;For embeddings, I use &lt;code&gt;Xenova/multilingual-e5-small&lt;/code&gt; through &lt;code&gt;@huggingface/transformers&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The model runs locally on CPU:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EmbbedingService&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;IEmbedd&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;extractor&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FeatureExtractionPipeline&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;extractor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;feature-extraction&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Xenova/multilingual-e5-small&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;device&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cpu&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;embbed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extractor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;pooling&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mean&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resulting vectors have 384 dimensions.&lt;/p&gt;

&lt;p&gt;One important detail with E5 models is the use of prefixes.&lt;/p&gt;

&lt;p&gt;For stored documents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;passage: &amp;lt;content&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For user queries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;query: &amp;lt;question&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These prefixes are part of the model's expected input format.&lt;/p&gt;

&lt;p&gt;I also wanted the system to work naturally with both Portuguese and English content, which is important for a developer portfolio.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. PostgreSQL as the vector database
&lt;/h2&gt;

&lt;p&gt;I didn't want to introduce another database just for vector search.&lt;/p&gt;

&lt;p&gt;Since the project already uses PostgreSQL, I added &lt;code&gt;pgvector&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The table stores both the original content and its embedding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;professionalProfileTable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pgTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;profile&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;serial&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;primaryKey&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;project&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;experience&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;profile&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;embedding&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dimensions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;384&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For similarity search, pgvector provides operators such as &lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Using Drizzle, the query looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;similarity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;`
    (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;professionalProfileTable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
    &amp;lt;=&amp;gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;embbeding&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;::vector)
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;professionalProfileTable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;professionalProfileTable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;professionalProfileTable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;similarity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;similarity&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;professionalProfileTable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;similarity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, the returned value represents cosine distance.&lt;/p&gt;

&lt;p&gt;Lower distance means greater similarity.&lt;/p&gt;

&lt;p&gt;For this project, PostgreSQL + pgvector was enough to keep the retrieval layer simple without introducing a separate vector database.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Filtering retrieval results
&lt;/h2&gt;

&lt;p&gt;Retrieval alone isn't enough.&lt;/p&gt;

&lt;p&gt;Even if the vector search returns the closest chunks, they may still be irrelevant to the question.&lt;/p&gt;

&lt;p&gt;So I added a similarity threshold:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gatherKnowledge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;relevantChunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;gatherKnowledge&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;similarity&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.35&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If no chunk passes the threshold, I don't inject arbitrary retrieved context into the prompt.&lt;/p&gt;

&lt;p&gt;Instead, the LLM receives a basic prompt instructing it not to invent information.&lt;/p&gt;

&lt;p&gt;This is important because a RAG system doesn't automatically prevent hallucinations.&lt;/p&gt;

&lt;p&gt;The retrieval layer itself needs rules for deciding when retrieved information is relevant enough to be used.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. The complete pipeline
&lt;/h2&gt;

&lt;p&gt;Putting everything together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;             ┌──────────────────┐
             │ Markdown files   │
             │ profile.md       │
             │ experience.md    │
             │ projects.md      │
             └────────┬─────────┘
                      │
                      ▼
             ┌──────────────────┐
             │ Parsing +        │
             │ Chunking         │
             └────────┬─────────┘
                      │
                      ▼
             ┌──────────────────┐
             │ Question         │
             │ enrichment       │
             └────────┬─────────┘
                      │
                      ▼
             ┌──────────────────┐
             │ Local embeddings │
             │ multilingual-e5  │
             └────────┬─────────┘
                      │
                      ▼
             ┌──────────────────┐
             │ PostgreSQL       │
             │ + pgvector       │
             └────────┬─────────┘
                      │
               User question
                      │
                      ▼
             ┌──────────────────┐
             │ Query embedding  │
             └────────┬─────────┘
                      │
                      ▼
             ┌──────────────────┐
             │ Vector search    │
             └────────┬─────────┘
                      │
                      ▼
             ┌──────────────────┐
             │ Similarity       │
             │ threshold        │
             └────────┬─────────┘
                      │
                      ▼
             ┌──────────────────┐
             │ Groq LLM         │
             └────────┬─────────┘
                      │
                      ▼
                   Response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LLM is therefore only one part of the system.&lt;/p&gt;

&lt;p&gt;The retrieval pipeline determines what information is allowed to reach it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I built it this way
&lt;/h2&gt;

&lt;p&gt;The main goal wasn't simply to make an AI chatbot.&lt;/p&gt;

&lt;p&gt;I wanted to understand the retrieval pipeline end to end.&lt;/p&gt;

&lt;p&gt;That meant dealing with questions such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How should documents be chunked?&lt;/li&gt;
&lt;li&gt;How can retrieval be improved without changing the LLM?&lt;/li&gt;
&lt;li&gt;How should multilingual content be embedded?&lt;/li&gt;
&lt;li&gt;When is a retrieved chunk actually relevant?&lt;/li&gt;
&lt;li&gt;Do I really need a dedicated vector database?&lt;/li&gt;
&lt;li&gt;What should happen when retrieval finds nothing useful?&lt;/li&gt;
&lt;li&gt;How can I keep the system lightweight?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is a small RAG backend with a relatively simple architecture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Bun + Elysia
      +
PostgreSQL + pgvector
      +
Local embeddings
      +
Groq inference
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The most important lesson for me was that the LLM is not the whole system.&lt;/p&gt;

&lt;p&gt;A large part of the quality of a RAG application comes from the data representation, chunking strategy, embeddings, retrieval, and relevance filtering that happen before the model generates an answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;Building the retrieval layer myself also made the system easier to reason about.&lt;/p&gt;

&lt;p&gt;The embedding model runs locally, PostgreSQL handles both relational and vector data, and the LLM only receives context that passes the retrieval rules.&lt;/p&gt;

&lt;p&gt;It is not a universal architecture, and a dedicated vector database can make sense for larger or more complex workloads.&lt;/p&gt;

&lt;p&gt;But for a personal portfolio, PostgreSQL + pgvector was enough to build the entire retrieval pipeline without adding another infrastructure component.&lt;/p&gt;

&lt;p&gt;You can read the full article, including more implementation details, on my website:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://josehenriquedev.com/blog/como-implementei-o-backend-do-meu-site-pessoal-rag-local-com-bun-elysia-e-pgvector" rel="noopener noreferrer"&gt;https://josehenriquedev.com/blog/como-implementei-o-backend-do-meu-site-pessoal-rag-local-com-bun-elysia-e-pgvector&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;If you're building RAG applications, I'd be interested to know:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When do you choose PostgreSQL + pgvector instead of a dedicated vector database?&lt;/strong&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  typescript #ai #rag #postgresql #webdev
&lt;/h1&gt;

</description>
      <category>ai</category>
      <category>database</category>
      <category>rag</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
