<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>LLM &#8211; Wasalt Blog</title>
	<atom:link href="https://blog.wasalt.sa/en/tag/llm/feed/" rel="self" type="application/rss+xml" />
	<link>https://blog.wasalt.sa/en</link>
	<description></description>
	<lastBuildDate>Thu, 02 Oct 2025 10:00:38 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.0</generator>

<image>
	<url>https://blog.wasalt.sa/en/wp-content/uploads/2023/11/cropped-ms-icon-144x144-1-180x180-1-75x75.png</url>
	<title>LLM &#8211; Wasalt Blog</title>
	<link>https://blog.wasalt.sa/en</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Build a Minimal RAG Pipeline from Scratch (Python, FAISS, pgvector)</title>
		<link>https://blog.wasalt.sa/en/build-a-minimal-rag-pipeline-python-faiss-pgvector/?utm_source=rss&#038;utm_medium=rss&#038;utm_campaign=build-a-minimal-rag-pipeline-python-faiss-pgvector</link>
					<comments>https://blog.wasalt.sa/en/build-a-minimal-rag-pipeline-python-faiss-pgvector/#respond</comments>
		
		<dc:creator><![CDATA[admin]]></dc:creator>
		<pubDate>Fri, 26 Sep 2025 05:32:29 +0000</pubDate>
				<category><![CDATA[Tech World]]></category>
		<category><![CDATA[AI&ML]]></category>
		<category><![CDATA[AI & ML]]></category>
		<category><![CDATA[Architecture]]></category>
		<category><![CDATA[Blog]]></category>
		<category><![CDATA[build RAG pipeline]]></category>
		<category><![CDATA[Coding]]></category>
		<category><![CDATA[FAISS RAG]]></category>
		<category><![CDATA[LLM]]></category>
		<category><![CDATA[pgvector RAG]]></category>
		<category><![CDATA[Python]]></category>
		<category><![CDATA[Rag Pipeline]]></category>
		<category><![CDATA[RAG tutorial Python]]></category>
		<category><![CDATA[retrieval-augmented generation]]></category>
		<category><![CDATA[Technology]]></category>
		<category><![CDATA[Wasalt]]></category>
		<guid isPermaLink="false">https://blog.wasalt.sa/en/?p=3439</guid>

					<description><![CDATA[1) Architecture at a glance 2) Minimal stack (why these) Chunking: 500–800 tokens with 50–100 overlap → balances recall &#38; context. Embeddings: sentence-transformers (local) or OpenAI text-embedding-3-large (managed). Index: FAISS for single-machine; switch to pgvector for multi-service. LLM: Any function-calling or plain chat model; keep it swappable. 3) The tiniest working example (Python) Works on [&#8230;]]]></description>
										<content:encoded><![CDATA[<h3 id="ember65" class="ember-view reader-text-block__heading-3">1) Architecture at a glance</h3>
<pre class="brush: plain; title: ; notranslate">
&#x5B;Docs/PDFs/Markdown] 
      │ ingest + chunk
      ▼
&#x5B;Embeddings Model]  ──►  &#x5B;Vector Index (FAISS/PgVector)]
                             ▲
                             │ top-k by query embedding
User Question ──embed──► &#x5B;Retriever] ──► &#x5B;Prompt Builder] ──► &#x5B;LLM]
                                               │
                                           Answer + Sources
</pre>
<h3 id="ember66" class="ember-view reader-text-block__heading-3">2) Minimal stack (why these)</h3>
<ul>
<li><strong>Chunking:</strong> 500–800 tokens with 50–100 overlap → balances recall &amp; context.</li>
<li><strong>Embeddings:</strong> sentence-transformers (local) or OpenAI text-embedding-3-large (managed).</li>
<li><strong>Index:</strong> <strong>FAISS</strong> for single-machine; switch to <strong>pgvector</strong> for multi-service.</li>
<li><strong>LLM:</strong> Any function-calling or plain chat model; keep it swappable.</li>
</ul>
<h3 id="ember68" class="ember-view reader-text-block__heading-3">3) The tiniest working example (Python)</h3>
<blockquote id="ember69" class="ember-view reader-text-block__blockquote"><p>Works on a laptop. Replace the model names/keys as you like.</p></blockquote>
<h3 id="ember70" class="ember-view reader-text-block__heading-3">Install</h3>
<pre class="brush: bash; title: ; notranslate">
pip install sentence-transformers faiss-cpu pypdf tiktoken openai
</pre>
<h3 id="ember71" class="ember-view reader-text-block__heading-3">3.1 Ingest &amp; chunk</h3>
<pre class="brush: python; gutter: true; title: ; notranslate">

# ingest.py
import os, glob, re
from pypdf import PdfReader

def read_txt(path): return open(path, &quot;r&quot;, encoding=&quot;utf-8&quot;, errors=&quot;ignore&quot;).read()
def read_pdf(path):
r = PdfReader(path)
return &quot;\n&quot;.join(page.extract_text() or &quot;&quot; for page in r.pages)

def load_corpus(folder=&quot;docs&quot;):
texts = &#x5B;]
for f in glob.glob(os.path.join(folder, &quot;**/*&quot;), recursive=True):
if os.path.isdir(f): continue
if f.lower().endswith((&quot;.md&quot;,&quot;.txt&quot;,&quot;.markdown&quot;)):
texts.append((f, read_txt(f)))
elif f.lower().endswith(&quot;.pdf&quot;):
texts.append((f, read_pdf(f)))
return texts

def chunk(text, size=800, overlap=120):
# rough token-ish splitter on sentences
sents = re.split(r&#039;(?&amp;amp;amp;amp;lt;=&#x5B;.?!])\s+&#039;, text)
chunks, cur = &#x5B;], &#x5B;]
cur_len = 0
for s in sents:
cur.append(s)
cur_len += len(s.split())
if cur_len &amp;amp;amp;amp;gt;= size:
chunks.append(&quot; &quot;.join(cur))
# overlap
back = &quot; &quot;.join(&quot; &quot;.join(cur).split()&#x5B;-overlap:])
cur = &#x5B;back]
cur_len = len(back.split())
if cur:
chunks.append(&quot; &quot;.join(cur))
return chunks
</pre>
<h3 id="ember72" class="ember-view reader-text-block__heading-3">3.2 Build embeddings &amp; FAISS index</h3>
<pre class="brush: python; gutter: true; title: ; notranslate">
 #index.py
import faiss, pickle
from sentence_transformers import SentenceTransformer
from ingest import load_corpus, chunk

EMB_NAME = &quot;sentence-transformers/all-MiniLM-L6-v2&quot;  # small &amp;amp;amp;amp;amp; fast

def build_index(folder=&quot;docs&quot;, out_dir=&quot;rag_store&quot;):
    model = SentenceTransformer(EMB_NAME)
    records = &#x5B;]   # &#x5B;(doc_id, chunk_text, metadata)]
    vectors = &#x5B;]   # list of embeddings

    for path, text in load_corpus(folder):
        for i, ch in enumerate(chunk(text)):
            emb = model.encode(ch, normalize_embeddings=True)
            vectors.append(emb)
            records.append((f&quot;{path}#chunk{i}&quot;, ch, {&quot;source&quot;: path, &quot;chunk&quot;: i}))

    dim = len(vectors&#x5B;0])
    index = faiss.IndexFlatIP(dim)  # cosine with normalized vectors ~ inner product
    import numpy as np
    mat = np.vstack(vectors).astype(&quot;float32&quot;)
    index.add(mat)

    os.makedirs(out_dir, exist_ok=True)
    faiss.write_index(index, f&quot;{out_dir}/index.faiss&quot;)
    with open(f&quot;{out_dir}/records.pkl&quot;, &quot;wb&quot;) as f:
        pickle.dump(records, f)

if __name__ == &quot;__main__&quot;:
    build_index() </pre>
<h3 id="ember73" class="ember-view reader-text-block__heading-3">3.3 Query → retrieve → augment → generate</h3>
<pre class="brush: python; gutter: true; title: ; notranslate"># query.py
import faiss, pickle, numpy as np, os
from sentence_transformers import SentenceTransformer
from openai import OpenAI

EMB_NAME = &quot;sentence-transformers/all-MiniLM-L6-v2&quot;
STORE = &quot;rag_store&quot;
K = 5

def retrieve(question):
    model = SentenceTransformer(EMB_NAME)
    q = model.encode(question, normalize_embeddings=True).astype(&quot;float32&quot;).reshape(1, -1)
    index = faiss.read_index(f&quot;{STORE}/index.faiss&quot;)
    with open(f&quot;{STORE}/records.pkl&quot;,&quot;rb&quot;) as f: records = pickle.load(f)
    D, I = index.search(q, K)
    hits = &#x5B;records&#x5B;i] for i in I&#x5B;0]]
    return hits  # &#x5B;(id, text, meta), ...]

def build_prompt(question, hits):
    context = &quot;\n\n&quot;.join(
        &#x5B;f&quot;&#x5B;{i+1}] Source: {h&#x5B;2]&#x5B;&#039;source&#039;]}\n{h&#x5B;1]&#x5B;:1200]}&quot; for i,h in enumerate(hits)]
    )
    return f&quot;&quot;&quot;You are a precise assistant. Use ONLY the context to answer.
If the answer isn&#039;t in the context, say &quot;I don&#039;t know&quot; and suggest where to look.
Cite sources like &#x5B;1], &#x5B;2] that map to the snippets below.

Question: {question}

Context:
{context}
&quot;&quot;&quot;

def answer(question):
    hits = retrieve(question)
    prompt = build_prompt(question, hits)

    # Choose your LLM. Here: OpenAI for example; swap with local server if needed.
    client = OpenAI()  # requires OPENAI_API_KEY
    resp = client.chat.completions.create(
        model=&quot;gpt-4o-mini&quot;,
        messages=&#x5B;{&quot;role&quot;:&quot;user&quot;,&quot;content&quot;:prompt}],
        temperature=0.2
    )
    return resp.choices&#x5B;0].message.content

if __name__ == &quot;__main__&quot;:
    print(answer(&quot;What are the refund steps for premium auctions?&quot;))&amp;amp;amp;lt;/code&amp;amp;amp;gt;&amp;amp;amp;lt;/pre&amp;amp;amp;gt;

</pre>
<section>
<p id="ember832" class="ember-view reader-text-block__paragraph"><strong>What this gives you</strong></p>
<ul>
<li>Local embedding + FAISS speed.</li>
<li>Pluggable LLM.</li>
<li>Answers that <strong>only</strong> use retrieved chunks and <strong>cite sources</strong>.</li>
</ul>
<h3 id="ember834" class="ember-view reader-text-block__heading-3">4) Measuring quality</h3>
<ul>
<li><strong>Retrieval hit-rate:</strong> % of test questions where the gold answer appears in top-k chunks.</li>
<li><strong>Answer accuracy:</strong> exact-match / semantic similarity (e.g., BLEURT/BERTScore) against gold answers.</li>
<li><strong>Hallucination rate:</strong> manual spot checks + “I don’t know” rate (should go <strong>up</strong> slightly when you tighten guardrails).</li>
<li><strong>Latency/cost:</strong> p50/p95 query time, tokens per answer.</li>
</ul>
<p id="ember836" class="ember-view reader-text-block__paragraph">Create a small <strong>eval set</strong> (20–50 Q/A pairs) from your docs. Run it after any change (new chunking, new embedding model).</p>
<h3 id="ember837" class="ember-view reader-text-block__heading-3">5) Guardrails I actually used</h3>
<ul>
<li><strong>Strict prompt:</strong> “Use ONLY the context; else say I don’t know.”</li>
<li><strong>Chunk citations:</strong> attach file &amp; chunk IDs; show them in UI.</li>
<li><strong>Post-validation:</strong> regex/JSON checks for structured answers (IDs, amounts, dates).</li>
<li><strong>Source diversity:</strong> prefer hits from <strong>different files</strong> to avoid redundancy.</li>
</ul>
<h3 id="ember839" class="ember-view reader-text-block__heading-3">6) Common pitfalls (and fixes)</h3>
<ul>
<li><strong>Over-chunking (too small):</strong> loses semantics → drop to 500–800 tokens with 80 overlap.</li>
<li><strong>Wrong embeddings:</strong> domain jargon suffers → try larger models (e5-large, text-embedding-3-large) for critical domains.</li>
<li><strong>PDF extraction mess:</strong> run a cleanup step (collapse hyphens, fix Unicode), or pre-convert to Markdown with a good parser.</li>
<li><strong>Query drift:</strong> add a quick classifier: “Is this answerable from internal docs?” → if not, escalate or say “I don’t know.”</li>
</ul>
<h3 id="ember841" class="ember-view reader-text-block__heading-3">7) From laptop to production</h3>
<p id="ember842" class="ember-view reader-text-block__paragraph"><strong>Option A: Keep FAISS, wrap as a service</strong></p>
<ul>
<li>Tiny FastAPI server with /search and /answer.</li>
<li>Nightly cron to re-ingest.</li>
<li>Cache by normalized question → reuse the same top-k for 24h.</li>
</ul>
<p id="ember844" class="ember-view reader-text-block__paragraph"><strong>Option B: Move to Postgres + pgvector</strong></p>
<ul>
<li>Pros: transactions, backups, horizontal scaling.</li>
<li>Schema example:</li>
</ul>
<pre class="brush: sql; gutter: true; title: ; notranslate">
CREATE TABLE rag_chunks (
  id bigserial PRIMARY KEY,
  source text,
  chunk_index int,
  content text,
  embedding vector(1536)  -- match your embedding dim
);
-- Vector search
SELECT id, source, chunk_index, content
FROM rag_chunks
ORDER BY embedding &amp;amp;lt;#&amp;amp;gt; $1  -- cosine distance with pgvector
LIMIT 5; 
</pre>
<p id="ember846" class="ember-view reader-text-block__paragraph"><strong>Option C: Orchestrate with Temporal (reliable pipelines)</strong></p>
<ul>
<li>Workflow: ingest → chunk → embed (batched) → upsert index → smoke test → publish.</li>
<li>Activity retries, idempotent upserts, metrics on every step.</li>
</ul>
<h3 id="ember848" class="ember-view reader-text-block__heading-3">8) Prompt I ship with (copy/paste)</h3>
<pre class="brush: plain; title: ; notranslate">
System:
You answer only from the supplied CONTEXT. 
If the answer is missing, reply: &quot;I don&#039;t know based on the provided documents.&quot;
Always include citations like &#x5B;1], &#x5B;2] mapping to sources below.
Be concise and exact.

User:
Question: {{question}}

CONTEXT SNIPPETS:
{{#each snippets}}
&#x5B;{{@index+1}}] Source: {{this.source}}
{{this.text}}
{{/each}} </pre>
<h3 id="ember849" class="ember-view reader-text-block__heading-3">9) What I’d add next (nice upgrades)</h3>
<ul>
<li><strong>Hybrid retrieval:</strong> BM25 (keyword) + vectors → better on numbers/code.</li>
<li><strong>Reranking:</strong> small cross-encoder re-rank the top-50 to top-5 (big relevance win).</li>
<li><strong>Multi-tenant:</strong> per-team namespaces, per-doc ACLs.</li>
<li><strong>Inline quotes:</strong> highlight matched spans in each chunk.</li>
<li><strong>Evals dashboard:</strong> store runs in SQLite/Parquet and chart trends.</li>
</ul>
<h3 id="ember851" class="ember-view reader-text-block__heading-3">10) Repo structure (starter)</h3>
<pre class="brush: plain; title: ; notranslate">
rag/
  docs/                   # your source files
  rag_store/              # generated index + records
  ingest.py
  index.py
  query.py
  evals/
    qa.jsonl              # &#x5B;{&quot;q&quot;:&quot;..&quot;,&quot;a&quot;:&quot;..&quot;,&quot;ids&quot;:&#x5B;...]}]
  server.py               # optional FastAPI
  requirements.txt 
</pre>
<h3 id="ember852" class="ember-view reader-text-block__heading-3">Final thought</h3>
<p id="ember853" class="ember-view reader-text-block__paragraph">RAG works best when it’s <strong>boringly deterministic</strong> around a very flexible LLM. Keep the moving parts few, measure retrieval first, and make “I don’t know” an acceptable, logged outcome. Ship tiny, improve weekly.</p>
</section>
]]></content:encoded>
					
					<wfw:commentRss>https://blog.wasalt.sa/en/build-a-minimal-rag-pipeline-python-faiss-pgvector/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
