<?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[Small experiments with AI]]></title><description><![CDATA[Small experiments with AI]]></description><link>https://small-experiments-with-ai.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 20 Sep 2026 09:58:19 GMT</lastBuildDate><atom:link href="https://small-experiments-with-ai.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[What GPT-OSS20b Outputs Tell Us About Its Training and Behavior]]></title><description><![CDATA[OpenAI stated in the GPT-OSS documentation that these models should only be used with the Harmony response format; otherwise, they won’t work correctly. They’re trained on this specific format, only understand this, and only respond properly when use...]]></description><link>https://small-experiments-with-ai.hashnode.dev/what-gpt-oss20b-outputs-tell-us-about-its-training-and-behavior</link><guid isPermaLink="true">https://small-experiments-with-ai.hashnode.dev/what-gpt-oss20b-outputs-tell-us-about-its-training-and-behavior</guid><category><![CDATA[openai]]></category><category><![CDATA[gpt-oss]]></category><category><![CDATA[large language models]]></category><dc:creator><![CDATA[Abed K]]></dc:creator><pubDate>Tue, 19 Aug 2025 15:50:10 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/stock/unsplash/8OyKWQgBsKQ/upload/5d7166c69d0f7e917e318ad45e9d3fe5.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>OpenAI stated in the <a target="_blank" href="https://github.com/openai/gpt-oss?utm_source=chatgpt.com">GPT-OSS documentation</a> that these models should only be used with the Harmony response format; otherwise, they won’t work correctly. They’re trained on this specific format, only understand this, and only respond properly when use with harmony format. This raises a couple of questions:</p>
<ul>
<li><p>What exactly does “won’t work properly” mean?</p>
</li>
<li><p>What is it about the Harmony response format that makes the model behave correctly?</p>
</li>
<li><p>How heavily are these models trained on the Harmony format, such that they shift from being raw next-token predictors to behaving like structured conversational models?</p>
</li>
</ul>
<p>And if we skip Harmony, does that basically reduce the model to raw completion mode? If so, how well can it still predict the next token?</p>
<p>Jack Morris, in one of his <a target="_blank" href="https://x.com/jxmnop/status/1953899426075816164">tweets</a>, generated about 10M responses from GPT-OSS-20B, most likely without using the Harmony protocol. He ran some analysis and claimed the model shows a strong bias towards math and code. In fact, for many general prompts, it tends to drift back into math or programming responses. This could suggest that the model (and maybe even GPT-OSS-120B) was trained heavily on math/code domains or benchmarks. He also argued that by generating this kind of large scale responses, we can get a rough glimpse into the underlying training data of the model.</p>
<p>In this blog, we’ll try to replicate and verify Morris’s findings, and, in the process, maybe we’ll get closer to answering the questions raised above.</p>
<p>For this experiment, I’m using Ollama to run GPT-OSS-20B locally. We’ll be using the model without the Harmony format, just to see how it behaves in plain mode. The assumption here is simple: if we don’t use Harmony, the model falls back into being a typical completion model, just predicting the next token without any structured conversation format.</p>
<p>The hypothesis is: if we give it a single word, or even an empty prompt, in what “direction” does it start predicting the next token?</p>
<ul>
<li><p><strong>Null hypothesis (H₀):</strong> when given a general one-word prompt, the model does <em>not</em> consistently continue in the same direction.</p>
</li>
<li><p><strong>Alternative hypothesis (H₁):</strong> when given a general one-word prompt, the model <em>does</em> predict the next token in the same direction.</p>
</li>
</ul>
<p>By “same direction” we mean, if we prompt with something like <em>“quantum mechanics,”</em> the model’s next predicted tokens stay in that domain, e.g., quantum mechanics, physics, scientific concepts.</p>
<p>If the null holds true, then it strengthens Jack Morris’s claim: that these models’ training data is biased, with GPT-OSS being heavily trained with math and code, to the point that even general prompts drift back to those domains. Also, it may not have real-world general understanding that much.</p>
<h3 id="heading-inferences">Inferences</h3>
<p>With that setup, after getting the model running via Ollama locally, we define our prompts as follows:</p>
<pre><code class="lang-python">input_prompts = [
    <span class="hljs-string">"Smile"</span>, <span class="hljs-string">"Joy"</span>, <span class="hljs-string">"Sadness"</span>, <span class="hljs-string">"Anger"</span>, <span class="hljs-string">"Fear"</span>, <span class="hljs-string">"Love"</span>, <span class="hljs-string">"Hope"</span>, <span class="hljs-string">"Peace"</span>, <span class="hljs-string">"Calm"</span>, <span class="hljs-string">"Rage"</span>, <span class="hljs-string">"Happy"</span>, <span class="hljs-string">"Excited"</span>, <span class="hljs-string">"Nervous"</span>, <span class="hljs-string">"Confident"</span>, <span class="hljs-string">"Worried"</span>, <span class="hljs-string">"Grateful"</span>, <span class="hljs-string">"Lonely"</span>, <span class="hljs-string">"Pride"</span>,
    <span class="hljs-string">"Apple"</span>, <span class="hljs-string">"Car"</span>, <span class="hljs-string">"House"</span>, <span class="hljs-string">"Tree"</span>, <span class="hljs-string">"Ocean"</span>, <span class="hljs-string">"Mountain"</span>, <span class="hljs-string">"Book"</span>, <span class="hljs-string">"Phone"</span>, <span class="hljs-string">"Computer"</span>, <span class="hljs-string">"Chair"</span>,  <span class="hljs-string">"Table"</span>, <span class="hljs-string">"Window"</span>, <span class="hljs-string">"Door"</span>, <span class="hljs-string">"Key"</span>, <span class="hljs-string">"Lamp"</span>, <span class="hljs-string">"Mirror"</span>, <span class="hljs-string">"Painting"</span>, <span class="hljs-string">"Clock"</span>, <span class="hljs-string">"Flower"</span>, <span class="hljs-string">"Stone"</span>,
    <span class="hljs-string">"Run"</span>, <span class="hljs-string">"Jump"</span>, <span class="hljs-string">"Dance"</span>, <span class="hljs-string">"Sleep"</span>, <span class="hljs-string">"Think"</span>, <span class="hljs-string">"Write"</span>, <span class="hljs-string">"Read"</span>, <span class="hljs-string">"Listen"</span>, <span class="hljs-string">"Watch"</span>, <span class="hljs-string">"Create"</span>,  <span class="hljs-string">"Build"</span>, <span class="hljs-string">"Destroy"</span>, <span class="hljs-string">"Help"</span>, <span class="hljs-string">"Learn"</span>, <span class="hljs-string">"Teach"</span>, <span class="hljs-string">"Play"</span>, <span class="hljs-string">"Work"</span>, <span class="hljs-string">"Rest"</span>, <span class="hljs-string">"Travel"</span>, <span class="hljs-string">"Explore"</span>,
    <span class="hljs-string">"Freedom"</span>, <span class="hljs-string">"Justice"</span>, <span class="hljs-string">"Truth"</span>, <span class="hljs-string">"Beauty"</span>, <span class="hljs-string">"Wisdom"</span>, <span class="hljs-string">"Courage"</span>, <span class="hljs-string">"Honor"</span>, <span class="hljs-string">"Faith"</span>, <span class="hljs-string">"Trust"</span>, <span class="hljs-string">"Mystery"</span>,  <span class="hljs-string">"Future"</span>, <span class="hljs-string">"Past"</span>, <span class="hljs-string">"Present"</span>, <span class="hljs-string">"Infinity"</span>, <span class="hljs-string">"Nothing"</span>, <span class="hljs-string">"Everything"</span>, <span class="hljs-string">"Reality"</span>, <span class="hljs-string">"Dream"</span>, <span class="hljs-string">"Memory"</span>, <span class="hljs-string">"Imagination"</span>,
    <span class="hljs-string">"Red"</span>, <span class="hljs-string">"Blue"</span>, <span class="hljs-string">"Green"</span>, <span class="hljs-string">"Yellow"</span>, <span class="hljs-string">"Purple"</span>, <span class="hljs-string">"Orange"</span>, <span class="hljs-string">"Black"</span>, <span class="hljs-string">"White"</span>, <span class="hljs-string">"Pink"</span>, <span class="hljs-string">"Brown"</span>, <span class="hljs-string">"Cat"</span>, <span class="hljs-string">"Dog"</span>, <span class="hljs-string">"Bird"</span>, <span class="hljs-string">"Fish"</span>, <span class="hljs-string">"Lion"</span>, <span class="hljs-string">"Tiger"</span>, <span class="hljs-string">"Elephant"</span>, <span class="hljs-string">"Horse"</span>, <span class="hljs-string">"Rabbit"</span>, <span class="hljs-string">"Snake"</span>, <span class="hljs-string">"Pizza"</span>, <span class="hljs-string">"Cake"</span>, <span class="hljs-string">"Bread"</span>, <span class="hljs-string">"Milk"</span>, <span class="hljs-string">"Coffee"</span>,
     <span class="hljs-string">"Tea"</span>, <span class="hljs-string">"Rice"</span>, <span class="hljs-string">"Pasta"</span>, <span class="hljs-string">"Soup"</span>, <span class="hljs-string">"Salad"</span>, <span class="hljs-string">"A"</span>, <span class="hljs-string">"B"</span>, <span class="hljs-string">"C"</span>, <span class="hljs-string">"X"</span>, <span class="hljs-string">"Y"</span>, <span class="hljs-string">"Z"</span>,  <span class="hljs-string">"One"</span>, <span class="hljs-string">"Two"</span>, <span class="hljs-string">"Five"</span>, <span class="hljs-string">"Ten"</span>, <span class="hljs-string">"Hundred"</span>, <span class="hljs-string">"Thousand"</span>, <span class="hljs-string">"Million"</span>, <span class="hljs-string">"Zero"</span>, <span class="hljs-string">""</span>, <span class="hljs-string">" "</span>, <span class="hljs-string">"."</span>, <span class="hljs-string">"?"</span>, <span class="hljs-string">"!"</span>, <span class="hljs-string">"..."</span>, <span class="hljs-string">"???"</span>, <span class="hljs-string">"The"</span>, <span class="hljs-string">"And"</span>, <span class="hljs-string">"Or"</span>, <span class="hljs-string">"But"</span>, <span class="hljs-string">"If"</span>, <span class="hljs-string">"When"</span>, <span class="hljs-string">"Why"</span>, <span class="hljs-string">"How"</span>, <span class="hljs-string">"What"</span>,
    <span class="hljs-string">"Quantum"</span>, <span class="hljs-string">"Gravity"</span>, <span class="hljs-string">"Energy"</span>, <span class="hljs-string">"Matter"</span>, <span class="hljs-string">"Space"</span>, <span class="hljs-string">"Time"</span>, <span class="hljs-string">"Evolution"</span>, <span class="hljs-string">"DNA"</span>, <span class="hljs-string">"Atom"</span>, <span class="hljs-string">"Universe"</span>,<span class="hljs-string">"Existence"</span>, <span class="hljs-string">"Consciousness"</span>, <span class="hljs-string">"Soul"</span>, <span class="hljs-string">"Mind"</span>, <span class="hljs-string">"Body"</span>, <span class="hljs-string">"Spirit"</span>, <span class="hljs-string">"Ethics"</span>, <span class="hljs-string">"Morality"</span>, <span class="hljs-string">"Purpose"</span>, <span class="hljs-string">"Meaning"</span>
]
</code></pre>
<p>After defining prompts, we define the inference function, and we also define how many inferences we want the model to make; in this case, I went for 1 million, 1k per file. Also, the <a target="_blank" href="https://github.com/ollama/ollama/blob/main/docs/api.md?utm_source=chatgpt.com"><code>raw</code> flag is <code>True</code></a> which means no harmony formating will be applied to the prompt.</p>
<pre><code class="lang-python"><span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">make_inference</span>(<span class="hljs-params">prompt</span>):</span>
    <span class="hljs-string">"""Send one prompt to the model and return text or error string."""</span>
    url = <span class="hljs-string">"http://localhost:11434/api/generate"</span>
    data = {
        <span class="hljs-string">"model"</span>: <span class="hljs-string">"gpt-oss:20b"</span>,
        <span class="hljs-string">"prompt"</span>: selected_prompt,
        <span class="hljs-string">"stream"</span>: <span class="hljs-literal">False</span>,
        <span class="hljs-string">"raw"</span>: <span class="hljs-literal">True</span>,
        <span class="hljs-string">"options"</span>: { <span class="hljs-string">"temperature"</span>: <span class="hljs-number">0.7</span>, <span class="hljs-string">"num_predict"</span>: <span class="hljs-number">4000</span>, <span class="hljs-string">"num_ctx"</span>: <span class="hljs-number">2100</span>, <span class="hljs-string">"top_p"</span>: <span class="hljs-number">0.9</span>
        }
    }

total_inferences = <span class="hljs-number">1</span>_000_000
inferences_per_file = <span class="hljs-number">1000</span>
</code></pre>
<p>Now, we run the loop to randomly select a prompt from input_prompt and make 1M inferences and save both the input query and output response in a clean way for further analysis.</p>
<pre><code class="lang-python"><span class="hljs-keyword">for</span> i <span class="hljs-keyword">in</span> range(total_inferences):
    selected_prompt = random.choice(input_prompts)
    file_number = (i // inferences_per_file) + <span class="hljs-number">1</span>
    output = make_inference(selected_prompt)
    save_inference(selected_prompt, output, file_number)
    <span class="hljs-keyword">if</span> (i + <span class="hljs-number">1</span>) % <span class="hljs-number">100</span> == <span class="hljs-number">0</span>:
        print(<span class="hljs-string">f"Completed <span class="hljs-subst">{i+<span class="hljs-number">1</span>:,}</span> inferences (<span class="hljs-subst">{((i+<span class="hljs-number">1</span>)/total_inferences)*<span class="hljs-number">100</span>:<span class="hljs-number">.2</span>f}</span>%)"</span>)
    time.sleep(<span class="hljs-number">5.0</span>)
</code></pre>
<p>Great, we have created 1M inferences, and a screenshot of 1 in a million is following where gpt-oss20b, instead of physics or Newton's laws, coded an entire JavaScript game character with jump mechanics and all lol.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1755606084153/0d2c983c-3d95-4bf0-9400-d6e5815a0985.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-embeddings">Embeddings</h3>
<p>After generating the inferences, we take the input query and its corresponding output and create embeddings for both. By computing the cosine distance between them, we can measure how semantically close or far the output is from the original query.</p>
<p>To dig deeper, we split the outputs into chunks of 400 words with a 100-word overlap. Then, for each sentence in the output, we create embeddings and calculate its distance from the input query. This way, we can track not just the overall closeness but also how the output drifts in meaning as the prediction goes on.</p>
<p>For example, with a query like <em>“smile,”</em> the model might start with something loosely related, say, about time or emotions, and then gradually deviate into other domains like math or code. By analyzing sentence-level distances, we can see exactly when the output stays aligned with the query and when it strays off-topic.</p>
<p>For embeddings, we use OpenAI <code>text-embedding-3-small</code></p>
<pre><code class="lang-python"><span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">get_openai_embedding</span>(<span class="hljs-params">text, model=<span class="hljs-string">"text-embedding-3-small"</span></span>):</span>
    resp = openai.embeddings.create(input=text, model=model)
    <span class="hljs-keyword">return</span> np.array(resp.data[<span class="hljs-number">0</span>].embedding)

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">chunk_text</span>(<span class="hljs-params">long_sentence, chunk_size=<span class="hljs-number">400</span>, overlap=<span class="hljs-number">100</span></span>):</span>
    <span class="hljs-string">"""Split by words into overlapping chunks (same pattern you used)."""</span>
    words = long_sentence.split()
    chunks, start = [], <span class="hljs-number">0</span>
    step = max(chunk_size - overlap, <span class="hljs-number">1</span>)
    <span class="hljs-keyword">while</span> start &lt; len(words):
        end = min(start + chunk_size, len(words))
        chunk_text = <span class="hljs-string">" "</span>.join(words[start:end])
        chunks.append(chunk_text)
        <span class="hljs-keyword">if</span> end &gt;= len(words):
            <span class="hljs-keyword">break</span>
        start += step
    <span class="hljs-keyword">return</span> chunks
</code></pre>
<p>After setting up the embedding and chunking functions, we also define some simple parsing and data-loading utilities. These are included in the Jupyter notebook linked in the GitHub repo attached to this blog.</p>
<p>Once the parsing is done, as shown in the code below, we generate embeddings for the input query as well as for each sentence in the model’s output in sequence (Sentence 1, Sentence 2, and so on). Alongside the embeddings, we also store the raw sentences themselves. This is important, because later we can attach labels or domain/topic names to each sentence.</p>
<pre><code class="lang-python"><span class="hljs-keyword">for</span> i, rec <span class="hljs-keyword">in</span> enumerate(records, <span class="hljs-number">1</span>):
    query = rec[<span class="hljs-string">"input"</span>]
    output_joined = <span class="hljs-string">" "</span>.join(rec[<span class="hljs-string">"output"</span>].split())  

    <span class="hljs-comment"># 1) input embedding</span>
    q_emb = get_openai_embedding(query)
    <span class="hljs-keyword">with</span> open(input_emb_file, <span class="hljs-string">"a"</span>, encoding=<span class="hljs-string">"utf-8"</span>) <span class="hljs-keyword">as</span> f:
        f.write(<span class="hljs-string">f"Embedding: <span class="hljs-subst">{q_emb.tolist()}</span>\n"</span>)

    <span class="hljs-comment"># 2) output → chunks (sentences)</span>
    chunks = chunk_text(output_joined, chunk_size=<span class="hljs-number">400</span>, overlap=<span class="hljs-number">100</span>)

    <span class="hljs-comment"># save the sentences </span>
    <span class="hljs-keyword">with</span> open(chunks_text_file, <span class="hljs-string">"a"</span>, encoding=<span class="hljs-string">"utf-8"</span>) <span class="hljs-keyword">as</span> f:
        <span class="hljs-keyword">for</span> j, ch <span class="hljs-keyword">in</span> enumerate(chunks, <span class="hljs-number">1</span>):
            f.write(<span class="hljs-string">f"Sentence <span class="hljs-subst">{j}</span>: <span class="hljs-subst">{ch}</span>\n"</span>)

    <span class="hljs-comment"># 3) embeddings for each sentence</span>
    <span class="hljs-keyword">with</span> open(output_emb_file, <span class="hljs-string">"a"</span>, encoding=<span class="hljs-string">"utf-8"</span>) <span class="hljs-keyword">as</span> f:
        <span class="hljs-keyword">for</span> j, ch <span class="hljs-keyword">in</span> enumerate(chunks, <span class="hljs-number">1</span>):
            emb = get_openai_embedding(ch)
            f.write(<span class="hljs-string">f"Inference <span class="hljs-subst">{i}</span> | Sentence <span class="hljs-subst">{j}</span>: <span class="hljs-subst">{emb.tolist()}</span>\n"</span>)
</code></pre>
<p>For this case, I used only 10 inferences to create embeddings, though the approach can be extended further.</p>
<h3 id="heading-cosine-distance">Cosine Distance</h3>
<p>After storing the embeddings, we calculate the cosine distance between the query and each sentence in its output. This distance gives us a way to quantify the semantic gap between prompt and response.</p>
<p>The setup is simple: basic imports and input parsing utilities are already defined in the Jupyter notebook (linked in the repo). After running those, the main function is shown below.</p>
<pre><code class="lang-python"><span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">cosine_distance</span>(<span class="hljs-params">a: np.ndarray, b: np.ndarray</span>) -&gt; float:</span>
    denom = (np.linalg.norm(a) * np.linalg.norm(b))
    <span class="hljs-keyword">return</span> <span class="hljs-number">1.0</span> - float(a @ b / denom) <span class="hljs-keyword">if</span> denom <span class="hljs-keyword">else</span> <span class="hljs-number">1.0</span>

<span class="hljs-keyword">for</span> inf <span class="hljs-keyword">in</span> range (<span class="hljs-number">1</span>, <span class="hljs-number">11</span>):
    q = extract_query_embedding(inf)
    sents = extract_sentence_embeddings(inf)

    out_path = os.path.join(output_dir, <span class="hljs-string">f"inference<span class="hljs-subst">{inf}</span>_cosine_distances.txt"</span>)
    <span class="hljs-keyword">with</span> open(out_path, <span class="hljs-string">"w"</span>, encoding=<span class="hljs-string">"utf-8"</span>) <span class="hljs-keyword">as</span> out:
        out.write(<span class="hljs-string">"sentence_index,cosine_distance\n"</span>)

    print(<span class="hljs-string">"Saved:"</span>, out_path)
</code></pre>
<p>Hence, we compute the cosine distances and save.</p>
<h3 id="heading-classification">Classification</h3>
<p>Cosine distance alone may not tell a full story. So, the next step is to classify each output sentence and assign it a label or topic, showing what the sentence is about and which domain it belongs to. For this classification and labeling, we use GPT-OSS-20B with the Harmony format (this time with the <code>raw</code> flag set to <em>False</em>).</p>
<p>Since we already stored the input queries and output sentences, the parsing, data-loading, and saving utilities (defined in the Jupyter notebook) handle the setup in the right order. Below is the exact prompt we use for labeling and classification.</p>
<pre><code class="lang-python"><span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">generate_simple_label</span>(<span class="hljs-params">sentence_text</span>):</span>
    <span class="hljs-keyword">try</span>:
        prompt = <span class="hljs-string">f"""You are an expert text analyzer. Your task is to read the following text and create a precise, descriptive label that captures its essence.

TEXT: "<span class="hljs-subst">{sentence_text[:<span class="hljs-number">300</span>]}</span>"

HOW TO ANALYZE:
1. First, identify the MAIN SUBJECT or topic being discussed
2. Second, determine the FIELD or DOMAIN this belongs to
3. Third, consider the SPECIFIC CONTEXT or approach being used
4. Finally, create a label that combines the most important aspects

THINKING PROCESS:
- What is this text primarily about?
- What field of knowledge does this belong to?
- What specific aspect or angle is being discussed?
- How would an expert in this field categorize this?

LABEL REQUIREMENTS:
- Use EXACTLY 2-3 words
- Be specific and descriptive
- Use clear, professional terminology
- Capture the most important essence of the text

EXAMPLES OF GOOD LABELS:
- "quantum mechanics" (not just "physics")
- "data structures" (not just "programming")  
- "financial modeling" (not just "business")
- "cognitive psychology" (not just "psychology")
- "organic synthesis" (not just "chemistry")
- "machine learning" (not just "technology")

CREATE YOUR LABEL (2-3 words): """</span>
</code></pre>
<p>After labeling each sentence, we move on to visualizing the results. The visualization code is included in the Jupyter notebook.</p>
<h3 id="heading-visualization">Visualization</h3>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1755609675793/b499a1d9-691a-460f-becd-14c47fd7c8be.png" alt class="image--center mx-auto" /></p>
<p>Figure A shows the set of queries we used in this experiment. The x-axis represents the cosine distance. We deliberately chose queries that are very general and tied to everyday language, with nothing from programming, math, or logic domains. Each dot represents one sentence (or chunk) of the model’s output, plotted in order from left to right.</p>
<p>Take the query <em>“Spirit”</em> for example, on the bottom: the output has two sentences, one landing just above 0.80 cosine distance and the second around 0.85. Looking across all queries, the first big observation is that none of the output sentences are semantically very close to their input query. The minimum distance we see starts above 0.60. This suggests that when GPT-OSS-20B is given a one-word, general-life prompt, the next tokens it predicts already sit at least 0.60 distance away from the query.</p>
<p>The second insight is about drift. As generation progresses, cosine distance keeps increasing. In other words, the further the model goes, the further away it gets from the meaning of the original query. It’s still predicting tokens with high probability, but those tokens may not necessarily stay related to the input.</p>
<p>So across these 10 examples, the model consistently moves semantically away from the prompt instead of staying attached. That means if we give GPT-OSS-20B a general life query, its responses are never truly close to the query, at best loosely related.</p>
<p>This naturally raises the next question: if the outputs keep drifting away, what exactly is the model predicting instead? We dig into that in the next section (see the table below).</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1755609684599/026b53dd-26f1-45e6-9ab3-b2509c8ee3f7.png" alt class="image--center mx-auto" /></p>
<p>The table above labels each output sentence in the exact left to right order shown in Figure A. This lets us see what the model is drifting into as the generation progresses.</p>
<p>What stands out immediately is that the outputs are not grounded in the input query at all. For example:</p>
<ul>
<li><p>The query <em>“Spirit”</em> quickly turns into <em>“Java game objects,”</em> <em>“Thread-safe inventory,”</em> and <em>“Java concurrency.”</em></p>
</li>
<li><p><em>“Smile”</em> ends up with <em>“prefix lookup”</em> and <em>“database query performance.”</em></p>
</li>
<li><p><em>“Anger”</em> shifts into <em>“synthetic emotion embeddings”</em> and <em>“logic”</em></p>
</li>
<li><p>Even <em>“Pasta”</em> gives back <em>“PyQt GUI.”</em></p>
</li>
<li><p><em>“Excited”</em> jumps straight into <em>“quantum state estimation”</em> and <em>“statistical modeling.”</em></p>
</li>
</ul>
<p>Across all 10 cases, the same pattern holds: general, real-life queries are hijacked by technical or programming-related completions. The drift we saw in cosine distance (Figure A) is now explained: the model is not staying close to the original concept but rather collapsing into code, software, or math tokens that it assigns high probability.</p>
<p>So the combined picture is:</p>
<ul>
<li><p>Figure A shows <em>semantic drift</em> (increasing distance from the query).</p>
</li>
<li><p>This table shows the <em>direction of that drift</em> (logic, programming, technical jargon).</p>
</li>
</ul>
<p>Now, the above results circle back to the questions we raised in the beginning. First of all, we did not use gpt-oss20b with the harmony format, and it did not work correctly. It wasn’t just that the answers were wrong, the model went in the exact opposite direction of the query. That alone suggests something deeper: this model seems to be heavily trained on, or even locked into, the harmony format. If you don’t follow that protocol, the model basically breaks. It doesn’t know what to do, and in that confusion it starts spitting out random completions , often technical and often completely unrelated to the input. That dependency is not just an artifact; it raises a vulnerability. If a model is 100% relying on a strict prompting protocol, then its safety guardrails might also be fragile. Maybe it can be tricked, bypassed, or exploited if someone knows how to deliberately break that harmony flow. The fact that the exact same model, when used with harmony in labeling, suddenly performs so well only strengthens that suspicion.</p>
<p>Another thing: we assumed that using the model without harmony might correspond to some kind of “completion mode,” where the model just predicts the next tokens based on the input. But in our case, although it was predicting next tokens, those predictions were not semantically close to the query at all. That raises another question: is this actually the base/completion version of the model, or is gpt-oss a highly instruction-tuned model, and what we saw was just a mismatch between what we expected and what the model is designed for?</p>
<p>Lastly, our analysis does give some weight to Jack’s claims, that these models collapse into domains of code and math. When we gave it simple, general queries, the responses drifted back to coding and technical jargon. Does this mean the model has been heavily trained, maybe even over-trained, on math, programming, and benchmarks, to the point where it doesn’t have much understanding of the general world? And if this is the case, then when we run large-scale probing experiments , generating millions of responses, analyzing deviations, and tracking what direction outputs take, maybe we can use this drift as a way to uncover the true nature of a model’s training data.</p>
<p>This blog leaves us with even more questions than we started with. I plan to extend these experiments to other models and see what kind of stories they tell. I’d love for the above results to be replicated, and I’d looking forward to hear insights from others. Let’s see if we can start answering some of these questions and understand these models better.</p>
<p><a target="_blank" href="https://github.com/akarim23131/gpt_oss_testing">GitHub Repo</a></p>
]]></content:encoded></item><item><title><![CDATA[How to View the Context GraphRAG Sends to Your LLM (New --raw-chunks Flag)]]></title><description><![CDATA[Sometimes, when working with RAG systems, we need to know exactly what context is being passed to the LLM. You run a query, the model gives an answer, sometimes strange or irrelevant, and you are left wondering , “Did it actually get the right inform...]]></description><link>https://small-experiments-with-ai.hashnode.dev/how-to-view-the-context-graphrag-sends-to-your-llm-new-raw-chunks-flag</link><guid isPermaLink="true">https://small-experiments-with-ai.hashnode.dev/how-to-view-the-context-graphrag-sends-to-your-llm-new-raw-chunks-flag</guid><category><![CDATA[Microsoft]]></category><category><![CDATA[graph database]]></category><category><![CDATA[knowledge graph]]></category><category><![CDATA[graphrag]]></category><category><![CDATA[#RAG  #RetrievalAugmentedGeneration  #LLMApplications  #TechnicalDocs  #SensorEngineering  #MultimodalAI  #GraphRAG  #HyDEPrompting  #AIForEngineers  #KnowledgeGraphs  #MachineLearning  #LangChain  #LlamaIndex  #OCR  #Mechatronics  #IndustrialAI  #SmartManufacturing  #AIKnowledgeManagement  #SemanticSearch  #AIInfrastructure]]></category><category><![CDATA[llm]]></category><category><![CDATA[Query]]></category><dc:creator><![CDATA[Abed K]]></dc:creator><pubDate>Wed, 23 Apr 2025 05:41:09 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1745375458690/45488daf-f979-41f2-a471-0ade5b2207fe.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Sometimes, when working with RAG systems, we need to know exactly what context is being passed to the LLM. You run a query, the model gives an answer, sometimes strange or irrelevant, and you are left wondering , <em>“Did it actually get the right information? What was retrieved and sent to the model?”</em></p>
<p>Often, the issue isn’t with the model itself, it’s with the retrieved context from a database. Maybe irrelevant chunks were selected. Maybe important pieces were missing. To make this process more transparent, I added a simple but powerful feature to Microsoft GraphRAG: You can now print the raw context chunks that are sent to the LLM during a query. No additional configuration is required , the feature works out of the box. By default, the raw chunks are not displayed unless the <code>--raw-chunks</code> flag is explicitly provided, ensuring existing behavior remains unchanged.</p>
<p><code>graphrag query --method local --query "Do LLMs Struggle with Math Across Cultural Context" --root index --raw-chunks</code></p>
<p>With this <code>(--raw-chunks)</code> flag, GraphRAG will display not just the final answer, but also the raw retrieved context used to generate it. This gives you clear visibility into:</p>
<ul>
<li><p>What was retrieved</p>
</li>
<li><p>What was passed to the LLM</p>
</li>
<li><p>And why the model may have responded the way it did</p>
</li>
</ul>
<p>It’s a lightweight, non-intrusive addition , but it makes a big difference for debugging, development, and quality assurance.</p>
<h2 id="heading-code-changes-and-implementation-details"><strong>Code Changes and Implementation Details</strong></h2>
<p>To enable the <code>--raw-chunks</code> functionality in GraphRAG, I made modifications across six key files within the package. When you install GraphRAG using <code>pip install graphrag</code>, the full source code is downloaded into your environment. To implement this feature, you’ll need to dive into the installed package and directly modify the relevant components.</p>
<p>the following files are should be changed:</p>
<ul>
<li><p><code>graphrag/query/</code><a target="_blank" href="http://factory.py"><code>factory.py</code></a></p>
</li>
<li><p><code>graphrag/cli/</code><a target="_blank" href="http://main.py"><code>main.py</code></a></p>
</li>
<li><p><code>graphrag/cli/</code><a target="_blank" href="http://query.py"><code>query.py</code></a></p>
</li>
<li><p><code>graphrag/query/structured_search/local_search/</code><a target="_blank" href="http://search.py"><code>search.py</code></a></p>
</li>
<li><p><code>graphrag/query/structured_search/global_search/</code><a target="_blank" href="http://search.py"><code>search.py</code></a></p>
</li>
<li><p><code>graphrag/query/structured_search/drift_search/</code><a target="_blank" href="http://search.py"><code>search.py</code></a></p>
</li>
</ul>
<p>Each of these files plays a role in the query flow and structured search logic. The <code>cli</code> module handles command-line parsing, so I updated it to accept the new <code>--raw-chunks</code> flag. In the <a target="_blank" href="http://factory.py"><code>factory.py</code></a> and search-related modules, I integrated the logic needed to capture and return the raw retrieved context alongside the final output. No entirely new files, classes, or components were created , the feature was added by extending and modifying existing functions and classes within the current codebase. The implementation is designed to be non-intrusive, if the flag is not used, existing behavior remains unchanged.</p>
<ol>
<li><a target="_blank" href="http://factory.py"><strong>factory.py</strong></a> - Adding raw_chunks parameter to factory functions:</li>
</ol>
<pre><code class="lang-python"><span class="hljs-comment"># factory.py</span>

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">get_local_search_engine</span>(<span class="hljs-params">
    reports: dict[str, list[CommunityReport]],
    text_units: dict[str, list[TextUnit]],
    <span class="hljs-comment"># ... other parameters ...</span>
    callbacks: list[QueryCallbacks] | None = None,
    raw_chunks: bool = True,  <span class="hljs-comment"># Added parameter</span>
</span>) -&gt; LocalSearch:</span>
    <span class="hljs-string">"""Create a local search engine based on data + configuration."""</span>
    <span class="hljs-comment"># ... existing setup code ...</span>

    <span class="hljs-keyword">return</span> LocalSearch(
        model=chat_model,
        system_prompt=system_prompt,
        context_builder=LocalSearchMixedContext(
            community_reports=reports,
            text_units=text_units,
            <span class="hljs-comment"># ... other parameters ...</span>
        ),
        token_encoder=token_encoder,
        model_params=model_params,
        context_builder_params={
            <span class="hljs-comment"># ... existing params ...</span>
        },
        callbacks=callbacks,
        raw_chunks=raw_chunks  <span class="hljs-comment"># Pass the parameter</span>
    )

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">get_global_search_engine</span>(<span class="hljs-params">
    <span class="hljs-comment"># ... other parameters ...</span>
    callbacks: list[QueryCallbacks] | None = None,
    raw_chunks: bool = True,  <span class="hljs-comment"># Added parameter</span>
</span>) -&gt; GlobalSearch:</span>
    <span class="hljs-comment"># ... existing code ...</span>
    <span class="hljs-keyword">return</span> GlobalSearch(
        <span class="hljs-comment"># ... other parameters ...</span>
        callbacks=callbacks,
        raw_chunks=raw_chunks  <span class="hljs-comment"># Pass the parameter</span>
    )

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">get_drift_search_engine</span>(<span class="hljs-params">
    <span class="hljs-comment"># ... other parameters ...</span>
    callbacks: list[QueryCallbacks] | None = None,
    raw_chunks: bool = True,  <span class="hljs-comment"># Added parameter</span>
</span>) -&gt; DRIFTSearch:</span>
    <span class="hljs-comment"># ... existing code ...</span>
    <span class="hljs-keyword">return</span> DRIFTSearch(
        <span class="hljs-comment"># ... other parameters ...</span>
        callbacks=callbacks,
        raw_chunks=raw_chunks  <span class="hljs-comment"># Pass the parameter</span>
    )
</code></pre>
<ol start="2">
<li><a target="_blank" href="http://query.py"><strong>query.py</strong></a> - Adding raw chunks callback handling:</li>
</ol>
<pre><code class="lang-python"><span class="hljs-comment"># query.py</span>

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">RawChunksCallback</span>(<span class="hljs-params">QueryCallbacks</span>):</span>
    <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">on_context_chunk</span>(<span class="hljs-params">self, chunk_type: str, chunk_data: Any</span>):</span>
        <span class="hljs-string">"""Display raw chunks based on search type and chunk data."""</span>
        <span class="hljs-keyword">if</span> <span class="hljs-keyword">not</span> chunk_data:
            <span class="hljs-keyword">return</span>

        print(<span class="hljs-string">f"\n=== <span class="hljs-subst">{chunk_type}</span> ==="</span>)
        <span class="hljs-keyword">if</span> isinstance(chunk_data, dict):
            <span class="hljs-keyword">for</span> key, value <span class="hljs-keyword">in</span> chunk_data.items():
                print(<span class="hljs-string">f"<span class="hljs-subst">{key}</span>:"</span>, value)
        <span class="hljs-keyword">elif</span> isinstance(chunk_data, list):
            <span class="hljs-keyword">for</span> item <span class="hljs-keyword">in</span> chunk_data:
                print(<span class="hljs-string">"- "</span>, item)
        <span class="hljs-keyword">else</span>:
            print(chunk_data)
        print(<span class="hljs-string">"="</span> * <span class="hljs-number">40</span>)

    <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">on_map_reduce_chunk</span>(<span class="hljs-params">self, stage: str, data: Any</span>):</span>
        <span class="hljs-string">"""Display chunks from map-reduce process."""</span>
        print(<span class="hljs-string">f"\n=== <span class="hljs-subst">{stage}</span> ==="</span>)
        print(data)
        print(<span class="hljs-string">"="</span> * <span class="hljs-number">40</span>)
</code></pre>
<ol start="3">
<li><a target="_blank" href="http://main.py"><strong>main.py</strong></a> - Adding CLI support for raw chunk</li>
</ol>
<pre><code class="lang-python"><span class="hljs-comment"># main.py</span>

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">_query_cli</span>(<span class="hljs-params">
    query: str,
    search_type: str,
    config: GraphRagConfig,
    raw_chunks: bool = True,  <span class="hljs-comment"># Added parameter</span>
    <span class="hljs-comment"># ... other parameters ...</span>
</span>) -&gt; str:</span>
    <span class="hljs-string">"""Query the graph RAG system."""</span>
    <span class="hljs-keyword">if</span> search_type == <span class="hljs-string">"local"</span>:
        <span class="hljs-keyword">return</span> run_local_search(
            query,
            config,
            raw_chunks=raw_chunks,  <span class="hljs-comment"># Pass parameter</span>
            <span class="hljs-comment"># ... other parameters ...</span>
        )
    <span class="hljs-keyword">elif</span> search_type == <span class="hljs-string">"global"</span>:
        <span class="hljs-keyword">return</span> run_global_search(
            query,
            config,
            raw_chunks=raw_chunks,  <span class="hljs-comment"># Pass parameter</span>
            <span class="hljs-comment"># ... other parameters ...</span>
        )
    <span class="hljs-keyword">elif</span> search_type == <span class="hljs-string">"drift"</span>:
        <span class="hljs-keyword">return</span> run_drift_search(
            query,
            config,
            raw_chunks=raw_chunks,  <span class="hljs-comment"># Pass parameter</span>
            <span class="hljs-comment"># ... other parameters ...</span>
        )

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">main</span>():</span>
    parser = argparse.ArgumentParser()
    <span class="hljs-comment"># ... existing arguments ...</span>
    parser.add_argument(
        <span class="hljs-string">"--raw-chunks"</span>,
        action=<span class="hljs-string">"store_true"</span>,
        default=<span class="hljs-literal">True</span>,
        help=<span class="hljs-string">"Show raw chunks retrieved from vector store"</span>
    )
    args = parser.parse_args()

    response = _query_cli(
        args.query,
        args.search_type,
        config,
        raw_chunks=args.raw_chunks,  <span class="hljs-comment"># Pass CLI argument</span>
        <span class="hljs-comment"># ... other parameters ...</span>
    )
</code></pre>
<ol start="4">
<li><strong>Adding raw_chunks parameter to search functions:</strong></li>
</ol>
<ul>
<li><strong>Local Search Changes</strong>:</li>
</ul>
<pre><code class="lang-python"><span class="hljs-comment"># In LocalSearch constructor</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">LocalSearch</span>:</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">__init__</span>(<span class="hljs-params">
    self,
    <span class="hljs-comment"># ... other parameters ...</span>
    raw_chunks: bool = True  <span class="hljs-comment"># New parameter</span>
</span>):</span>
    self.raw_chunks = raw_chunks
    <span class="hljs-comment"># ... rest of initialization ...</span>

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">search</span>(<span class="hljs-params">self, query: str</span>) -&gt; str:</span>
    <span class="hljs-comment"># Get context</span>
    context = self.context_builder.build(query)

    <span class="hljs-comment"># Show raw chunks if enabled</span>
    <span class="hljs-keyword">if</span> self.raw_chunks:
        print(<span class="hljs-string">"\n=== Local Search Context ==="</span>)
        print(<span class="hljs-string">"Text Units:"</span>, context.text_units)
        print(<span class="hljs-string">"Community Reports:"</span>, context.community_reports)
        print(<span class="hljs-string">"========================\n"</span>)

    <span class="hljs-comment"># Process and return response</span>
    <span class="hljs-keyword">return</span> self._process_response(query, context)
</code></pre>
<ul>
<li><strong>Global Search Changes</strong>:</li>
</ul>
<pre><code class="lang-python"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">GlobalSearch</span>:</span>
    <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">__init__</span>(<span class="hljs-params">
        self,
        <span class="hljs-comment"># ... other parameters ...</span>
        raw_chunks: bool = True
    </span>):</span>
        self.raw_chunks = raw_chunks
        <span class="hljs-comment"># ... rest of initialization ...</span>

    <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">search</span>(<span class="hljs-params">self, query: str</span>) -&gt; str:</span>
        <span class="hljs-comment"># Map phase</span>
        map_responses = []
        <span class="hljs-keyword">for</span> batch <span class="hljs-keyword">in</span> self._get_batches():
            context = self.context_builder.build(query, batch)
            <span class="hljs-keyword">if</span> self.raw_chunks:
                print(<span class="hljs-string">f"\n=== Map Phase Batch <span class="hljs-subst">{len(map_responses)+<span class="hljs-number">1</span>}</span> ==="</span>)
                print(<span class="hljs-string">"Context:"</span>, context)
                print(<span class="hljs-string">"========================\n"</span>)
            response = self._map(query, context)
            map_responses.append(response)

        <span class="hljs-comment"># Reduce phase</span>
        <span class="hljs-keyword">if</span> self.raw_chunks:
            print(<span class="hljs-string">"\n=== Reduce Phase Context ==="</span>)
            print(<span class="hljs-string">"Map Responses:"</span>, map_responses)
            print(<span class="hljs-string">"========================\n"</span>)

        <span class="hljs-keyword">return</span> self._reduce(query, map_responses)
</code></pre>
<ul>
<li><strong>DRIFT Search Changes</strong>:</li>
</ul>
<pre><code class="lang-python"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">DRIFTSearch</span>:</span>
    <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">__init__</span>(<span class="hljs-params">
        self,
        <span class="hljs-comment"># ... other parameters ...</span>
        raw_chunks: bool = True
    </span>):</span>
        self.raw_chunks = raw_chunks
        <span class="hljs-comment"># ... rest of initialization ...</span>

    <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">search</span>(<span class="hljs-params">self, query: str</span>) -&gt; str:</span>
        <span class="hljs-comment"># Primer search</span>
        primer_context = self._get_primer_context(query)
        <span class="hljs-keyword">if</span> self.raw_chunks:
            print(<span class="hljs-string">"\n=== DRIFT Primer Context ==="</span>)
            print(<span class="hljs-string">"Query:"</span>, query)
            print(<span class="hljs-string">"Context:"</span>, primer_context)
            print(<span class="hljs-string">"========================\n"</span>)

        <span class="hljs-comment"># Follow-up searches</span>
        <span class="hljs-keyword">for</span> epoch <span class="hljs-keyword">in</span> range(self.n_depth):
            action_context = self._get_action_context(query, epoch)
            <span class="hljs-keyword">if</span> self.raw_chunks:
                print(<span class="hljs-string">f"\n=== DRIFT Action Context (Epoch <span class="hljs-subst">{epoch+<span class="hljs-number">1</span>}</span>) ==="</span>)
                print(<span class="hljs-string">"Context:"</span>, action_context)
                print(<span class="hljs-string">"========================\n"</span>)

        <span class="hljs-comment"># Final synthesis</span>
        final_context = self._get_final_context()
        <span class="hljs-keyword">if</span> self.raw_chunks:
            print(<span class="hljs-string">"\n=== DRIFT Final Synthesis Context ==="</span>)
            print(<span class="hljs-string">"Context:"</span>, final_context)
            print(<span class="hljs-string">"========================\n"</span>)

        <span class="hljs-keyword">return</span> self._process_response(query, final_context)
</code></pre>
<h2 id="heading-testing">Testing</h2>
<p>For Query: <code>graphrag query --method local --query "Do LLMs Struggle with Math Across Cultural Context" --root index</code></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1745384234785/d46b7bdf-ce43-4135-bb9e-4610baf047de.png" alt class="image--center mx-auto" /></p>
<p>For Query: <code>graphrag query --method local --query "Do LLMs Struggle with Math Across Cultural Context" --root index --raw-chunks</code></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1745384458837/b5a5c1a2-bdd3-4aae-a0dc-7f1b7fa7fa11.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1745384484696/91d323d6-92f6-4d06-b66a-51f872956527.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-current-status-and-contribution">Current Status and Contribution</h2>
<p>This feature is currently <a target="_blank" href="https://github.com/microsoft/graphrag/pull/1886"><strong>under review as a pull request</strong></a> to the main <a target="_blank" href="https://github.com/microsoft/graphrag">Microsoft GraphRAG repository</a>. Once the <a target="_blank" href="https://github.com/microsoft/graphrag/pull/1886">PR</a> is reviewed and (hopefully) merged, the <code>--raw-chunks</code> flag will become a part of the official release. At that point, we’ll simply be able to use it by upgrading our GraphRAG installation and adding <code>--raw-chunks</code> to your CLI query — no code modifications needed.</p>
<p>Until then, if we’d like to use this feature right away, we’ll need to make manual changes to the files listed above. For convenience, I’ve included the updated code snippets inside a <a target="_blank" href="https://colab.research.google.com/drive/1futs5tlsSUlZN9ZDmfl3T-_GdeI8CHoG?usp=sharing">Google Colab notebook</a>, so we can easily copy and paste the relevant parts into your local setup.</p>
]]></content:encoded></item><item><title><![CDATA[Old Parsers vs. Smart LLMs: Which Understands Messy Documents Better?]]></title><description><![CDATA[We are surrounded by very messy data. Text is often unsupervised, unlabeled, with images scattered throughout documents, along with complex figures and tables that hold most of the actual information. The insight is stored less in words and more in v...]]></description><link>https://small-experiments-with-ai.hashnode.dev/old-parsers-vs-smart-llms-who-understands-messy-documents-better</link><guid isPermaLink="true">https://small-experiments-with-ai.hashnode.dev/old-parsers-vs-smart-llms-who-understands-messy-documents-better</guid><category><![CDATA[AI]]></category><category><![CDATA[parsing]]></category><category><![CDATA[LLM's ]]></category><category><![CDATA[GPT-4o]]></category><category><![CDATA[#PromptEngineering]]></category><dc:creator><![CDATA[Abed K]]></dc:creator><pubDate>Sun, 30 Mar 2025 04:40:52 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/stock/unsplash/XlEsQ76Bwfw/upload/0d92ed0d48066fe55bf896827530d7ad.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>We are surrounded by very messy data. Text is often unsupervised, unlabeled, with images scattered throughout documents, along with complex figures and tables that hold most of the actual information. The insight is stored less in words and more in visual or structured elements. At the same time, there’s data in these documents that might not even be worth extracting like the table of contents or repetitive headers.</p>
<p>This mess of data is useless unless we extract it in a clean way without losing the actual semantic meaning or the true gist of the content. We need a structure. Each paragraph should connect with the next and the previous one. Extracted content from tables and figures should also maintain its context. Only then can we break down our data into meaningful chunks, create embeddings, form proper nodes and edges for Graph RAG, or use it for RAG pipelines in general. That’s how we pretrain models with intelligent input not just input and tokens, but smart tokens. So, we have two main approaches to extract text from documents:</p>
<ol>
<li><p>Using traditional built-in parsing libraries like llmsherpa, and</p>
</li>
<li><p>Using LLMs.</p>
</li>
</ol>
<h3 id="heading-llmsherpa"><strong>llmsherpa</strong></h3>
<p>llmsherpa library, which relies on LayoutPDFReader under the hood. These built-in parser libraries are pretty solid they can extract text from input documents, and llmsherpa is considered one of the most powerful among them. That’s because it doesn’t just pull raw text, it parses PDFs along with hierarchical layout information like:<br />Sections and subsections, paragraphs, links between sections and paragraphs, tables along captions and headings, etc. But here’s the issue, This works well only when the document itself is clean and properly structured. What happens when it gets a complex, messy document like below.</p>
<p><a target="_blank" href="https://dcj.nsw.gov.au/documents/service-providers/out-of-home-care-and-permanency-support-program/contracts-funding-and-packages/PSP_Packages_Eligibility_and_Inclusions_FC_ITC.pdf"><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1743305777063/38c5fd25-c472-4800-ad0e-b74375cfebfa.png" alt="Source of image: Open source: https://dcj.nsw.gov.au/documents/service-providers/out-of-home-care-and-permanency-support-program/contracts-funding-and-packages/PSP_Packages_Eligibility_and_Inclusions_FC_ITC.pdf" class="image--center mx-auto" /></a></p>
<p><a target="_blank" href="https://dcj.nsw.gov.au/documents/service-providers/out-of-home-care-and-permanency-support-program/contracts-funding-and-packages/PSP_Packages_Eligibility_and_Inclusions_FC_ITC.pdf">Source of the document</a></p>
<ul>
<li><p>It fails to identify sections and subsections because there’s no consistent formatting.</p>
</li>
<li><p>It struggles with paragraphs, especially when the line breaks and indentation are irregular.</p>
</li>
<li><p>And once the paragraphs and sections aren’t clearly identified, it can’t establish any meaningful links between them. Why? Because it just goes horizontally line by line, extracting text in a plain, mechanical way.</p>
</li>
</ul>
<p>Same goes for tables. It does extract table content but many times, tables come in complex structures.</p>
<ul>
<li><p>Sometimes the information is stacked vertically, sometimes horizontally, sometimes even nested.</p>
</li>
<li><p>But the parser just reads rows from left to right, without understanding the logical grouping of data.</p>
</li>
<li><p>The output ends up being a bunch of disconnected lines of text no structure, no meaning.</p>
</li>
</ul>
<p>And that’s the problem. If the parser doesn’t understand the true layout and logic of the content, how are we supposed to create nodes and edges from it for Graph RAG? How do we get usable embeddings from something that’s semantically broken? Example code and output of using llmsherpa to extract text from above document is following.</p>
<pre><code class="lang-python"><span class="hljs-keyword">import</span> os
<span class="hljs-keyword">import</span> sys
<span class="hljs-keyword">from</span> llmsherpa.readers <span class="hljs-keyword">import</span> LayoutPDFReader
llmsherpa_api_url = <span class="hljs-string">"http://localhost:5001/api/parseDocument?renderFormat=all"</span>
pdf_url = <span class="hljs-string">""</span>
pdf_reader = LayoutPDFReader(llmsherpa_api_url)
<span class="hljs-keyword">try</span>:
    doc = pdf_reader.read_pdf(pdf_url)
    <span class="hljs-keyword">with</span> open(<span class="hljs-string">"output.txt"</span>, <span class="hljs-string">"w"</span>) <span class="hljs-keyword">as</span> f:
        f.write(doc.to_text())
    print(<span class="hljs-string">"\nText output saved to output.txt"</span>)
<span class="hljs-keyword">except</span> Exception <span class="hljs-keyword">as</span> e:
    print(<span class="hljs-string">f"Error processing PDF: <span class="hljs-subst">{str(e)}</span>"</span>)
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1743306845514/6764bdaa-550f-430c-8e17-cc23966a7d5f.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-llms">LLMs</h3>
<p>Let’s talk about a different approach: using LLMs directly to parse documents — specifically gpt4o**,** which comes with built-in OCR capabilities and the ability to understand and extract information from images.</p>
<p>To test this, we take the same complex, messy document. We convert the pages into PNG and pass them to gpt4o using a carefully crafted prompt. The output? A structured representation of the content , far more aligned, coherent, and semantically rich than what we got using llmsherpa.</p>
<p>This method skips over the rigid parsing step and instead leans into the flexibility of LLMs. With gpt4o, it's possible to analyze each page of a document regardless of its structure and extract meaningful metadata. Text, tables, figures, and even complex visuals can be processed while preserving the connections and context.</p>
<p>One of the biggest advantages is how LLMs handle tables and graphics. They don’t just extract rows, they understand the logic. Whether the information is stacked vertically, horizontally, or embedded in visuals, gpt4o can interpret and describe it. Even in cases where figures or charts don’t contain text, the model is capable of explaining what the graphic represents and connecting it to surrounding content.</p>
<p>This approach leads to much cleaner, richer outputs text that retains meaning, tables that are actually useful, and visuals that are explained instead of ignored. The extracted chunks are not only easier to work with but are also better suited for embeddings, RAG pipelines, or graph-based representations like Graph RAG. An example of the prompt used with gpt4o, along with the extracted output from the same document, is shown below.</p>
<pre><code class="lang-python"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">TableContent</span>(<span class="hljs-params">BaseModel</span>):</span>
    <span class="hljs-string">"""Table information with semantic context"""</span>
    section: str = Field(description=<span class="hljs-string">"Table section or category"</span>)
    content: Dict[str, List[str]] = Field(description=<span class="hljs-string">"Structured table content"</span>)
    context: str = Field(description=<span class="hljs-string">"Table significance and relationships"</span>)
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ExtractedContent</span>(<span class="hljs-params">BaseModel</span>):</span>
    <span class="hljs-string">"""Document content with semantic preservation"""</span>
    title: str = Field(description=<span class="hljs-string">"Document title or main heading"</span>)
    raw_text: str = Field(description=<span class="hljs-string">"Complete raw text from the image in paragraph format"</span>)
    main_content: List[str] = Field(description=<span class="hljs-string">"Key content sections"</span>)
    table_content: List[TableContent] = Field(description=<span class="hljs-string">"Structured table data"</span>)
    technical_terms: List[str] = Field(description=<span class="hljs-string">"Technical terminology"</span>)
    visual_description: str = Field(description=<span class="hljs-string">"Visual element description"</span>)
    summary: str = Field(description=<span class="hljs-string">"Contextual summary"</span>)
`
`
response = client.chat.completions.create(
            model=<span class="hljs-string">"openai/chatgpt-4o-latest"</span>,
            messages=[
                {
                    <span class="hljs-string">"role"</span>: <span class="hljs-string">"system"</span>,
                    <span class="hljs-string">"content"</span>: <span class="hljs-string">"""Analyze document images with focus on 
                                   complete content extraction:
                    1. Raw Text Extraction:
                       - Extract ALL text exactly as it appears in the image
                       - Maintain paragraph structure and formatting
                       - Include ALL headers, labels, and annotations
                       - Preserve text order and hierarchy
                    2. Document Structure:
                       - Extract title and main headings
                       - Identify key content sections
                       - Maintain document hierarchy
                    3. Table Analysis:
                       - Carefully read and understand tables
                       - Preserve relationships between data
                       - Convert tables to meaningful text
                       - Explain table context and significance
                    4. Visual Analysis:
                       - Describe diagrams and flowcharts
                       - Explain visual relationships
                       - Provide context for images

                    `
                    `
                    `
                },
                {
                    "role": "user",
                    "content": [
                        {
                            "type": "text",
                            "text": "Extract ALL text from this image exactly as it appears,
                             maintaining formatting and structure. Also analyze tables and 
                              visual elements."
                 },</span>
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1743308163348/4b00222d-fb12-490a-a3d7-a1f100c76cb7.png" alt class="image--center mx-auto" /></p>
<p>There’s a clear difference between the two extracted outputs and it’s obvious that gpt4o performs intelligent extraction. With LLMs, the prompt can be tweaked based on the structure of the input document, giving full control over how the content is extracted. We are not just extracting text we are extracting intelligent tokens, preserving meaning, context, and structure. This kind of extraction might not be possible with traditional parsers. It’s the LLM that truly understands and extracts the data the way we need it.</p>
]]></content:encoded></item></channel></rss>