<?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[Data & Engineering Log]]></title><description><![CDATA[Honest build logs from projects built on different tech stacks. Why I chose each tool, what broke in production, and what I'd do differently. Architecture decis]]></description><link>https://qdrant.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 28 Oct 2026 20:13:13 GMT</lastBuildDate><atom:link href="https://qdrant.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Embedded retail analytics with DuckDB, dbt, Polars, and FastAPI]]></title><description><![CDATA[How to build a sub-16ms embedded retail analytics engine over 8,494 products and 1,094,411 customer reviews without cloud data warehouse overhead.]]></description><link>https://qdrant.hashnode.dev/embedded-retail-analytics-with-duckdb-dbt-polars-and-fastapi</link><guid isPermaLink="true">https://qdrant.hashnode.dev/embedded-retail-analytics-with-duckdb-dbt-polars-and-fastapi</guid><category><![CDATA[duckDB]]></category><category><![CDATA[dbt]]></category><category><![CDATA[Polars]]></category><category><![CDATA[data-engineering]]></category><dc:creator><![CDATA[Edna Maina]]></dc:creator><pubDate>Tue, 27 Oct 2026 10:36:57 GMT</pubDate><content:encoded><![CDATA[<p>Most small and mid-sized retail datasets fit comfortably on a single laptop, yet teams often rent expensive cloud data warehouses to query a few million rows. The <a href="https://github.com/Julie-t/SME-Retail-Analytics-Engine">Deterministic SME Retail Analytics Engine</a> processes <code>8,494</code> beauty catalogue products and <code>1,094,411</code> customer reviews inside a single local DuckDB file (<code>857,223,168</code> bytes).</p>
<p>Think of <strong>embedded analytics</strong> with DuckDB like SQLite, but built for crunching millions of spreadsheet rows directly inside your Python process instead of paying for a separate database server. On top of DuckDB, we use a <strong>Medallion architecture</strong>—a simple data pipeline with three steps: Raw/Messy (Bronze), Cleaned/Standardized (Silver), and Business-Ready Reports (Gold). We then validate those tables in Polars and serve <code>14</code> read-only FastAPI endpoints alongside a guarded Groq AI assistant.</p>
<h3>System architecture</h3>
<pre><code class="language-plaintext">Kaggle CSVs (8,494 products + 1,094,411 reviews across 5 partitions)
  └── src/ingestion/load_sephora_kaggle_to_duckdb.py
        ├── Verified closed-file backup (.bak)
        └── raw.sephora_product_info / raw.sephora_reviews / raw.raw_products
              └── dbt-duckdb Medallion (16 relations, 119 passing data tests)
                    ├── Bronze: stg_raw_products, stg_raw_reviews
                    ├── Silver: int_product_catalog, int_review_analytics
                    └── Gold:   8 analytics &amp; data-quality marts
                          └── Polars Validation &amp; KPI Readers (src/analytics/)
                                └── FastAPI Serving &amp; File-Stat LRU Cache (app/main.py)
                                      ├── 14 GET /metrics/* &amp; /health endpoints
                                      ├── POST /assistant/ask (Groq tool-calling)
                                      └── Streamlit HTTP Dashboard (dashboard/)
</code></pre>
<p>In the table below, p50 latency means the typical response time for half of all requests, while p95 latency measures the slowest 5% of requests.</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Component</th>
<th>Verified production metric</th>
</tr>
</thead>
<tbody><tr>
<td>Storage</td>
<td><code>data/retail_engine.duckdb</code></td>
<td><code>857,223,168</code> bytes (<code>16</code> relations across <code>raw</code> and <code>main_main</code>)</td>
</tr>
<tr>
<td>Catalogue volume</td>
<td><code>raw.sephora_product_info</code></td>
<td><code>8,494</code> products, <code>304</code> normalized brands, <code>9</code> primary categories</td>
</tr>
<tr>
<td>Review volume</td>
<td><code>raw.sephora_reviews</code></td>
<td><code>1,094,411</code> reviews across <code>5</code> CSV partitions (<code>176</code> monthly buckets)</td>
</tr>
<tr>
<td>Reviewed coverage</td>
<td><code>main_main.mart_review_summary</code></td>
<td><code>2,351</code> reviewed products across <code>142</code> reviewed brands (<code>0</code> orphans)</td>
</tr>
<tr>
<td>Transformation quality</td>
<td><code>dbt_retail/</code></td>
<td><code>119</code> passing dbt tests (<code>PASS=119 WARN=0 ERROR=0 SKIP=0</code>)</td>
</tr>
<tr>
<td>Application test suite</td>
<td><code>tests/</code></td>
<td><code>106</code> backend <code>pytest</code> tests passing (<code>114</code> total with UI client tests)</td>
</tr>
<tr>
<td>Warm serving latency</td>
<td><code>app/</code><a href="http://main.py"><code>main.py</code></a></td>
<td><code>3.665 ms</code> in-process p50 (<code>7.073 ms</code> p95), <code>15.43–15.67 ms</code> loopback HTTP median</td>
</tr>
</tbody></table>
<h3>Atomic DuckDB ingestion with verified pre-load backups</h3>
<p>Here is the problem we are solving: if a CSV ingestion script crashes halfway through loading a million rows, you do not want a half-written, corrupted database file. We combine a verified file backup with an atomic transaction—an all-or-nothing database update where either every row loads cleanly, or the database rewinds as if nothing happened.</p>
<p>This function in <code>src/ingestion/load_sephora_kaggle_to_duckdb.py</code> copies the closed <code>.duckdb</code> file to a timestamped <code>.bak</code> snapshot and opens a test connection to prove the backup is readable before touching the live data:</p>
<pre><code class="language-python"># src/ingestion/load_sephora_kaggle_to_duckdb.py
def _create_verified_backup(database_path: Path, backup_dir: Path | None) -&gt; Path | None:
    if not database_path.exists():
        return None

    resolved_backup_dir = backup_dir or database_path.parent / "backups"
    resolved_backup_dir.mkdir(parents=True, exist_ok=True)
    # Generate a UTC timestamp so backups sort chronologically and never overwrite each other
    timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    backup_path = resolved_backup_dir / f"{database_path.stem}_{timestamp}{database_path.suffix}.bak"
    # copy2 copies both the file bytes and filesystem metadata (timestamps)
    shutil.copy2(database_path, backup_path)

    # Open the backup in read-only mode to verify the file is not corrupted
    with duckdb.connect(str(backup_path), read_only=True) as connection:
        connection.execute("SELECT 1").fetchone()
    return backup_path
</code></pre>
<p>After the backup passes verification, all 8,494 product rows and 1,094,411 review rows load inside a single <code>BEGIN TRANSACTION ... COMMIT</code> block using <code>read_csv(..., all_varchar = true, filename = true)</code>. Loading every raw column as text <code>(all_varchar = true)</code> stops DuckDB from crashing on weird formatting in raw CSVs—such as the unnamed first CSV header (column00)—so we can clean it safely with SQL in the next step.</p>
<h3>Dynamic dbt target binding and Medallion quality diagnosis</h3>
<p>Here is the problem we are solving: if you hardcode <code>database: retail_engine</code> in your dbt config, running tests in a staging environment <code>(retail_engine_staging.duckdb)</code> will still read from your production database by mistake.</p>
<p>Removing the static <code>database:</code> key from <code>dbt_retail/model/staging/schema.yml</code> lets dbt connect dynamically to whichever database file your active profile specifies:</p>
<pre><code class="language-yaml"># dbt_retail/model/staging/schema.yml
version: 2

sources:
  - name: raw
    schema: raw
    # Omitting 'database: retail_engine' here allows --target staging
    # to read from retail_engine_staging.duckdb instead of production.
    tables:
      - name: raw_products
      - name: sephora_product_info
      - name: sephora_reviews
      - name: sephora_ingestion_manifest
</code></pre>
<p>When cleaning data, beginners often run <code>.dropna()</code> or <code>.drop_duplicates()</code>, which silently deletes rows and hides upstream bugs. Instead, our Bronze and Silver models keep every row and attach <code>TRUE/FALSE</code> quality flags (like <code>is_missing_rating</code>).</p>
<p>This query in <code>dbt_retail/model/marts/mart_review_summary.sql</code> uses SQL <code>FILTER (WHERE ...)</code> clauses—which work like a WHERE clause attached to a single <code>AVG()</code> or <code>SUM()</code>—so missing values never drag down the averages:</p>
<pre><code class="language-sql">-- dbt_retail/model/marts/mart_review_summary.sql
SELECT
    COUNT(*)::BIGINT AS total_reviews,
    COUNT(DISTINCT product_id)::BIGINT AS reviewed_products,
    COUNT(DISTINCT brand_key)::BIGINT AS reviewed_brands,
    -- Only average rows that actually have a valid rating
    ROUND(AVG(rating) FILTER (WHERE NOT is_missing_rating), 6) AS avg_rating,
    -- quantile_cont(..., 0.5) calculates the exact median (50th percentile)
    quantile_cont(rating, 0.5) FILTER (WHERE NOT is_missing_rating) AS median_rating,
    -- Calculate recommendation % over the 926,423 valid yes/no answers, ignoring 167,988 blanks
    ROUND(
        AVG(CASE WHEN is_recommended_bool THEN 1.0 ELSE 0.0 END)
            FILTER (WHERE NOT is_missing_is_recommended AND NOT is_invalid_is_recommended),
        6
    ) AS recommendation_rate,
    SUM(total_feedback_count) FILTER (WHERE NOT is_invalid_feedback_counts)::BIGINT AS total_feedback_votes,
    ROUND(AVG(helpfulness_ratio) FILTER (WHERE helpfulness_ratio IS NOT NULL), 6) AS avg_helpfulness_ratio,
    -- NULLIF(..., 0) prevents division-by-zero errors if total votes ever equal 0
    ROUND(
        SUM(total_pos_feedback_count) FILTER (WHERE NOT is_invalid_feedback_counts)::DOUBLE
        / NULLIF(SUM(total_feedback_count) FILTER (WHERE NOT is_invalid_feedback_counts), 0),
        6
    ) AS weighted_helpfulness_rate
FROM {{ ref('int_review_analytics') }}
</code></pre>
<table>
<thead>
<tr>
<th>Metric / Quality Diagnostic</th>
<th>Verified Gold Mart Output</th>
</tr>
</thead>
<tbody><tr>
<td>Average / median rating</td>
<td><code>4.2992</code> (<code>4.299158</code>) mean, <code>5.0</code> median</td>
</tr>
<tr>
<td>Recommendation rate</td>
<td><code>84.00%</code> (<code>0.839962</code> over <code>926,423</code> valid values; <code>167,988</code> missing)</td>
</tr>
<tr>
<td>Helpfulness votes</td>
<td><code>4,571,493</code> total votes (<code>76.78%</code> unweighted avg, <code>78.58%</code> weighted)</td>
</tr>
<tr>
<td>Monthly trend buckets</td>
<td><code>176</code> months (<code>2008-08</code> through <code>2023-03</code>)</td>
</tr>
<tr>
<td>Catalogue quality flags</td>
<td><code>83</code> duplicate brand/name candidates, <code>945</code> missing ingredient texts</td>
</tr>
<tr>
<td>Review referential integrity</td>
<td><code>0</code> orphan review references across <code>1,094,411</code> rows</td>
</tr>
</tbody></table>
<h2><strong>Polars schema validation and FastAPI file-metadata caching</strong></h2>
<p>Here is the problem we are solving: before sending numbers to an API or dashboard, we want a fast safety check in Python to confirm that no boolean flags turned into <code>NULL</code> and no required columns disappeared.</p>
<p>This check in <code>src/analytics/catalog_</code><a href="http://metrics.py"><code>metrics.py</code></a> loads the dbt output into a Polars DataFrame (a fast, strictly typed alternative to Pandas) and raises a <code>ValueError</code> if any quality flag contains nulls:</p>
<pre><code class="language-python"># src/analytics/catalog_metrics.py
for boolean_column in (
    "is_duplicate_candidate",
    "is_missing_brand",
    "is_missing_ingredients",
    "is_missing_rating",
    "is_missing_reviews",
    "has_any_quality_issue",
):
    # .null_count() returns how many empty/NULL cells exist in that column
    null_count = int(dataframe.get_column(boolean_column).null_count())
    if null_count &gt; 0:
        raise ValueError(f"Column '{boolean_column}' contains {null_count} null values.")
</code></pre>
<p>Next, we need the API to respond in milliseconds without re-reading the 857 MB DuckDB file on every click. We use an LRU (Least Recently Used) cache keyed on file metadata—think of it like keeping a sticky note of the last answer on your desk, and only recalculating it when the database file's "last modified" timestamp (st_mtime_ns) or byte size (st_size) changes.</p>
<p>This code in app/main.py wires that cache to FastAPI and returns an HTTP 503 (Service Unavailable) status if the database is missing or fails validation:</p>
<pre><code class="language-python"># app/main.py
# @lru_cache stores the last 8 results in memory keyed by the 3 arguments below
@lru_cache(maxsize=8)
def _load_cached_review_bundle(
    database_path_str: str,
    modified_time_ns: int,
    file_size_bytes: int,
) -&gt; ReviewAnalyticsBundle:
    # modified_time_ns and file_size_bytes only exist to bust the cache when the file changes
    del modified_time_ns, file_size_bytes
    return calculate_review_analytics_bundle(Path(database_path_str))


def get_review_bundle() -&gt; ReviewAnalyticsBundle:
    database_path, database_stat = _get_database_signature()
    try:
        return _load_cached_review_bundle(
            str(database_path),
            database_stat.st_mtime_ns,  # Nanosecond timestamp of last file update
            database_stat.st_size,      # Exact file size in bytes
        )
    except ValueError as exc:
        logger.error("Review dataset readiness or validation error: %s", exc)
        raise HTTPException(
            status_code=503,
            detail="Analytics dataset is not ready. Run the ingestion and dbt pipeline first.",
        ) from exc
</code></pre>
<p>On a cold start (the first request when the cache is empty), reading from DuckDB takes ~227 ms. On subsequent warm requests, FastAPI returns the cached result in 3.665 ms p50 in-process (15.43–15.67 ms over local HTTP).</p>
<h3>Allowlisted Groq tool-calling and commercial refusal guardrails</h3>
<p>Here is the problem we are solving: if you let an LLM write raw SQL against your database (Text-to-SQL), it can hallucinate column names, write slow joins, or invent metrics that do not exist in your data. Instead, we use allowlisted tool-calling—giving the AI a vending-machine keypad of 12 pre-written Python functions (<code>AnalyticsOperation</code>) so every number comes straight from our tested Polars/DuckDB readers.</p>
<p>We also block out-of-scope commercial questions before calling Groq. Because public Sephora data has product prices and reviews—but no order receipts or warehouse logs—this regex filter in <code>src/ai/assistant_service.py</code> catches questions about revenue, sales volume, profit, or inventory and refuses them immediately:</p>
<pre><code class="language-python"># src/ai/assistant_service.py
# Each tuple pairs a category label with a compiled case-insensitive regex pattern (\b = word boundary)
_COMMERCIAL_OUT_OF_SCOPE_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
    ("revenue", re.compile(r"\brevenue\b", re.IGNORECASE)),
    ("sales", re.compile(r"\b(total\s+sales|sales\s+volume|units?\s+sold|gross\s+sales|net\s+sales)\b", re.IGNORECASE)),
    ("profit", re.compile(r"\b(profit|profits|margin|margins|ebitda)\b", re.IGNORECASE)),
    ("inventory", re.compile(r"\b(inventory|stock\s+level|stockout|warehouse\s+stock|on[-\s]?hand)\b", re.IGNORECASE)),
    ("orders", re.compile(r"\b(order\s+count|orders\s+placed|checkout|cart\s+size|aov)\b", re.IGNORECASE)),
    ("customers_spend", re.compile(r"\b(customer\s+lifetime\s+value|ltv|cac|arpu|spend)\b", re.IGNORECASE)),
)
</code></pre>
<h3>Engineering challenges and fixes</h3>
<ul>
<li>Challenge — Static dbt database binding polluted production during staging tests: Having <code>database: retail_engine</code> in <code>dbt_retail/model/staging/schema.yml</code> caused staging builds (<code>--target staging</code>) to read from the production database.<ul>
<li>Fix: Removed the static <code>database:</code> property from schema.yml and added <code>scripts/build_staging_db.py</code> to verify that retail_engine_staging.duckdb builds and passes all 119 dbt tests.</li>
</ul>
</li>
<li><strong>Challenge — Groq</strong> <code>openai/gpt-oss-20b</code> <strong>returned empty message content on strict JSON prompts:</strong> Early synthesis prompts constrained token budgets too tightly, causing reasoning models to exhaust completion tokens before emitting final JSON.<ul>
<li><strong>Fix:</strong> Added a two-stage model invocation with deterministic fallback in <code>GroqAnalyticsService</code>: even if Groq rate-limits (<code>RateLimitError</code>), times out (<code>APITimeoutError</code>), or returns malformed output, <code>_build_deterministic_answer</code> still returns the exact pre-computed KPI payload alongside a sanitized <code>warning</code> field.</li>
</ul>
</li>
<li><strong>Challenge — Repeated DuckDB scans on concurrent dashboard refreshes:</strong> Opening a new DuckDB connection and scanning <code>1,094,411</code> review rows on every Streamlit tab switch added unnecessary I/O overhead.<ul>
<li><strong>Fix:</strong> Keyed FastAPI's <code>@lru_cache(maxsize=8)</code> on <code>(str(database_path), database_</code><a href="http://stat.st"><code>stat.st</code></a><code>_mtime_ns, database_</code><a href="http://stat.st"><code>stat.st</code></a><code>_size)</code>, cutting warm endpoint latency from <code>~227 ms</code> cold start to <code>3.665 ms</code> p50 in-process.</li>
</ul>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Why We Chose Groq for High-Speed Text and Voice Processing.]]></title><description><![CDATA[Build sub-second AI pipelines with Groq LPUs and FastAPI. Handle LLM synthesis and Whisper voice transcription within tight webhook latency constraints.]]></description><link>https://qdrant.hashnode.dev/why-we-chose-groq-for-high-speed-text-and-voice-processing</link><guid isPermaLink="true">https://qdrant.hashnode.dev/why-we-chose-groq-for-high-speed-text-and-voice-processing</guid><category><![CDATA[groq]]></category><category><![CDATA[Python]]></category><category><![CDATA[FastAPI]]></category><category><![CDATA[llm]]></category><category><![CDATA[Whisper large-v3 Turbo]]></category><dc:creator><![CDATA[Edna Maina]]></dc:creator><pubDate>Wed, 07 Oct 2026 15:25:26 GMT</pubDate><content:encoded><![CDATA[<p>In conversational customer service, <strong>latency is the difference between trust and abandonment</strong>.</p>
<p>When a member sends a question to their financial cooperative over WhatsApp, they expect an experience that feels like texting a human officer. If an AI assistant takes 4 to 6 seconds to respond, the illusion collapses: users wonder if their message failed to deliver or if the service is down.</p>
<p>To deliver truly snappy conversational turns, our architecture standardizes on <strong>Groq Cloud</strong> as our unified inference engine. In this post, we explain how Groq’s LPU hardware, dual-modality support (Text + Whisper), and sub-second execution transform our production pipeline.</p>
<h2><strong>1. The Latency Trap in Conversational WhatsApp Apps</strong></h2>
<p>Most cloud LLM providers run inference on clusters optimized for batched training throughput rather than single-stream latency. A standard 70B-parameter model running on typical GPU endpoints generates 20 to 35 tokens per second. For a 200-token answer, that means six to ten seconds of generation time. Because WhatsApp does not support token streaming in chat bubbles, the webhook must wait for completion before dispatching the response.</p>
<pre><code class="language-plaintext">Standard GPU Cloud:
[User Sends Query] ──────▶ [Wait 4-6 seconds...] ──────▶ [Single Answer Arrives]
(Member assumes the bot crashed or network stalled)

Groq LPU Engine:
[User Sends Query] ─▶ [~300ms Inference] ─▶ [Answer Delivered]
(Immediate conversational turn)
</code></pre>
<p>Groq’s custom silicon—the <strong>LPU™ (Language Processing Unit)</strong>—is purpose-built for sequential inference. By utilizing high-bandwidth SRAM and deterministic tensor execution, Groq generates tokens at speeds from <strong>500 to 1,000 tokens per second</strong> depending on model size.</p>
<p>For a 200-token financial explanation, raw generation takes 200–400ms. Combined with a ~120ms Time-to-First-Token (TTFT), the entire completion finishes in approximately 300 milliseconds.</p>
<h2><strong>2. One Engine, Multiple Modalities.</strong></h2>
<p>Using Groq as a single provider across text and speech keeps our client infrastructure and latency budgets unified:</p>
<pre><code class="language-plaintext">sequenceDiagram
    autonumber
    actor Member as WhatsApp Member
    participant Hook as FastAPI Webhook
    participant GroqAudio as Groq Audio (Whisper)
    participant Core as RAG &amp; Verification
    participant GroqLLM as Groq LLM (gpt-oss-20b)

    alt Member sends Voice Note
        Member-&gt;&gt;Hook: Audio Voice Note (.ogg)
        Hook-&gt;&gt;GroqAudio: Transcribe with Swahili/Sheng Bias
        GroqAudio--&gt;&gt;Hook: Transcribed Text (~400ms)
    else Member sends Text
        Member-&gt;&gt;Hook: Text Query
    end

    Hook-&gt;&gt;Core: Fast Intent Filter &amp; Local RAG Retrieval (~20ms)
    Core-&gt;&gt;GroqLLM: Prompt + Retrieved Context
    GroqLLM--&gt;&gt;Core: Synthesized Answer (~280ms)
    Core-&gt;&gt;Core: Deterministic Fact Verification (~5ms)
    Core-&gt;&gt;Member: Verified WhatsApp Reply (Text: ~350ms, Voice: ~900ms)
</code></pre>
<h3>A. Text &amp; Triage: <code>openai/gpt-oss-20b</code></h3>
<p>In our core configuration (<code>app/config/settings.py</code>), we deploy <code>openai/gpt-oss-20b</code> with low reasoning effort (<code>GROQ_REASONING_EFFORT = "low"</code>).</p>
<p>Operating as a Mixture-of-Experts (MoE) with 3.6 billion active parameters per forward pass, <code>gpt-oss-20b</code> generates at <strong>~1,000 tokens per second</strong> on Groq LPUs. This delivers rich policy synthesis and intent classification without spending the token budget or runtime latency of 70B+ dense models. For complex offline evaluation or deep reasoning chains, the architecture can swap in <code>openai/gpt-oss-120b</code> (~500 tokens/sec) using the same provider adapter.</p>
<h3>B. Voice Messaging: Fast Whisper for Audio Queries</h3>
<p>Many cooperative members prefer sending voice notes rather than typing text on a mobile keyboard.</p>
<p>Incoming <code>.ogg</code> voice notes are forwarded to Groq’s audio endpoint running <code>whisper-large-v3-turbo</code>. A 10-second voice note is transcribed in approximately <strong>350–450 milliseconds</strong>.</p>
<p>To support the regional linguistic reality of Kenya, our audio client (<code>app/ai/providers/groq_audio.py</code>) primes Whisper with a trilingual prompt bias:</p>
<pre><code class="language-python"># Contextual vocabulary biasing for East African SACCOs
VOICE_PROMPT_BIAS = (
    "Mazungumzo ya mwanachama wa SACCO kuhusu mikopo, hisa, akiba, na huduma. "
    "Code-switching in English, Kiswahili, and Kenyan Sheng: "
    "chama, ganji, bob, chapaa, mkopo, mikopo, bodaboda, biashara, "
    "loan balance, emergency loan, dividends, shares, repayment, M-Pesa."
)
</code></pre>
<p>This ensures localized financial terms like <em>ganji</em> or <em>chapaa</em> (money) and <em>mkopo</em> (loan) transcribe accurately instead of getting garbled by standard English models.</p>
<h2><strong>3. Engineering for Safety: Rate Limits &amp; Bounded Retries</strong></h2>
<p>hen evaluating an inference provider, two distinct metrics matter: <strong>execution latency</strong> (how fast it returns) and <strong>financial cost</strong> (how much it bills).</p>
<h3>A. API Pricing Economics</h3>
<p>Self-hosting a 70B or 120B model on dedicated cloud GPUs costs $1.50 to $3.00 per hour ($1,000 to $2,000+ monthly), even when idle. Groq bills strictly per token and audio hour:</p>
<table>
<thead>
<tr>
<th>Model / Service</th>
<th>Input Price</th>
<th>Output Price</th>
<th>Cost per Average Query</th>
</tr>
</thead>
<tbody><tr>
<td><code>openai/gpt-oss-20b</code></td>
<td>$0.075 / 1M tokens</td>
<td>$0.30 / 1M tokens</td>
<td><strong>~$0.00008</strong> (300 in / 200 out)</td>
</tr>
<tr>
<td><code>openai/gpt-oss-120b</code></td>
<td>$0.15 / 1M tokens</td>
<td>$0.60 / 1M tokens</td>
<td><strong>~$0.00017</strong> (300 in / 200 out)</td>
</tr>
<tr>
<td><code>whisper-large-v3-turbo</code></td>
<td>$0.04 / hour of audio</td>
<td>—</td>
<td><strong>~$0.00011</strong> (per 10s voice note)</td>
</tr>
</tbody></table>
<p>At these rates, 10,000 text inquiries cost less than <strong>$1.00</strong>, and 5,000 voice queries cost roughly <strong>$0.55</strong>.</p>
<h3>B. Engineering for the 8,000 TPM Rate Limit</h3>
<p>In free and developer tiers, Groq enforces an <strong>8,000 Tokens Per Minute (TPM)</strong> limit and a <strong>14,400 Tokens Per Day (TPD)</strong> ceiling. Running smaller, focused models like <code>gpt-oss-20b</code> keeps each turn around 400–500 total tokens, allowing steady conversational throughput.</p>
<p>Our provider (<code>app/ai/providers/groq.py</code>) implements three defensive patterns:</p>
<ol>
<li><p><strong>Differentiate Token-Per-Day (TPD) vs. Token-Per-Minute (TPM):</strong> If daily quota is exhausted, fail fast immediately. Never sleep-loop when quota will not recover for hours.</p>
</li>
<li><p><strong>Respect</strong> <code>Retry-After</code> <strong>Headers:</strong> Parse the exact reset interval from the HTTP response headers or error body.</p>
</li>
<li><p><strong>Bounded Jittered Backoff:</strong> Retry transient rate spikes without blocking webhook worker threads indefinitely.</p>
</li>
</ol>
<pre><code class="language-python">import asyncio
import httpx
from typing import Any


class GroqClient:
    def __init__(self, api_key: str, model: str = "openai/gpt-oss-20b"):
        self.api_key = api_key
        self.model = model
        self.base_url = "https://api.groq.com/openai/v1/chat/completions"

    async def generate_safe(
        self, 
        messages: list[dict[str, str]], 
        max_retries: int = 2
    ) -&gt; str:
        """Call Groq with strict error envelopes and bounded rate-limit handling."""
        headers = {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
        }
        payload = {
            "model": self.model,
            "messages": messages,
            "max_tokens": 400,
            "temperature": 0.2,
        }

        async with httpx.AsyncClient(timeout=10.0) as client:
            for attempt in range(max_retries + 1):
                try:
                    response = await client.post(
                        self.base_url, json=payload, headers=headers
                    )
                    
                    if response.status_code == 429:
                        error_text = response.text.lower()
                        # Fail-fast if daily quota (TPD) is blown
                        if "tokens per day" in error_text or "tpd" in error_text:
                            raise RuntimeError("Daily token quota exhausted. Cannot retry.")
                        
                        retry_after = float(response.headers.get("retry-after", 1.5))
                        if attempt &lt; max_retries:
                            await asyncio.sleep(min(retry_after, 3.0))
                            continue
                            
                    response.raise_for_status()
                    data = response.json()
                    return data["choices"][0]["message"]["content"]

                except httpx.TimeoutException:
                    if attempt &lt; max_retries:
                        await asyncio.sleep(1.0)
                        continue
                    raise RuntimeError("Groq inference timed out.")

        raise RuntimeError("Groq inference failed after bounded retries.")
</code></pre>
<hr />
<h2>4. Why Speed Enables Deterministic Verification</h2>
<p>The primary advantage of high-speed inference is latency headroom for safety checks.</p>
<p>Financial answers must be verified before delivery. When inference takes four seconds, there is no time left to validate output against source data within the webhook window.</p>
<p>Because Groq inference returns in ~280ms, our pipeline has ample headroom within a 1-second webhook window to execute a complete multi-step safety harness:</p>
<table>
<thead>
<tr>
<th>Pipeline Step</th>
<th>Execution Latency</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><strong>1. Intent Routing &amp; Triage</strong></td>
<td>~5ms</td>
<td>In-memory regex and rule-based fast path</td>
</tr>
<tr>
<td><strong>2. Local Embedding &amp; Qdrant Search</strong></td>
<td>~15ms</td>
<td>In-process <code>all-MiniLM-L6-v2</code> + Docker Qdrant filter</td>
</tr>
<tr>
<td><strong>3. Groq LLM Inference (</strong><code>gpt-oss-20b</code><strong>)</strong></td>
<td><strong>~280ms</strong></td>
<td>TTFT (<del>120ms) + 200 tokens at ~1,000 tps (</del>160ms)</td>
</tr>
<tr>
<td><strong>4. Fact &amp; Grounding Verification</strong></td>
<td>~5ms</td>
<td>Deterministic regex matching against retrieved chunks</td>
</tr>
<tr>
<td><strong>Total Text Pipeline</strong></td>
<td><strong>~305ms</strong></td>
<td><strong>Delivered to WhatsApp in well under 500ms</strong></td>
</tr>
</tbody></table>
<h3>What About Voice Notes?</h3>
<p>For incoming voice notes, audio processing adds two steps before text RAG begins:</p>
<ul>
<li><p><strong>Audio Download from Twilio:</strong> ~150–250ms (network-dependent media fetch).</p>
</li>
<li><p><strong>Groq Whisper Transcription:</strong> ~350–450ms (<code>whisper-large-v3-turbo</code>).</p>
</li>
</ul>
<p>Adding the 305ms text pipeline results in an end-to-end voice turnaround of <strong>~800–1,000ms</strong>, remaining safely under the 1.5-second messaging threshold.</p>
<hr />
<h2>Why Groq, trade-offs, and production hurdles</h2>
<table>
<thead>
<tr>
<th>Approach</th>
<th>Trade-offs</th>
<th>Decision</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Cloud / Traditional</strong> (OpenAI / GPUs)</td>
<td>2–5s generation latency and higher cost; cannot stream inside WhatsApp.</td>
<td>Rejected; breaches webhook response budget.</td>
</tr>
<tr>
<td><strong>Self-hosted / Local</strong> (vLLM / Ollama)</td>
<td>Requires expensive GPU hardware (H100/A100) and operational maintenance.</td>
<td>Rejected; prohibitive infrastructure overhead.</td>
</tr>
<tr>
<td><strong>Selected Solution</strong> (Groq LPUs)</td>
<td>Restricted to compiled open-weights models and developer rate limits.</td>
<td>Selected; ~300ms multimodal inference at minimal cost.</td>
</tr>
</tbody></table>
<ul>
<li><p><strong>[Symptom/Challenge]:</strong> Concurrent traffic bursts triggered HTTP 429 rate limit errors against the 8,000 TPM ceiling.</p>
</li>
<li><p><strong>[The Fix]:</strong> Added bounded jittered retries and daily quota fail-fast logic in <code>app/ai/providers/groq.py</code>.</p>
</li>
<li><p><strong>[Symptom/Challenge]:</strong> Transient WAN latency spikes risked exceeding Twilio's strict webhook timeout thresholds.</p>
</li>
<li><p><strong>[The Fix]:</strong> Wrapped calls in 10s HTTPX timeouts with human-escalation fallbacks via <code>FallbackHandler.provider_failure()</code>.</p>
</li>
</ul>
<hr />
<h2>6. Implementation summary</h2>
<ul>
<li><p><strong>Sub-second response budget:</strong> Operating at ~1,000 tokens/sec on Groq LPUs completes 200-token text generation in ~280ms, leaving over 600ms of headroom for retrieval and validation.</p>
</li>
<li><p><strong>Clear cost separation:</strong> High speed does not mean high bills. At \(0.075 / 1M input tokens and \)0.04 / hour of audio, typical member inquiries cost fractions of a cent ($0.00008).</p>
</li>
<li><p><strong>Unified text and voice endpoints:</strong> Running both <code>gpt-oss-20b</code> and <code>whisper-large-v3-turbo</code> on Groq consolidates credentials and keeps latency predictable across chat bubbles and voice clips.</p>
</li>
<li><p><strong>Defensive rate limiting:</strong> Enforce bounded backoff on 429 responses, parse <code>Retry-After</code> headers, and distinguish transient TPM spikes from permanent daily quota exhaustion (TPD).</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Why We Run Local Qdrant in Docker for Multi-Tenant Financial Knowledge]]></title><description><![CDATA[Learn how to run local Qdrant in Docker with FastAPI and Sentence Transformers for multi-tenant vector retrieval with zero external API costs or latency.]]></description><link>https://qdrant.hashnode.dev/why-we-run-local-qdrant-in-docker-for-multi-tenant-financial-knowledge</link><guid isPermaLink="true">https://qdrant.hashnode.dev/why-we-run-local-qdrant-in-docker-for-multi-tenant-financial-knowledge</guid><category><![CDATA[qdrant]]></category><category><![CDATA[Python]]></category><category><![CDATA[Docker]]></category><category><![CDATA[FastAPI]]></category><category><![CDATA[RAG ]]></category><dc:creator><![CDATA[Edna Maina]]></dc:creator><pubDate>Wed, 07 Oct 2026 09:48:47 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac60531e33de2d190701ed1/33b143ad-671c-4792-81be-73a180a1ab43.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Deploying a Retrieval-Augmented Generation (RAG) assistant on WhatsApp leaves little room for network latency. If a user asks about loan terms, the webhook handler must complete search and generation before the messaging platform times out or the user drops off.</p>
<p>Managed cloud vector databases introduce remote HTTP round-trips and fixed monthly subscription fees. For small-to-medium fintech deployments, running <strong>Qdrant locally in Docker Compose</strong> alongside an in-process <strong>Sentence Transformers</strong> model removes external network transit entirely.</p>
<p>This post covers our local vector architecture, how we enforce tenant isolation using payload filters, and why account balances never touch the vector index.</p>
<h2><strong>1. The Latency Problem with Cloud Vector DBs</strong></h2>
<p>A typical cloud-hosted vector database setup adds multiple network hops:</p>
<pre><code class="language-plaintext">[User on WhatsApp] 
      │ 
      ▼
[FastAPI Server] ──(Remote API / 120ms)──▶ [Cloud Embeddings API]
      │
      ▼
 (Remote API / 80ms)
      │
      ▼
[Cloud Vector DB]
</code></pre>
<p>Every incoming question makes multiple remote HTTP round-trips before the application can begin generating a response. If your WhatsApp webhook has a strict timeout, spending 200–400ms on network transit alone burns valuable compute budget.</p>
<p>Managed vector clusters also charge flat minimums regardless of dataset size:</p>
<ul>
<li><p>A cooperative typically has <strong>50 to 500 policy documents</strong> (bylaws and credit terms).</p>
</li>
<li><p>That produces roughly <strong>1,000 to 10,000 text chunks</strong>.</p>
</li>
<li><p>Storing 10,000 384-dimensional vectors requires less than <strong>50 MB of RAM</strong>.</p>
</li>
</ul>
<p>Paying $70–$100/month for a managed cluster to store 50 megabytes of data is unnecessary overhead.</p>
<hr />
<h2>2. The Local Vector Stack</h2>
<p>We run a self-contained vector stack directly alongside the core API.</p>
<pre><code class="language-mermaid">graph TD
    User["WhatsApp Member"] --&gt;|Question| API["FastAPI Application"]
    API --&gt;|1. Generate 384-dim Vector in ~15ms| LocalEmbed["Local SentenceTransformer&lt;br/&gt;(all-MiniLM-L6-v2)"]
    API --&gt;|2. Query with sacco_id Filter| LocalQdrant["Local Qdrant&lt;br/&gt;(Docker container on port 6333)"]
    LocalQdrant --&gt;|3. Matched Chunks| API
    API --&gt;|4. Grounded Synthesis| LLM["Groq LPU Inference"]
</code></pre>
<h3>Running Qdrant via Docker Compose</h3>
<p>Our <code>docker_compose.yml</code> runs the official Qdrant image with durable volume storage:</p>
<pre><code class="language-yaml">services:
  qdrant:
    image: qdrant/qdrant:v1.13.2
    ports:
      - "6333:6333"  # HTTP REST API
      - "6334:6334"  # gRPC internal interface
    volumes:
      - qdrant_storage:/qdrant/storage

volumes:
  qdrant_storage:
</code></pre>
<h3>Local Embeddings: <code>all-MiniLM-L6-v2</code></h3>
<p>In <code>app/ai/rag/embeddings.py</code>, we load Hugging Face’s <code>sentence-transformers/all-MiniLM-L6-v2</code>:</p>
<ul>
<li><p><strong>Dimensionality:</strong> 384 dense floating-point dimensions.</p>
</li>
<li><p><strong>Latency:</strong> ~10–18ms per query on a commodity modern CPU without a GPU.</p>
</li>
<li><p><strong>Cost:</strong> $0.00.</p>
</li>
<li><p><strong>Reliability:</strong> Zero outbound network calls and no external API rate limits.</p>
</li>
</ul>
<p>On application startup, the service verifies that the Qdrant collection dimension matches 384 (<code>Distance.COSINE</code>), ensuring index consistency.</p>
<hr />
<h2>3. Multi-Tenancy Made Simple: Payload Filtering</h2>
<p>Multi-tenant retrieval does not require separate vector collections or isolated database instances for each customer.</p>
<p>In Qdrant, we use <strong>Payload Metadata Filtering</strong>. Every document chunk stored in the <code>sacco_knowledge</code> collection includes metadata:</p>
<ul>
<li><p><code>sacco_id</code> (e.g., <code>sacco_harambee</code>, <code>sacco_stima</code>)</p>
</li>
<li><p><code>language</code> (e.g., <code>sw</code> for Swahili, <code>en</code> for English)</p>
</li>
<li><p><code>topic</code> (e.g., <code>loans</code>, <code>emergency_fund</code>)</p>
</li>
</ul>
<p>Because Qdrant indexes payloads alongside vectors using HNSW (Hierarchical Navigable Small World) graphs with payload constraints, filtering by <code>sacco_id</code> occurs <strong>during</strong> vector traversal. Tenant A cannot see Tenant B's data under any condition.</p>
<p>Here is a simplified Python example adapted from our production vector adapter (<code>app/ai/rag/vector_store.py</code>):</p>
<pre><code class="language-python">from typing import Any
from qdrant_client import AsyncQdrantClient, models


async def search_tenant_knowledge(
    client: AsyncQdrantClient,
    collection_name: str,
    query_vector: list[float],
    sacco_id: str,
    language: str = "en",
    top_k: int = 3,
) -&gt; list[dict[str, Any]]:
    """Retrieve policy chunks strictly scoped to a specific SACCO tenant."""
    
    # Construct tenant and language isolation filter
    tenant_filter = models.Filter(
        must=[
            models.FieldCondition(
                key="sacco_id",
                match=models.MatchValue(value=sacco_id),
            ),
            models.FieldCondition(
                key="language",
                match=models.MatchValue(value=language),
            ),
        ]
    )

    # Search with payload filter applied at index traversal time
    search_result = await client.query_points(
        collection_name=collection_name,
        query=query_vector,
        query_filter=tenant_filter,
        limit=top_k,
        with_payload=True,
    )

    return [
        {
            "title": hit.payload.get("title"),
            "content": hit.payload.get("content"),
            "score": hit.score,
            "source": hit.payload.get("source"),
        }
        for hit in search_result.points
    ]
</code></pre>
<p>A single Qdrant container serves multiple cooperatives while keeping tenant isolation deterministic.</p>
<hr />
<h2>4. The Cardinal Rule: Why Balances Never Touch Qdrant</h2>
<p>A common pitfall in financial RAG applications is loading member balances and ledger records into a vector store.</p>
<p>Consider this query from a member:</p>
<blockquote>
<p><em>"What is my current loan balance?"</em></p>
</blockquote>
<p>If routed to a vector index, the engine might retrieve a document stating:</p>
<blockquote>
<p><em>"Emergency loans have a maximum ceiling of KES 50,000."</em></p>
</blockquote>
<p>An LLM synthesizing that context can easily generate: <em>"Your loan balance is KES 50,000."</em> In financial software, this is an unacceptable hallucination.</p>
<h3>The Rule</h3>
<blockquote>
<p><strong>Vector Search is for unstructured policy knowledge.</strong><br /><strong>Relational SQL is for member state and balances.</strong></p>
</blockquote>
<p>In our architecture (<code>app/ai/intent_router.py</code>), incoming messages undergo deterministic triage before vector retrieval is evaluated:</p>
<pre><code class="language-plaintext">[Incoming WhatsApp Query]
            │
   Is it an account query?
   ("my balance", "how much do I owe?")
         /       \
      YES         NO
      /             \
 [PostgreSQL / Core API]    [Local Qdrant RAG]
  - Exact KES figures        - Bylaws &amp; Loan rules
  - Deterministic SQL        - Semantic search
  - Zero hallucination       - Source-grounded synthesis
</code></pre>
<p>Balance and payment queries bypass Qdrant completely, reading directly from authenticated PostgreSQL ledger tables. Qdrant is reserved for unstructured retrieval: locating the exact text of official policy terms.</p>
<hr />
<h2>Why Qdrant, trade-offs, and production hurdles</h2>
<table>
<thead>
<tr>
<th>Approach</th>
<th>Trade-offs</th>
<th>Decision</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Cloud / Traditional</strong> (Pinecone)</td>
<td>High latency (80–150ms network hops) and fixed monthly cluster fees.</td>
<td>Rejected; burns webhook latency budget.</td>
</tr>
<tr>
<td><strong>Self-hosted / Local</strong> (pgvector)</td>
<td>Vector indexing contends for CPU and memory with transactional SQL queries.</td>
<td>Rejected; couples ledger and RAG loads.</td>
</tr>
<tr>
<td><strong>Selected Solution</strong> (Local Qdrant)</td>
<td>Requires host RAM allocation and self-managed container volume persistence.</td>
<td>Selected; sub-20ms HNSW search with payload tenant isolation.</td>
</tr>
</tbody></table>
<ul>
<li><p><strong>[Symptom/Challenge]:</strong> PyTorch model initialization caused a &gt;1.5s first-query latency spike on boot.</p>
</li>
<li><p><strong>[The Fix]:</strong> Implemented <code>_run_warmup()</code> inside <code>app/main.py</code> lifespan to pre-load embeddings before serving traffic.</p>
</li>
<li><p><strong>[Symptom/Challenge]:</strong> Dense embeddings scored 68.18% Recall@1, occasionally missing exact numeric clauses.</p>
</li>
<li><p><strong>[The Fix]:</strong> Bounded retrieval to Top-3 chunks and enforced <code>RAG_MIN_SCORE = 0.20</code> in <code>app/ai/rag/vector_store.py</code>.</p>
</li>
</ul>
<hr />
<h2>6. Implementation summary</h2>
<ul>
<li><p><strong>Local container deployment:</strong> Run Qdrant with persistent volumes (<code>qdrant/qdrant:v1.13.2</code> on port 6333). Small vector corpora (under 100,000 chunks) fit comfortably in local memory.</p>
</li>
<li><p><strong>In-process embeddings:</strong> Generate vectors locally with <code>all-MiniLM-L6-v2</code> (<code>dim=384</code>). This runs in ~15ms on CPU and eliminates third-party embedding API costs.</p>
</li>
<li><p><strong>Single-collection multi-tenancy:</strong> Use Qdrant payload filters on <code>sacco_id</code> and <code>language</code> to enforce tenant isolation at query time without managing multiple vector databases.</p>
</li>
<li><p><strong>Strict query boundary:</strong> Route account balances and loan amounts to relational SQL. Keep vector search strictly scoped to policy and bylaws retrieval.</p>
</li>
</ul>
]]></content:encoded></item></channel></rss>