<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
    <channel>
        <title>SysAdmin Journal · Build with AI</title>
        <link>https://sysadmin-journal.com/tag/build-with-ai</link>
        <description>Posts tagged with Build with AI</description>
        <language>en</language>
        <lastBuildDate>Sun, 17 May 2026 09:45:52 +0000</lastBuildDate>
        <atom:link href="https://sysadmin-journal.com/tag/build-with-ai/rss" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        <item>
            <title>Document AI &amp; RAG for a Newspaper Archive</title>
            <link>https://sysadmin-journal.com/document-ai-rag-for-a-newspaper-archive</link>
            <guid isPermaLink="true">https://sysadmin-journal.com/document-ai-rag-for-a-newspaper-archive</guid>
            <pubDate>Sun, 17 May 2026 09:45:52 +0000</pubDate>
            <dc:creator>Ish Sookun</dc:creator>
            <category>Build with AI</category>
            <description>A Saturday morning of workshops and corridor conversations with Mauritius&#039;s developer community at SWAN HQ in Port-Louis. My own session was on Document AI and RAG for a historical newspaper archive — turning a 1955 Le Figaro into something a researcher can actually query.</description>
            <media:content url="https://sysadmin-journal.com/content/images/2026/05/build-with-ai-2026-opening.jpeg" medium="image" />
            <content:encoded><![CDATA[<p>On Saturday May 9 2026, I participated in the <a href="https://gdg.community.dev/events/details/google-gdg-mauritius-presents-build-with-ai-2026" target="_blank">Google Build with AI</a> event organised by GDG Mauritius and hosted by SWAN, at their Port-Louis Head-Office. 36 people attended the event. My presentation was titled &quot;Document AI &amp; RAG for a Newspaper Archive.&quot; I started the talk with a soft &quot;Hello&quot; and pulled up the Google Chrome browser on the screen with a tab showing Gemini. It had a question, &quot;What did Le Figaro write about Bao Dai in 1955?&quot;</p>
<blockquote>
<p><em>There's no precise search result for 'What did Le Figaro write about Bao Đại in 1955?'. Let's broaden the search to look at French newspapers covering Bao Dai in late 1955, particularly around the October 1955 referendum where Ngo Dinh Diem deposed him.</em></p>
</blockquote>
<div>
<img src="https://sysadmin-journal.com/content/images/2026/05/gemini-answering-question-about-le-figaro.jpg" alt="Gemini answering the question about Le Figaro and Bao Dai." />
</div>
<p>I let that sink in for a moment. Then I switched to my terminal and ran a small Python script — same question, against a tool I had built and trained on actual 1955 issues of Le Figaro. The answer came back in about four seconds. Concise. Specific. Footnoted, with each citation linking back to the precise page in the original PDF where the claim came from.</p>
<div>
<img src="https://sysadmin-journal.com/content/images/2026/05/build-with-ai-ish-sookun-running-script.jpeg" alt="Running the query using a Python script." />
</div>
<p>Different tool. Different answer. Same question.</p>
<p>That contrast was the whole reason I was doing that presentation.</p>
<h2>A quick note on framing</h2>
<p>I opened the talk by introducing myself and where I work — La Sentinelle Ltd, publisher of <em>l'express</em>, <em>5-Plus</em>, <em>Business Magazine</em>, <em>Turf Magazine</em>, and a few other Mauritian titles. And I was up front about one thing: <strong>this is a personal hobby project.</strong> Nights and weekends, no La Sentinelle mandate. If it grows into something the company or other regional publishers want to adopt one day, brilliant. For now it's just me, a laptop, and a stubborn belief that queryable newspaper archives are worth building.</p>
<h2>Why old newspapers are hard</h2>
<p>Generally-available chatbots are excellent at the surface of public knowledge. Ask Gemini about the 1955 Vietnamese referendum and it'll tell you Ngô Đình Diệm &quot;won&quot; with an implausible 98.2% of the vote. Ask it what <em>Le Figaro specifically wrote about it on a specific date, with citations</em>, and it falls over.</p>
<p>Two reasons. <strong>The training data isn't there</strong> — archives like the BnF's Gallica hold millions of 1950s pages, but as PDFs of microfilm with only basic OCR. <strong>And modern OCR doesn't help much either</strong> — tools trained on contemporary invoices and magazines aren't ready for a seven-column 1955 broadsheet. They read horizontally across columns and produce what I call <strong>column-bleed</strong>.</p>
<div>
<img src="https://sysadmin-journal.com/content/images/2026/05/Document_AI_RAG_Slide_7.jpg" alt="Anatomy of a 1955 newspaper" />
</div>
<p>A real <code>pdftotext</code> output from the 10 May 1955 Le Figaro:</p>
<blockquote>
<p><em>L'Allemagne LE TRAITÉ DANS UNE INTERVIEW Gérard Dupriez AUJOURD'HUI / a fait hier D'ÉTAT EXCLUSIVE BAO DAI parricide SEIZE PAGES.</em></p>
</blockquote>
<p>Four unrelated articles spliced together. Articles don't even live in one column — the Bao Đại interview starts in column 3 of page 1, runs into column 4, then jumps to &quot;page 13, columns 3, 4 and 5&quot; via a tiny « Suite page 13 » pointer. One article. Two pages. Five columns. Then there are the ads — about 40% of every page, much of it dressed up as editorial. Orlane sells beauty cream under the headline « À peau soignée, beau maquillage ». Pullnyl's « La révélation de l'année » reads like a scoop until you notice it's selling nylon shirts at 1,200 francs. If your pipeline can't tell these from editorial, your knowledge base ships ad copy as facts.</p>
<h2>The pipeline</h2>
<p>Five stages, end to end:</p>
<ol>
<li><strong>Ingest</strong> — PDFs land in Cloud Storage.</li>
<li><strong>Parse</strong> — Document AI Layout Parser. Hierarchical block structure, cross-column reading order.</li>
<li><strong>Enrich</strong> — Gemini 2.5 multimodal. Article assembly, kind classification, jump resolution. <em>This is where the actual contribution lives.</em></li>
<li><strong>Index</strong> — Cloud SQL Postgres 18 with pgvector.</li>
<li><strong>Generate</strong> — Gemini grounded answers with inline citations.</li>
</ol>
<div>
<img src="https://sysadmin-journal.com/content/images/2026/05/build-with-ai-ish-sookun-explaining-pipeline.jpeg" alt="Explaining the five stages of the pipeline" />
</div>
<p>Stages 1, 4 and 5 are well-trodden. Stages 2 and 3 are where teams fall down on newspaper data.</p>
<h2>A few interesting moments from the build</h2>
<p>Most of the provisioning is the kind of <code>gcloud</code> you've seen a hundred times. Three things stood out as worth showing.</p>
<p><strong>Cloud SQL Postgres 18 with pgvector.</strong> Postgres 18 went GA on Cloud SQL this year, and <code>pgvector</code> is now a first-class extension:</p>
<pre><code class="language-bash">gcloud sql instances create figaro-db \
  --database-version=POSTGRES_18 \
  --tier=db-custom-1-3840 --region=us-central1 \
  --root-password=&quot;$(openssl rand -base64 24)&quot;
</code></pre>
<p>The actual technical contribution sits in the schema — a French <code>tsvector</code> column and a 768-dimensional embedding column in the same row, each with the right index:</p>
<pre><code class="language-sql">CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE articles (
  -- ... id, source, page, published, title, body ...
  kind      TEXT CHECK (kind IN
              ('news','feature','opinion','advertorial','ad','listing')),
  tsv       TSVECTOR GENERATED ALWAYS AS (
              setweight(to_tsvector('french', coalesce(title,'')), 'A') ||
              setweight(to_tsvector('french', coalesce(body,'')),  'B')
            ) STORED,
  embedding VECTOR(768)
);

CREATE INDEX ON articles USING GIN (tsv);
CREATE INDEX ON articles USING hnsw (embedding vector_cosine_ops);
</code></pre>
<p>The whole retrieval story rests on those two columns existing side-by-side, each with the appropriate index. GIN for full-text, HNSW for cosine similarity.</p>
<p><strong>Document AI has no <code>gcloud</code> surface.</strong> This is the kind of friction nobody warns you about. Want to create a Layout Parser processor? <code>gcloud documentai processors create</code> does not exist — not in <code>gcloud</code>, not in <code>gcloud alpha</code>, not anywhere. You hit the REST API directly:</p>
<pre><code class="language-bash">curl -X POST \
  -H &quot;Authorization: Bearer $(gcloud auth print-access-token)&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d '{&quot;displayName&quot;:&quot;figaro-layout-parser&quot;,&quot;type&quot;:&quot;LAYOUT_PARSER_PROCESSOR&quot;}' \
  &quot;https://us-documentai.googleapis.com/v1/projects/$PROJECT_ID/locations/us/processors&quot;
</code></pre>
<p>Two quirks worth knowing. The endpoint is <code>us-documentai.googleapis.com</code> (multi-region) — Document AI doesn't expose per-region endpoints like <code>us-central1</code>. And the <code>locations/us</code> in the path matches that.</p>
<h2>Passing two modalities to Gemini</h2>
<p>For each page, the ingest splits the source PDF to a single page (Document AI's sync endpoint caps at 40 MB), parses the layout, renders the page as JPEG, and hands Gemini both inputs together:</p>
<pre><code class="language-python">def extract_articles(layout_chunks, page_image, page_num):
    prompt = f&quot;&quot;&quot;Group blocks into articles from this 1955 French newspaper.
Use the page image as authoritative for grouping. For each article return:
title, body, byline, kind (news|feature|opinion|advertorial|ad|listing),
and jump_target if a « Suite page X » pointer is visible. Translate nothing.

Layout chunks for page {page_num}: {json.dumps(layout_chunks)[:12000]}&quot;&quot;&quot;

    response = gen.models.generate_content(
        model=&quot;gemini-2.5-flash&quot;,
        contents=[
            types.Part.from_bytes(data=page_image, mime_type=&quot;image/jpeg&quot;),
            prompt,
        ],
        config=types.GenerateContentConfig(
            response_mime_type=&quot;application/json&quot;,
            temperature=0.2,
        ),
    )
    return json.loads(response.text)
</code></pre>
<p>The image gives Gemini visual context the JSON can't carry — ad framing, advertorial styling, « Suite page X » pointers, the way a section break looks different from a paragraph break. The model literally <em>sees</em> what a 1955 newspaper reader saw. The <code>kind</code> value in the output is the linchpin: classifying ads vs editorial at ingest time keeps ad copy out of every future retrieval, forever.</p>
<h2>Retrieval is hybrid by necessity</h2>
<p>The query that runs against the database has two CTEs side by side:</p>
<pre><code class="language-sql">WITH lexical AS (
  SELECT id, ROW_NUMBER() OVER (
           ORDER BY ts_rank_cd(tsv, q) DESC) AS rk
  FROM articles, plainto_tsquery('french', :query) q
  WHERE tsv @@ q
    AND kind IN ('news','feature','opinion')
    AND published BETWEEN :from AND :to
  LIMIT 50
),
semantic AS (
  SELECT id, ROW_NUMBER() OVER (
           ORDER BY embedding &lt;=&gt; :qvec::vector) AS rk
  FROM articles
  WHERE kind IN ('news','feature','opinion')
    AND published BETWEEN :from AND :to
  ORDER BY embedding &lt;=&gt; :qvec::vector LIMIT 50
)
SELECT a.*,
       COALESCE(1.0/(60+l.rk), 0) + COALESCE(1.0/(60+s.rk), 0) AS score
FROM lexical l FULL OUTER JOIN semantic s USING (id)
JOIN articles a ON a.id = COALESCE(l.id, s.id)
ORDER BY score DESC LIMIT 12;
</code></pre>
<p>Three things matter here. <strong><code>tsvector</code> with <code>'french'</code></strong> gives exact-term precision — type <em>Bao Đại</em> and every chunk with that tokenisation surfaces. <strong><code>embedding &lt;=&gt; qvec</code> cosine</strong> gives paraphrase tolerance — <em>&quot;what did the South Vietnamese ruler say about elections&quot;</em> still finds chunks discussing « régime républicain ou monarchie constitutionnelle ». And <strong><code>kind IN (...)</code> on both CTEs</strong> filters Pullnyl's nylon-shirt ads out of editorial answers before either ranker sees them.</p>
<p>The fusion is <strong>Reciprocal Rank Fusion with k=60</strong> — a parameter-free way to combine rankers that produce scores on incommensurable scales. A document at rank 2 in lexical and rank 3 in semantic beats a document at rank 1 in lexical and rank 50 in semantic. The constant <code>60</code> comes from the original 2009 RRF paper and remains the empirically robust default.</p>
<p>Lexical for precision. Semantic for paraphrase. RRF to fuse them without calibrating score scales.</p>
<h2>The Mauritius angle</h2>
<p>Two hundred years of Mauritian newsprint sitting in PDFs and microfilm. <em>Le Cernéen</em> from 1832 to 1982 — the oldest paper in the southern hemisphere. <em>Le Mauricien</em> from 1908. <em>l'express</em> from 1963. Plus the National Library of Mauritius, the Mahatma Gandhi Institute archives, the official gazette since 1773. Almost none of it queryable today.</p>
<p>For institutions like the National Library or the MGI, data residency matters. The production version of this pipeline runs end-to-end in <code>africa-south1</code> — one network hop from Mauritius, historical content never leaves the region. Building this index is, in a real sense, an act of recovery: putting our history into a form our great-grandchildren can actually ask questions of.</p>
<h2>What's next</h2>
<p>A few directions on my list:</p>
<ul>
<li><strong>A knowledge graph layer</strong> — entities, dates, geographies, navigable visually.</li>
<li><strong>Multimodal photo Q&amp;A</strong> — <em>&quot;show me Cannes festival photos 1950 to 1960&quot;</em>.</li>
<li><strong>Cross-paper queries</strong> across <em>Le Figaro</em>, <em>l'express</em>, <em>Le Cernéen</em>, the gazette.</li>
<li><strong>Era-aware retrieval</strong> — 1955 vocabulary differs from 2025 vocabulary in ways that matter for embedding quality.</li>
</ul>
<p>The hobby project continues. By <strong>DevFest 2026</strong>, I'm hoping to have something more structured than a Python script firing a single query — a proper service layer, a frontend a researcher can actually navigate, maybe the cross-paper piece running across two or three titles. We'll see how far I get.</p>
<h2>Thanks</h2>
<p>Thank you to SWAN HQ for hosting a great Build with AI event in Port-Louis, to the Google Developer Group community for putting the day together, and to everyone who came up after the talk with questions, war stories, and corrections that I'm still chewing on. The slides are at the end of this post; the demo source code goes up on GitHub this week.</p>
]]></content:encoded>
        </item>
    </channel>
</rss>
