<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://mienxiu.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://mienxiu.com/" rel="alternate" type="text/html" /><updated>2026-08-15T13:25:02+00:00</updated><id>https://mienxiu.com/feed.xml</id><title type="html">Mienxiu</title><subtitle>An amazing website.</subtitle><author><name>Mienxiu</name></author><entry><title type="html">Relevance Feedback and Query Expansion</title><link href="https://mienxiu.com/relevance-feedback-and-query-expansion/" rel="alternate" type="text/html" title="Relevance Feedback and Query Expansion" /><published>2026-08-15T00:00:00+00:00</published><updated>2026-08-15T00:00:00+00:00</updated><id>https://mienxiu.com/relevance-feedback-and-query-expansion</id><content type="html" xml:base="https://mienxiu.com/relevance-feedback-and-query-expansion/"><![CDATA[<p>The problem of a pure keyword matching approach is that the systems can’t handle <em>synonymy</em> issue.
For example, you might search for “python” meaning the programming language, not the snake.
And a query for “sea” may fail to match documents that use “ocean” instead.</p>

<p>Two common ways to address this problem are relevance feedback and query expansion.
In relevance feedback, the system adjusts the query based on the documents that seem relevant in the first round of retrieval.
In query expansion, a query is expanded or reformulated to match other semantically similar terms.</p>

<script>
  MathJax = {
    output: {
      displayOverflow: 'scroll'
    },
    tex: {
      inlineMath: {'[+]': [['$', '$']]}
    }
  };
</script>

<script id="MathJax-script" async="" src="https://cdn.jsdelivr.net/npm/mathjax@4/tex-mml-chtml.js"></script>

<h2 id="relevance-feedback">Relevance Feedback</h2>

<p>Relevance feedback is used to improve both recall and precision.
The main idea is simple: use feedback from users to refine the query.</p>

<p>The process is typically as follows:</p>
<ol>
  <li>The user issues a query.</li>
  <li>The system returns an initial set of results.</li>
  <li>The user judges relevance of returned documents.</li>
  <li>The system updates the query based on the user feedback.</li>
  <li>The system returns a revised set of results.</li>
</ol>

<p><img src="/assets/posts/43/relevance_feedback.png" alt="Relevance feedback" /></p>

<p>Based on how step 3 is handled, there are three main types of relevance feedback:</p>
<ul>
  <li>Explicit relevance feedback</li>
  <li>Implicit relevance feedback</li>
  <li>Pseudo relevance feedback (also called blind or automatic relevance feedback)</li>
</ul>

<h3 id="explicit-relevance-feedback">Explicit Relevance Feedback</h3>

<p>In explicit relevance feedback, or just relevance feedback, users are asked to judge returned documents.
In practice, though, this approach is often limited because most users do not want to spend extra effort during search.
This is especially true when they have no strong incentive to provide that feedback.
It also becomes less practical when the result set is large, since judging many documents takes time.</p>

<p>Even so, explicit relevance feedback can still be useful in laboratory or experimental settings, where users may be motivated to provide relevance judgments.</p>

<h3 id="implicit-relevance-feedback">Implicit Relevance Feedback</h3>

<p>In implicit, or indirect, relevance feedback, the system uses user behavior logs such as clickthrough as relevance signals.
For example, documents clicked for a query may be treated as relevant, while documents that are not clicked may be treated as less relevant.
A higher number of clicks may also be interpreted as a stronger relevance signal.</p>

<p>This is probably the most common approach in real retrieval systems, because it does not ask users to provide explicit judgments.
It is also easier to collect at scale in systems with many users and large amounts of interaction data.</p>

<p>One limitation is that these signals are not always reliable.
A click does not always mean a document is relevant: it may be accidental, or even malicious.</p>

<p class="notice--info">Ranking advertisements for a search query in a web search engine is a closely related application of this idea.</p>

<h3 id="pseudo-relevance-feedback">Pseudo Relevance Feedback</h3>

<p>In pseudo relevance feedback, also called blind or automatic relevance feedback, the top-<code class="language-plaintext highlighter-rouge">k</code> documents in the initial results are assumed to be relevant.
So, unlike explicit or implicit feedback, this method does not require any user input.</p>

<p>Because it makes that assumption automatically, it is generally less reliable than other forms of relevance feedback.
For example, if the query is “john wick” and the top-<code class="language-plaintext highlighter-rouge">k</code> documents are mostly about Keanu Reeves, the updated query may drift toward documents about Keanu Reeves rather than the film itself.</p>

<p>One useful application of this method is association anaylsis.
For example, we can build a topic language model and compare it with the collection language model to extract terms that are strongly related to a given query.
These related terms can then be added to the original query.
This is a form of query expansion, which we will discuss later in this post.</p>

<h3 id="the-rocchio-algorithm">The Rocchio Algorithm</h3>

<p>The Rocchio algorithm is the most effective classic relevance feedback method in vector space model.
It is used in step 4, where the system updates the query based on user feedback.</p>

<p>The basic idea is to build a new query vector $\vec{q}$ that is closer to relevant documents and farther from nonrelevant ones.
In other words, we want a query vector that increases similarity to relevant documents while decreasing similarity to nonrelevant documents.</p>

<p><img src="/assets/posts/43/rocchio_algorithm.png" alt="The Rocchio algorithm" /></p>

<p>To do so, we need some knowledge of what documents are relevant (positive) and nonrelevant (negative).
This is where relevance feedback comes in.
Once we have the knowledge, we can move the initial query vector toward the centroid of the relevant documents and away from the centroid of the nonrelevant documents.</p>

<p>The formula is:</p>

\[\vec{q}_m=\alpha\vec{q}_o + \beta\frac{1}{\vert D_r \vert}\sum_{\vec{d}_j \in D_r}\vec{d}_j - \gamma\frac{1}{\vert D_{nr} \vert}\sum_{\vec{d}_j \in D_{nr}}\vec{d}_j\]

<p>Where:</p>
<ul>
  <li>$\vec{q_m}$: the modified (new) vector</li>
  <li>$\vec{q_o}$: the original query vector</li>
  <li>$D_r$: the set of known relevant documents</li>
  <li>$D_{nr}$: the set of known nonrelevant documents</li>
  <li>$\alpha$: the original query weight</li>
  <li>$\beta$: the related documents weight</li>
  <li>$\gamma$: the nonrelated documents weight</li>
  <li>$\frac{1}{\vert D_r \vert}\sum_{\vec{d}_j \in D_r}\vec{d}_j$: the centroid (vector) of positive examples</li>
  <li>\(\frac{1}{\vert D_{nr} \vert}\sum_{\vec{d}_j \in D_{nr}}\vec{d}_j\): the centroid (vector) of negative examples</li>
</ul>

<p>As the formula shows, three parameters ($\alpha$, $\beta$, $\gamma$) control how much weight we give to the original query, the relevant documents, and the nonrelevant documents.
For example, if we have enough judged documents to trust the feedback, we may want to use higher values for $\beta$ and $\gamma$.</p>

<p>In practice, $\alpha$ is usually kept relatively high to preserve the importance of the original query vector.
This also helps avoid overfitting when the number of feedback samples is small.
Meanwhile, $\gamma$ is often kept small, or even set to 0.
This is because nonrelevant documents are not always clustered together, so they can push the query in many different directions.
Therefore, reasonable values might be something like:</p>
<ul>
  <li>$\alpha$ = 1</li>
  <li>$\beta$ = 0.5</li>
  <li>$\gamma$ = 0.1</li>
</ul>

<p>Here’s an example.
Suppose the query $\vec{q}$ is <code class="language-plaintext highlighter-rouge">hybrid search</code>, and the system returns four documents:</p>

<table>
  <thead>
    <tr>
      <th>Document</th>
      <th>Text</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>D1</td>
      <td>a successful search system</td>
    </tr>
    <tr>
      <td>D2</td>
      <td>hybrid search in Elasticsearch</td>
    </tr>
    <tr>
      <td>D3</td>
      <td>hybrid search combines keyword search and semantic search</td>
    </tr>
    <tr>
      <td>D4</td>
      <td>hybrid engine can reduce fuel consumption</td>
    </tr>
  </tbody>
</table>

<p>The user judges the second and third documents as relevant, and the fourth document as nonrelevant:</p>

<table>
  <thead>
    <tr>
      <th>Document</th>
      <th>Text</th>
      <th>Feedback</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>D1</td>
      <td>a successful search system</td>
      <td> </td>
    </tr>
    <tr>
      <td>D2</td>
      <td>hybrid search in Elasticsearch</td>
      <td>O</td>
    </tr>
    <tr>
      <td>D3</td>
      <td>hybrid search combines keyword search and semantic search</td>
      <td>O</td>
    </tr>
    <tr>
      <td>D4</td>
      <td>hybrid engine can reduce fuel consumption</td>
      <td>X</td>
    </tr>
  </tbody>
</table>

<p>We first need to define a consistent term order for the vector dimensions.
If we list the unique terms from the query and the four documents in alphabetical order, we get:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>('a', 'and', 'can', 'combines', 'consumption', 'Elasticsearch', 'engine', 'fuel', 'hybrid', 'in', 'keyword', 'reduce', 'search', 'semantic', 'successful', 'system')
</code></pre></div></div>

<p>So this is a 16-dimensional vector space.</p>

<p>Assume the search engine uses term frequency, but not length normalization or IDF.
Then each document can be represented as follows:</p>

<table>
  <thead>
    <tr>
      <th>Document</th>
      <th>Text</th>
      <th>Feedback</th>
      <th>Vector</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>D1</td>
      <td>a successful search system</td>
      <td> </td>
      <td>$(1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1)$</td>
    </tr>
    <tr>
      <td>D2</td>
      <td>hybrid search in Elasticsearch</td>
      <td>O</td>
      <td>$(0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 0)$</td>
    </tr>
    <tr>
      <td>D3</td>
      <td>hybrid search combines keyword search and semantic search</td>
      <td>O</td>
      <td>$(0, 1, 0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 3, 1, 0, 0)$</td>
    </tr>
    <tr>
      <td>D4</td>
      <td>hybrid engine can reduce fuel consumption</td>
      <td>X</td>
      <td>$(0, 0, 1, 0, 1, 0, 1, 1, 1, 0, 0, 1, 0, 0, 0, 0)$</td>
    </tr>
  </tbody>
</table>

<p>The query $\vec{q}$ in the same vector space is:</p>

\[\vec{q} = (0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)\]

<p>Assume the system uses Rocchio relevance feedback with $\alpha = 1$, $\beta = 1$, and $\gamma = 0$.
Then the modified query becomes:</p>

\[\vec{q}_m = \vec{q}_0 + \frac{1}{2} (\vec{d}_2 + \vec{d}_3) - \frac{0}{1} (\vec{d}_4)\]

<p>Since $\gamma = 0$, the nonrelevant document $\vec{d}_4$ is ignored.</p>

<p>So the final modified query vector $\vec{q}_m$ is:</p>

\[\begin{aligned}
\vec{q}_m &amp;= (0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0) \\
&amp;+ \frac{1}{2} \big( (0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 0) + (0, 1, 0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 3, 1, 0, 0) \big) \\
&amp;= (0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0) + \frac{1}{2} (0, 1, 0, 1, 0, 1, 0, 0, 2, 1, 1, 0, 4, 1, 0, 0) \\
&amp;= (0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0) + (0, 0.5, 0, 0.5, 0, 0.5, 0, 0, 1, 0.5, 0.5, 0, 2, 0.5, 0, 0) \\
&amp;= (0, 0.5, 0, 0.5, 0, 0.5, 0, 0, 2, 0.5, 0.5, 0, 3, 0.5, 0, 0)
\end{aligned}\]

<p>In a more readable format:</p>

\[\vec{q}_m = \begin{pmatrix} \text{a}: 0 \\ \text{and}: 0.5 \\ \text{can}: 0 \\ \text{combines}: 0.5 \\ \text{consumption}: 0 \\ \text{Elasticsearch}: 0.5 \\ \text{engine}: 0 \\ \text{fuel}: 0 \\ \text{hybrid}: 2.0 \\ \text{in}: 0.5 \\ \text{keyword}: 0.5 \\ \text{reduce}: 0 \\ \text{search}: 3.0 \\ \text{semantic}: 0.5 \\ \text{successful}: 0 \\ \text{system}: 0 \end{pmatrix}\]

<p>One potential problem of this method is computational cost.
After relevance feedback, the updated query vector may contain many terms from relevant documents.
In our example, the original query has only 2 non-zero terms, but the updated query has 8.</p>

<p>In practice, we often truncate the modified vector and keep only the highest-weighted terms for efficiency.
In the modified query vector above, the top terms are <code class="language-plaintext highlighter-rouge">search</code> (3.0), <code class="language-plaintext highlighter-rouge">hybrid</code> (2.0), and then several terms with weight 0.5.
If we keep only the top 3 terms, the new query becomes roughly <code class="language-plaintext highlighter-rouge">search hybrid Elasticsearch</code>.</p>

<p>As the Rocchio algorithm works within a vector space model, it is not limited to text retrieval systems.
It can be applied to any type of data that can be represented as vectors, including images, audio, and learned embeddings.</p>

<h4 id="demonstration">Demonstration</h4>

<p>For this demonstration, I embedded a collection of images from <a href="https://pixabay.com/">Pixabay</a>.
I used <a href="https://huggingface.co/openai/clip-vit-base-patch32">openai/clip-vit-base-patch32</a> to generate the image embeddings, then reduced them to a 256-dimensional vector space.</p>

<p>The first image shows the initial results for the query “cat”:</p>

<p><img src="/assets/posts/43/rocchio_example0.jpg" alt="Initial result" /></p>

<p>Now suppose the user marks six images as relevant (blue boxes) and two cat food images as nonrelevant (red boxes):</p>

<p><img src="/assets/posts/43/rocchio_example1.jpg" alt="Explicit feedback" /></p>

<p>At this point, the system has useful information about which documents are relevant and which are nonrelevant for the query cat.
Using this feedback, we can modify the query vector with the Rocchio algorithm.</p>

<p>The next image shows the revised results after updating the query vector with $\alpha=1$, $\beta=0.5$, and $\gamma=0.1$:</p>

<p><img src="/assets/posts/43/rocchio_example2.jpg" alt="Rocchio algorithm applied" /></p>

<p>As you can see, the images explicitly marked as non-relevant no longer appear in the results, and more relevant images are shown instead.</p>

<p>However, increasing $\gamma$ can hurt the results.
The next image shows the revised results when the query vector is updated with $\alpha=1$, $\beta=0.5$, and $\gamma=0.8$:</p>

<p><img src="/assets/posts/43/rocchio_example3.jpg" alt="Rocchio algorithm applied with higher gamma value" /></p>

<p>In this result set, nonrelevant images are mixed in with the cat images because the query is pulled too strongly by the negative examples.</p>

<p>For reference, here is the Rocchio implementation I used in this demonstration:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">numpy</span> <span class="k">as</span> <span class="n">np</span>


<span class="k">def</span> <span class="nf">get_modified_query_vector</span><span class="p">(</span>
    <span class="n">original_query_vector</span><span class="p">:</span> <span class="n">np</span><span class="p">.</span><span class="n">ndarray</span><span class="p">,</span>
    <span class="n">relevant_document_vectors</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="n">np</span><span class="p">.</span><span class="n">ndarray</span><span class="p">],</span>
    <span class="n">nonrelevant_document_vectors</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="n">np</span><span class="p">.</span><span class="n">ndarray</span><span class="p">],</span>
    <span class="n">alpha</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">1.0</span><span class="p">,</span>
    <span class="n">beta</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">0.1</span><span class="p">,</span>
    <span class="n">gamma</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mi">0</span><span class="p">,</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="n">np</span><span class="p">.</span><span class="n">ndarray</span><span class="p">:</span>
    <span class="s">"""
    Calculate the modified query vector using the Rocchio algorithm.

    Args:
        original_query_vector: The original query vector.
        relevant_document_vectors: List of vectors for relevant documents.
        nonrelevant_document_vectors: List of vectors for non-relevant documents.
        alpha: Weight for the original query vector.
        beta: Weight for the relevant document vectors.
        gamma: Weight for the non-relevant document vectors.

    Returns:
        The modified query vector.
    """</span>
    <span class="c1"># Calculate the mean vector of relevant documents
</span>    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">relevant_document_vectors</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">:</span>
        <span class="n">mean_relevant_vector</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">mean</span><span class="p">(</span><span class="n">relevant_document_vectors</span><span class="p">,</span> <span class="n">axis</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span>
    <span class="k">else</span><span class="p">:</span>
        <span class="n">mean_relevant_vector</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">zeros_like</span><span class="p">(</span><span class="n">original_query_vector</span><span class="p">)</span>

    <span class="c1"># Calculate the mean vector of non-relevant documents
</span>    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">nonrelevant_document_vectors</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">:</span>
        <span class="n">mean_nonrelevant_vector</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">mean</span><span class="p">(</span><span class="n">nonrelevant_document_vectors</span><span class="p">,</span> <span class="n">axis</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span>
    <span class="k">else</span><span class="p">:</span>
        <span class="n">mean_nonrelevant_vector</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">zeros_like</span><span class="p">(</span><span class="n">original_query_vector</span><span class="p">)</span>

    <span class="c1"># Apply the Rocchio formula
</span>    <span class="n">modified_query_vector</span> <span class="o">=</span> <span class="p">(</span>
        <span class="n">alpha</span> <span class="o">*</span> <span class="n">original_query_vector</span>
        <span class="o">+</span> <span class="n">beta</span> <span class="o">*</span> <span class="n">mean_relevant_vector</span>
        <span class="o">-</span> <span class="n">gamma</span> <span class="o">*</span> <span class="n">mean_nonrelevant_vector</span>
    <span class="p">)</span>

    <span class="k">return</span> <span class="n">modified_query_vector</span>
</code></pre></div></div>

<h3 id="constraints-and-limitations">Constraints and Limitations</h3>

<p>Relevance feedback alone may not be sufficient in certain conditions:</p>
<ul>
  <li>Misspellings or typos. These are better handled with spelling correction.</li>
  <li>Cross-language information retrieval. For example, if you search for “global warming” in a collection of Korean documents that use the term “지구 온난화”, relevance feedback may not help if it only learns associations between English terms. This problem is usually handled with machine translation or cross-lingual semantic mapping.</li>
  <li>Synonymy. If you search for “laptop” but the documents use “notebook computer,” the query may miss relevant results. One way to handle this is query expansion using a predefined dictionary or thesaurus.</li>
</ul>

<p>The Rocchio algorithm represents relevant documents with a single centroid, so it works best when those documents form a cluster.
It may not work well when relevant documents are split into groups that use different vocabulary, such as “Burma” and “Myanmar.”
(The country of Burma was renamed to Myanmar in 1989.)</p>

<p>Relevance feedback is also less useful in web search.
Its main benefit is often improved recall, but most web search users do not care much about high recall.
They are usually satisfied if the system returns a small number of highly relevant results.</p>

<h2 id="query-expansion">Query Expansion</h2>

<p>In relevance feedback, user feedback is used to modify the query vector.
In query expansion, the original query is replaced with an alternative query or expanded with related terms.
Like relevance feedback, query expansion is primarily used to improve recall.</p>

<p>There are two main ways to do this:</p>
<ul>
  <li>Interactive query expansion: The system shows users related queries, and the user chooses one of them. This is often called query suggestion. For example, if a user searches for <code class="language-plaintext highlighter-rouge">apple</code>, the interface might suggest queries such as <code class="language-plaintext highlighter-rouge">apple store</code> or <code class="language-plaintext highlighter-rouge">apple support</code>.</li>
  <li>Automatic query expansion: The system automatically adds synonyms or related terms to the query. For example, if a user searches for <code class="language-plaintext highlighter-rouge">Burma</code>, the system may also search for <code class="language-plaintext highlighter-rouge">Myanmar</code>. In this case, the user may not even notice that the query has been expanded.</li>
</ul>

<p>The main problem is how to generate these alternative or expanded queries.
The most common approach is to use some form of thesaurus.
That is, for each term <code class="language-plaintext highlighter-rouge">t</code> in the query, the system can expand the query with synonyms or related words for <code class="language-plaintext highlighter-rouge">t</code> taken from the thesaurus.
A thesaurus-based approach has the advantage that it does not require user feedback.</p>

<p>The entries in a thesaurus can be created manually or generated automatically.</p>

<h3 id="manual-thesaurus">Manual Thesaurus</h3>

<p>In a manual thesaurus, developers or human editors manage sets of synonyms and alternative terms.
This approach is especially useful in specialized domains, where term relationships need to be carefully curated.
It can also be useful for fast-changing or trendy queries, where developers want to quickly add related terms that may not yet appear in a general thesaurus.</p>

<p>When building a thesaurus from scratch, developers can start from public or open-source thesauri.
For example, the UMLS Metathesaurus is a useful resource for building search systems in the biomedical domain.</p>

<p>A simple yet practical way to build up a thesaurus is to use a document database like Elasticsearch.
By leveraging an inverted index, each synonym can be looked up quickly, making it easy to retrieve the full synonym list from a query term.</p>

<p>For example, we can create a simple <code class="language-plaintext highlighter-rouge">thesaurus</code> index like this:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">PUT</span><span class="w"> </span><span class="err">thesaurus</span><span class="w">
</span><span class="p">{</span><span class="w">
  </span><span class="nl">"mappings"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"synonyms"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"keyword"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>If you need to designate canonical terms for each set of synonyms, then you can add another field such as <code class="language-plaintext highlighter-rouge">canonical_term</code>:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">PUT</span><span class="w"> </span><span class="err">thesaurus</span><span class="w">
</span><span class="p">{</span><span class="w">
  </span><span class="nl">"mappings"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"synonyms"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"keyword"</span><span class="w">
      </span><span class="p">},</span><span class="w">
      </span><span class="nl">"canonical_term"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"keyword"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>A canonical term is the preferred or normalized form of a synonym group.
For example, you might want to treat “burma” as a synonym, but internally normalize it to “myanmar”.</p>

<p>You can add an entry to this thesaurus like this:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">POST</span><span class="w"> </span><span class="err">thesaurus/_doc</span><span class="w">
</span><span class="p">{</span><span class="w">
  </span><span class="nl">"synonyms"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="s2">"burma"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"myanmar"</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Then, when a user searches for one term, you can look it up in the thesaurus and retrieve the full synonym group.
For example, if the query contains “burma”, we can search for thesaurus entries that include that term:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">GET</span><span class="w"> </span><span class="err">thesaurus/_search</span><span class="w">
</span><span class="p">{</span><span class="w">
  </span><span class="nl">"query"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"term"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"synonyms"</span><span class="p">:</span><span class="w"> </span><span class="s2">"burma"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Response:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="err">...</span><span class="p">,</span><span class="w">
  </span><span class="nl">"hits"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="err">...</span><span class="p">,</span><span class="w">
    </span><span class="nl">"hits"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="p">{</span><span class="w">
        </span><span class="err">...</span><span class="p">,</span><span class="w">
        </span><span class="nl">"_source"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
          </span><span class="nl">"synonyms"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
            </span><span class="s2">"burma"</span><span class="p">,</span><span class="w">
            </span><span class="s2">"myanmar"</span><span class="w">
          </span><span class="p">]</span><span class="w">
        </span><span class="p">}</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">]</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Now the search system can expand the original query using the returned synonym set.</p>

<h3 id="automatic-thesaurus-generation">Automatic Thesaurus Generation</h3>

<p>Because building a manual thesaurus is expensive, we can try to generate one automatically.</p>

<p>One approach is to use word co-occurrence statistics over a document collection.
The idea is that words that co-occur in the same document or paragraph are likely to be semantically similar or related.</p>

<p>The simplest way to build a co-occurrence thesaurus is to compute term-term similarity.
This approach starts with a term-document matrix $A$:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th style="text-align: right">$d_1$</th>
      <th style="text-align: right">$d_2$</th>
      <th style="text-align: right">$d_3$</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>$t_1$</td>
      <td style="text-align: right">$A_{t_1,d_1}$</td>
      <td style="text-align: right">$A_{t_1,d_2}$</td>
      <td style="text-align: right">$A_{t_1,d_3}$</td>
    </tr>
    <tr>
      <td>$t_2$</td>
      <td style="text-align: right">$A_{t_2,d_1}$</td>
      <td style="text-align: right">$A_{t_2,d_2}$</td>
      <td style="text-align: right">$A_{t_2,d_3}$</td>
    </tr>
    <tr>
      <td>$t_3$</td>
      <td style="text-align: right">$A_{t_3,d_1}$</td>
      <td style="text-align: right">$A_{t_3,d_2}$</td>
      <td style="text-align: right">$A_{t_3,d_3}$</td>
    </tr>
  </tbody>
</table>

<p>Each cell $A_{t,d}$ is a weight $w_{t,d}$ of term $t$ in document $d$.
If we compute $C = AA^T$, then each entry $C_{u,v}$ measures how strongly terms $u$ and $v$ are related.
The larger the value, the more related the two terms are.</p>

<p>Here’s a simple example.
Suppose we have three documents:</p>
<ul>
  <li>$d_1$ (about cars)</li>
  <li>$d_2$ (about cars)</li>
  <li>$d_2$ (about fruit)</li>
</ul>

<p>And four terms:</p>
<ul>
  <li>$t_1$: car</li>
  <li>$t_2$: automobile</li>
  <li>$t_3$: engine</li>
  <li>$t_4$: banana</li>
</ul>

<p>Let the term-document matrix $A$ be:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th style="text-align: right">$d_1$</th>
      <th style="text-align: right">$d_2$</th>
      <th style="text-align: right">$d_3$</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>car</td>
      <td style="text-align: right">1.0</td>
      <td style="text-align: right">0.8</td>
      <td style="text-align: right">0.0</td>
    </tr>
    <tr>
      <td>automobile</td>
      <td style="text-align: right">0.9</td>
      <td style="text-align: right">1.0</td>
      <td style="text-align: right">0.0</td>
    </tr>
    <tr>
      <td>engine</td>
      <td style="text-align: right">0.8</td>
      <td style="text-align: right">0.7</td>
      <td style="text-align: right">0.0</td>
    </tr>
    <tr>
      <td>banana</td>
      <td style="text-align: right">0.0</td>
      <td style="text-align: right">0.0</td>
      <td style="text-align: right">1.0</td>
    </tr>
  </tbody>
</table>

<p>Notice that “car”, “automobile”, and “engine” are strong in $d_1$ and $d_2$ while banana only appears in $d_3$.</p>

<p>By multipyling $A$ and $A^T$:</p>

\[\begin{bmatrix}
1.0 &amp; 0.8 &amp; 0.0 \\
0.9 &amp; 1.0 &amp; 0.0 \\
0.8 &amp; 0.7 &amp; 0.0 \\
0.0 &amp; 0.0 &amp; 1.0
\end{bmatrix}
\times
\begin{bmatrix}
1.0 &amp; 0.9 &amp; 0.8 &amp; 0.0 \\
0.8 &amp; 1.0 &amp; 0.7 &amp; 0.0 \\
0.0 &amp; 0.0 &amp; 0.0 &amp; 1.0
\end{bmatrix}
=
\begin{bmatrix}
1.64 &amp; 1.70 &amp; 1.36 &amp; 0.00 \\
1.70 &amp; 1.81 &amp; 1.42 &amp; 0.00 \\
1.36 &amp; 1.42 &amp; 1.13 &amp; 0.00 \\
0.00 &amp; 0.00 &amp; 0.00 &amp; 1.00
\end{bmatrix}\]

<p>we get the term-term matrix $C$:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th style="text-align: right">car</th>
      <th style="text-align: right">automobile</th>
      <th style="text-align: right">engine</th>
      <th style="text-align: right">banana</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>car</td>
      <td style="text-align: right">1.64</td>
      <td style="text-align: right">1.70</td>
      <td style="text-align: right">1.36</td>
      <td style="text-align: right">0.0</td>
    </tr>
    <tr>
      <td>automobile</td>
      <td style="text-align: right">1.70</td>
      <td style="text-align: right">1.81</td>
      <td style="text-align: right">1.42</td>
      <td style="text-align: right">0.0</td>
    </tr>
    <tr>
      <td>engine</td>
      <td style="text-align: right">1.36</td>
      <td style="text-align: right">1.42</td>
      <td style="text-align: right">1.13</td>
      <td style="text-align: right">0.0</td>
    </tr>
    <tr>
      <td>banana</td>
      <td style="text-align: right">0.00</td>
      <td style="text-align: right">0.00</td>
      <td style="text-align: right">0.00</td>
      <td style="text-align: right">1.0</td>
    </tr>
  </tbody>
</table>

<p>Each $C_{u,v}$ is just the dot product of the row for term $u$ and the row for term $v$:</p>

\[C_{u,v} = \sum_{d}A_{u,d}A_{v,d}\]

<p>For example, the similarity between “car” and “engine” is:</p>

\[\begin{aligned}
C_{\text{car}, \text{engine}} &amp;= [1.0, 0.8, 0.0] \cdot [0.8, 0.7, 0.0] \\
&amp;= 1.0\cdot0.8 + 0.8\cdot0.7 + 0.0\cdot0.0 \\
&amp;= 1.36
\end{aligned}\]

<p>So, the system can build entries like:</p>
<ul>
  <li>car -&gt; automobile, engine</li>
  <li>automobile -&gt; car, engine</li>
  <li>banana -&gt; (none of these)</li>
</ul>

<h3 id="constraints-and-limitations-1">Constraints and Limitations</h3>

<p>A manual thesaurus is expensive to build and maintain.
For domain-specific systems, a general thesaurus is often not enough, because it may not cover specialized vocabulary well.
For example, in medical or legal search systems, specialized terminology and relationships must be covered as well.</p>

<p>A thesaurus generated automatically from co-occurrence statistics also has clear limits.
It does not necessarily capture true synonyms.
Instead, it captures words that have similar distributions across documents, such as “car” and “engine”.
Its quality also depends heavily on the document collection.
If the corpus is small, the statistics may be unreliable.
If the corpus is biased toward a particular topic, the thesaurus will reflect that bias.
For example, in a general-language corpus, likely neighbors of the term “virus” might be “pathogen”, “infection”, and “germ”.
But if the corpus is heavily biased toward medicine and public health, virus may co-occur much more often with words like “vaccine”, “patient”, and “outbreak”.
As a result, the generated thesaurus may rank those words highly.</p>

<h3 id="elasticsearch-support-for-query-expansion">Elasticsearch Support for Query Expansion</h3>

<p>Elasticsearch, an open-source search and analytics engine, supports the <code class="language-plaintext highlighter-rouge">significant_terms</code> and <code class="language-plaintext highlighter-rouge">significant_text</code> aggregations.
These can be useful for discovering related terms automatically, at least to some extent.</p>

<p>These do not compute plain co-occurrence counts.
Instead, they find terms that are unusually common in a foreground set compared with a background set:</p>
<ul>
  <li>Foreground set: the search results matched by a query</li>
  <li>Background set: the index or indices from which the results were gathered</li>
</ul>

<p>For example, the following query searches headlines for “health” and finds terms that are unusually common in the <code class="language-plaintext highlighter-rouge">headline</code> field of those results compared with the whole index:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">GET</span><span class="w"> </span><span class="err">news_categories/_search</span><span class="w">
</span><span class="p">{</span><span class="w">
  </span><span class="nl">"size"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
  </span><span class="nl">"query"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"match"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"headline"</span><span class="p">:</span><span class="w"> </span><span class="s2">"health"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"aggregations"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"keywords"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"significant_text"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"field"</span><span class="p">:</span><span class="w"> </span><span class="s2">"headline"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Response:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="err">...</span><span class="w">
  </span><span class="nl">"hits"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"total"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="mi">2349</span><span class="p">,</span><span class="w">
      </span><span class="nl">"relation"</span><span class="p">:</span><span class="w"> </span><span class="s2">"eq"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"max_score"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
    </span><span class="nl">"hits"</span><span class="p">:</span><span class="w"> </span><span class="p">[]</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"aggregations"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"keywords"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"doc_count"</span><span class="p">:</span><span class="w"> </span><span class="mi">2349</span><span class="p">,</span><span class="w">
      </span><span class="nl">"bg_count"</span><span class="p">:</span><span class="w"> </span><span class="mi">628581</span><span class="p">,</span><span class="w">
      </span><span class="nl">"buckets"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="err">...</span><span class="w">
        </span><span class="p">{</span><span class="w">
          </span><span class="nl">"key"</span><span class="p">:</span><span class="w"> </span><span class="s2">"care"</span><span class="p">,</span><span class="w">
          </span><span class="nl">"doc_count"</span><span class="p">:</span><span class="w"> </span><span class="mi">706</span><span class="p">,</span><span class="w">
          </span><span class="nl">"score"</span><span class="p">:</span><span class="w"> </span><span class="mf">14.049006021392186</span><span class="p">,</span><span class="w">
          </span><span class="nl">"bg_count"</span><span class="p">:</span><span class="w"> </span><span class="mi">3957</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="p">{</span><span class="w">
          </span><span class="nl">"key"</span><span class="p">:</span><span class="w"> </span><span class="s2">"mental"</span><span class="p">,</span><span class="w">
          </span><span class="nl">"doc_count"</span><span class="p">:</span><span class="w"> </span><span class="mi">270</span><span class="p">,</span><span class="w">
          </span><span class="nl">"score"</span><span class="p">:</span><span class="w"> </span><span class="mf">6.105789285390884</span><span class="p">,</span><span class="w">
          </span><span class="nl">"bg_count"</span><span class="p">:</span><span class="w"> </span><span class="mi">1335</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="p">{</span><span class="w">
          </span><span class="nl">"key"</span><span class="p">:</span><span class="w"> </span><span class="s2">"insurance"</span><span class="p">,</span><span class="w">
          </span><span class="nl">"doc_count"</span><span class="p">:</span><span class="w"> </span><span class="mi">85</span><span class="p">,</span><span class="w">
          </span><span class="nl">"score"</span><span class="p">:</span><span class="w"> </span><span class="mf">1.3707596512376896</span><span class="p">,</span><span class="w">
          </span><span class="nl">"bg_count"</span><span class="p">:</span><span class="w"> </span><span class="mi">585</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="err">...</span><span class="w">
      </span><span class="p">]</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<p>The results suggest that “care”, “mental”, and “insurance” are strongly associated with “health” in headlines.</p>

<p>Take “care” as an example.
It appears in the <code class="language-plaintext highlighter-rouge">headline</code> field of 3,957 documents in the full index, as shown by <code class="language-plaintext highlighter-rouge">bg_count</code>.
Of those, 706 appearances are in the 2,349 documents whose headlines matched “health”.
That means “care” is unusually common in the foreground set compared with the background set, so Elasticsearch considers it significant.</p>

<p>A user might then choose to add “care” to the query, for example to expand a search for “health” into something like “health care” or to otherwise improve recall.</p>

<p>(The demonstration uses the <a href="https://www.kaggle.com/datasets/rmisra/news-category-dataset?select=News_Category_Dataset_v3.json">News Category Dataset by Rishabh Misra</a> from Kaggle, licensed under <a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a>.)</p>

<p>The query above is the simplest way to use the <code class="language-plaintext highlighter-rouge">significant_text</code> aggregation.
However, re-analyzing many matching documents requires a lot of time and memory.
For better performance, it is recommended using <code class="language-plaintext highlighter-rouge">significant_text</code> aggregation with a <code class="language-plaintext highlighter-rouge">sampler</code>, as shown below:</p>
<div class="language-json notice--warning highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">GET</span><span class="w"> </span><span class="err">news_categories/_search</span><span class="w">
</span><span class="p">{</span><span class="w">
  </span><span class="nl">"size"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
  </span><span class="nl">"query"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"match"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"headline"</span><span class="p">:</span><span class="w"> </span><span class="s2">"health"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"aggregations"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"my_sample"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"sampler"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"shard_size"</span><span class="p">:</span><span class="w"> </span><span class="mi">100</span><span class="w">
      </span><span class="p">},</span><span class="w">
      </span><span class="nl">"aggregations"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"keywords"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
          </span><span class="nl">"significant_text"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"field"</span><span class="p">:</span><span class="w"> </span><span class="s2">"headline"</span><span class="w">
          </span><span class="p">}</span><span class="w">
        </span><span class="p">}</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>One advantage of this approach over raw co-occurrence is that it automatically suppresses globally common words.
However, just like co-occurrence statistics, the selected terms are not necessarily similar or related terms.</p>

<h2 id="conclusion">Conclusion</h2>

<p>For improving recall, relevance feedback is generally more effective than query expansion.
That said, finding good values for the Rocchio weights, $\alpha$, $\beta$, and $\gamma$, is still a problem.</p>

<p>Query expansion, on the other hand, can hurt precision if it is used carelessly.
However, it is easier for developers to understand than relevance feedback.
Therefore, it is much more straightforward to implement, reason about, and debug.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://nlp.stanford.edu/IR-book/information-retrieval-book.html">An Introduction to Information Retrieval by Christopher D. Manning, Prabhakar Raghavan, and Hinrich Schütze</a></li>
  <li><a href="https://www.coursera.org/learn/text-retrieval">Text Retrieval and Search Engines by University of Illinois Urbana-Champaign</a></li>
  <li><a href="https://en.wikipedia.org/wiki/Rocchio_algorithm">Rocchio algorithm - Wikipedia</a></li>
  <li><a href="https://www.elastic.co/docs/reference/aggregations/search-aggregations-bucket-significantterms-aggregation">Significant terms aggregation - Elasticsearch</a></li>
  <li><a href="https://www.elastic.co/docs/reference/aggregations/search-aggregations-bucket-significanttext-aggregation">Significant text aggregation - Elasticsearch</a></li>
</ul>]]></content><author><name>Mienxiu</name></author><category term="algorithm" /><category term="elasticsearch" /><category term="ir" /><category term="python" /><summary type="html"><![CDATA[The problem of a pure keyword matching approach is that the systems can’t handle synonymy issue. For example, you might search for “python” meaning the programming language, not the snake. And a query for “sea” may fail to match documents that use “ocean” instead.]]></summary></entry><entry><title type="html">Langauge Models for Information Retrieval</title><link href="https://mienxiu.com/language-models-for-information-retrieval/" rel="alternate" type="text/html" title="Langauge Models for Information Retrieval" /><published>2026-05-23T00:00:00+00:00</published><updated>2026-05-23T00:00:00+00:00</updated><id>https://mienxiu.com/language-models-for-information-retrieval</id><content type="html" xml:base="https://mienxiu.com/language-models-for-information-retrieval/"><![CDATA[<p>In information retrieval (henceforth IR), the language modeling approach is a form of probabilistic retrieval.
Unlike traditional probabilistic methods, which explicitly estimate the probability that a document is relevant to a query, the language modeling approach takes a different perspective.
Instead of modeling relevance directly, it builds a probabilistic language model for each document and ranks documents based on the probability of the model generating the query.</p>

<p>The intuition is as follows.
When someone seeks information, they have an implicit picture of the kinds of documents that they are looking for and the words those documents are likely to contain.
Based on these imaginary documents and their representative terms, they choose search terms and send a query to a search engine, expecting the system to return those documents.
The language modeling approach inverts this process.
Given a document, it asks: “<strong>how likely is it that this document’s model would generate the given query?</strong>”</p>

<p>First, let’s review what the probabilistic modeling approach is in IR.</p>

<script>
  MathJax = {
    output: {
      displayOverflow: 'scroll'
    },
    tex: {
      inlineMath: {'[+]': [['$', '$']]}
    }
  };
</script>

<script id="MathJax-script" async="" src="https://cdn.jsdelivr.net/npm/mathjax@4/tex-mml-chtml.js"></script>

<h2 id="probabilistic-modeling-approach">Probabilistic Modeling Approach</h2>

<p>Probabilistic approaches to IR model ranking as an estimation problem.
That is, given a query $q$ and a document $d$, we aim to estimate the probability that the document is relevant:</p>

\[f(d,q) = P(R=1 \mid d,q), R \in {\{0,1\}}\]

<p>Where $R$ is a binary random variable indicating relevance.</p>

<p class="notice--info"><a href="https://mienxiu.com/understanding-bm25/">Okapi BM25</a> is a non-binary model of the probabilistic model family.</p>

<p>The key question is: how do we realize this idea?</p>

<p>One intuitive approach is to use click-through logs as implicit relevance feedback.
Suppose we have implicit relevance judgments mined from user clicks:</p>

<table>
  <thead>
    <tr>
      <th>$q$</th>
      <th>$d$</th>
      <th>$R$</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>$q_1$</td>
      <td>$d_1$</td>
      <td>1</td>
    </tr>
    <tr>
      <td>$q_1$</td>
      <td>$d_2$</td>
      <td>0</td>
    </tr>
    <tr>
      <td>$q_1$</td>
      <td>$d_3$</td>
      <td>0</td>
    </tr>
    <tr>
      <td>$q_1$</td>
      <td>$d_1$</td>
      <td>1</td>
    </tr>
    <tr>
      <td>$q_1$</td>
      <td>$d_2$</td>
      <td>0</td>
    </tr>
    <tr>
      <td>$q_1$</td>
      <td>$d_3$</td>
      <td>1</td>
    </tr>
    <tr>
      <td>$q_2$</td>
      <td>$d_2$</td>
      <td>1</td>
    </tr>
    <tr>
      <td>$q_2$</td>
      <td>$d_3$</td>
      <td>0</td>
    </tr>
  </tbody>
</table>

<p>For query $q_1$, we can estimate relevance for documents $d1$, $d2$, $d3$ by computing the fraction of exposures that resulted in a click: $p(R \mid d,q) = \frac{count(q,d,R=1)}{count(q,d)}$.</p>

<p>This yields:</p>
<ul>
  <li>$P(R=1 \mid d_1,q_1) = 2/2 = 1$</li>
  <li>$P(R=1 \mid d_2,q_1) = 0/2 = 0$</li>
  <li>$P(R=1 \mid d_3,q_1) = 1/2 = 0.5$</li>
</ul>

<p>So the ranking for $q_1$ would be $d_1 &gt; d_3 &gt; d_2$.</p>

<p>A clear limitation of this approach is that we have no data from which to estimate relevance if a document has never been shown before or if a query has never appeared.
In practice, most queries are rare and most documents get little or no interaction.</p>

<p>The language modeling approach provides a different realization of probabilistic IR.
Rather than estimating $P(R=1 \mid d,q)$, it reframes the problem as estimating how likely the document would generate the query:</p>

\[P(q \mid d, R=1)\]

<p>In practice, it builds a probabilistic language model $M_d$ from each document $d$, and ranks documents based on the probability of the model generating the query:</p>

\[P(q \mid M_d)\]

<p>This approach directly tackles the question in the intro: “how likely is it that this document’s model would generate the given query?”.</p>

<p>Let’s briefly go over what language models are and how they are constructed.</p>

<h2 id="language-models">Language Models</h2>

<p>A language model is a function that puts a probability measure over strings drawn from some vocabulary [An Introduction to Information Retrieval].
In the IR setting, these strings can be thought of as sequences of words or terms.</p>

<p>While often used interchangeably, it is useful to clarify three commonly used concepts:</p>
<ul>
  <li>words: natural language units as written or spoken.</li>
  <li>terms: normalized tokens that an IR system indexes and matches.</li>
  <li>tokens: implementation-level units produced by a tokenizer, purely technical.</li>
</ul>

<p>One of the simplest language models is a finite automaton, or simply a state machine, consisting of just a single node that generates terms based on a single probability distribution.</p>

<p><img src="/assets/posts/42/single_automaton.png" alt="Single automaton" /></p>

<p>Because all terms are drawn independently from this distribution, the probabilities must sum to 1:</p>

\[\sum_{t \in V}P(t)=1\]

<p>Where:</p>
<ul>
  <li>$t$: term</li>
  <li>$V$: vocabulary</li>
</ul>

<p>Since this model has no memory, each term is generated independently of the previous one.
To compute the probability of a sequence, we simply multiply the term probabilities.</p>

<p>For example, here’s a partial specification of the term probabilities:</p>

<table>
  <thead>
    <tr>
      <th>$t$</th>
      <th>$P(t)$</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>the</td>
      <td>0.2</td>
    </tr>
    <tr>
      <td>a</td>
      <td>0.1</td>
    </tr>
    <tr>
      <td>chocolate</td>
      <td>0.03</td>
    </tr>
    <tr>
      <td>milkshake</td>
      <td>0.02</td>
    </tr>
    <tr>
      <td>…</td>
      <td>…</td>
    </tr>
  </tbody>
</table>

<p>Using this table, the probability of generating a string “chocolate milkshake” is:</p>

\[P(\text{chocolate milkshake}) = P(\text{chocolate}) \times P(\text{milkshake}) = 0.03 \times 0.02 = 0.0006\]

<p>Such a model is often called “generative” because it can stochastically produce sequences of terms.
A formal generative model would include a stop probability to decide whether to stop or to loop around and then produce another word.
For example, if $P(\text{STOP}) = 0.2$, then $P(\text{chocolate milkshake}) = (0.03 \times 0.02) \times (0.8 \times 0.2) = 0.000096$.
But in IR this factor cancels out when comparing models and does not affect ranking.
Therefore, we omit to include stop probability in the IR context.</p>

<p>One use case of language models is association analysis, where we identify terms that are semantically related to a given seed word.
Suppose we want to find words related to “vitamin”.
We can build a topic language model $P_{topic}$ from documents containing that term:</p>

<table>
  <thead>
    <tr>
      <th>$t$</th>
      <th>$P_{topic}(t)$</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>the</td>
      <td>0.04</td>
    </tr>
    <tr>
      <td>is</td>
      <td>0.03</td>
    </tr>
    <tr>
      <td>a</td>
      <td>0.01</td>
    </tr>
    <tr>
      <td>vitamin</td>
      <td>0.005</td>
    </tr>
    <tr>
      <td>nutrition</td>
      <td>0.004</td>
    </tr>
  </tbody>
</table>

<p>Here, common terms like “the”, “is”, and “a” dominate the distribution, but they are not informative.
To discount such common terms, we can compare the topic model against a background model constructed from a broad corpus:</p>

<table>
  <thead>
    <tr>
      <th>$t$</th>
      <th>$P_{background}(t)$</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>the</td>
      <td>0.04</td>
    </tr>
    <tr>
      <td>is</td>
      <td>0.03</td>
    </tr>
    <tr>
      <td>a</td>
      <td>0.02</td>
    </tr>
    <tr>
      <td>vitamin</td>
      <td>0.00002</td>
    </tr>
    <tr>
      <td>nutrition</td>
      <td>0.00001</td>
    </tr>
  </tbody>
</table>

<p>By taking the ratio $\frac{P_{topic}(t)}{P_{background}(t)}$, we highlight terms that are unusually frequent in the topic:</p>

<table>
  <thead>
    <tr>
      <th>$t$</th>
      <th>$P_{topic}(t)$</th>
      <th>$P_{background}(t)$</th>
      <th>$\frac{P_{topic}(t)}{P_{background}(t)}$</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>nutrition</td>
      <td>0.004</td>
      <td>0.00001</td>
      <td>40</td>
    </tr>
    <tr>
      <td>vitamin</td>
      <td>0.005</td>
      <td>0.00002</td>
      <td>25</td>
    </tr>
    <tr>
      <td>the</td>
      <td>0.04</td>
      <td>0.04</td>
      <td>1</td>
    </tr>
    <tr>
      <td>is</td>
      <td>0.03</td>
      <td>0.03</td>
      <td>1</td>
    </tr>
    <tr>
      <td>a</td>
      <td>0.01</td>
      <td>0.02</td>
      <td>0.5</td>
    </tr>
  </tbody>
</table>

<p>From this result, we can see which terms are common to both models and which are disproportionately frequent in the topic model.
In particular, “nutrition” stands out as much more characteristic of the topic than generic words such as “the” or “is”, making it more likely to be semantically related to “vitamin”.</p>

<h3 id="unigram-language-model">Unigram Language Model</h3>

<p>The language models introduced so far belong to the simplest class called unigram language models.
In a unigram model, the order of words does not matter.
Because it treats a document as merely a collection of terms, it is often referred to as a “bag of words” approach.
The model ignores all contextual dependencies and assumes that every term occurs independently:</p>

\[P_{\text{uni}}(t_1 t_2 t_3)=P(t_1)P(t_2)P(t_3)\]

<p>There are more complex language models.
For example, a bigram language model conditions each term on the term immediately preceding it:</p>

\[P_{\text{bi}}(t_1 t_2 t_3)=P(t_1)P(t_2 \mid t_1)P(t_3 \mid t_2)\]

<p>More complex language models include trigram models and even grammar-based models that capture deeper structure in language.
These richer models can be helpful in tasks such as:</p>
<ul>
  <li>speech recognition</li>
  <li>spelling correction</li>
  <li>machine translation</li>
</ul>

<p>All of these tasks depend on how likely a term is given the context that surrounds it.</p>

<p>In contrast, most language-modeling work in IR relies on unigram models as IR rarely depends on sentence structure.
For determining what a document is about, unigram models are often sufficient.
Unigram models are also computationally more efficient to estimate and apply than higher-order models.
This efficiency makes them a good fit for large-scale IR systems, where speed and scalability are critical.</p>

<h2 id="maximum-likelihood-estimation">Maximum Likelihood Estimation</h2>

<p>To estimate $P(q \mid M_d)$, we typically use maximum likelihood estimation (MLE).
Under MLE, the probability of a term in a document is simply its frequency divided by the total number of tokens in that document:</p>

\[{P}_{MLE}(t \mid M_d)=\frac{tf_{t,d}}{L_d}\]

<p>Where:</p>
<ul>
  <li>$tf_{t,d}$ is the (raw) term frequency of term $t$ in document $d$</li>
  <li>$L_d$ is the number of tokens in document $d$</li>
</ul>

<p>With MLE and the unigram assumption, the probability of producing the query $q$ from the document language model $M_d$ becomes:</p>

\[\hat{P}(q \mid M_d) = \prod_{t \in q}\hat{P}_{MLE}(t \mid M_d) = \prod_{t \in q}\frac{tf_{t,d}}{L_d}\]

<p>Where $\hat{P}$ is the estimated probability.</p>

<p>Suppose a document $d$ contains 100 tokens ($L_d=100$), and we observe the following term frequencies:</p>

<table>
  <thead>
    <tr>
      <th>$t$</th>
      <th>$tf_{t,d}$</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>chocolate</td>
      <td>10</td>
    </tr>
    <tr>
      <td>milkshake</td>
      <td>8</td>
    </tr>
    <tr>
      <td>…</td>
      <td>…</td>
    </tr>
  </tbody>
</table>

<p>The corresponding MLE probabilities are:</p>

<table>
  <thead>
    <tr>
      <th>$t$</th>
      <th>$\frac{tf_{t,d}}{L_d}$</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>chocolate</td>
      <td>10/100</td>
    </tr>
    <tr>
      <td>milkshake</td>
      <td>8/100</td>
    </tr>
    <tr>
      <td>…</td>
      <td>…</td>
    </tr>
  </tbody>
</table>

<p>So, the probability of generating the query “chocolate milkshake” from $M_d$ is:</p>

\[\hat{P}(\text{chocolate milkshake} \mid M_d) = \hat{P}_{MLE}(\text{chocolate} \mid M_d) \times \hat{P}_{MLE}(\text{milkshake} \mid M_d) = \frac{10}{100} \times \frac{8}{100} = 0.008\]

<p>Now that we know what language models are and how to estimate $P(q \mid M_d)$, we can move on to applying these ideas to the ranking problem.
For simplicity, we will omit the estimator symbol $\hat{}$ on top of $P$ in the rest of the discussion.</p>

<h2 id="query-likelihood-model">Query Likelihood Model</h2>

<p>The query likelihood model (often abbreviated as QLM) is the original and basic method for using language models in IR.
The core assumption is that a user has some ideal or “prototype” document in mind and generates a query using terms that appear in that document.
Whether users literally behave this way is debatable, but the intuition holds.
That is, people tend to choose query terms they believe are representative and discriminative for the documents they want.</p>

<p>In QLM, we construct a language model $M_d$ for each document $d$ in the collection.
Our goal is to rank documents by $P(d \mid q)$.
This probability represents how likely it is that document $d$ is relevant to query $q$.
Using Bayes’ rule:</p>

\[P(d \mid q) = \frac{P(q \mid d)P(d)}{P(q)}\]

<p>Since $P(q)$ does not vary across documents, it can be ignored for ranking.
The prior $P(d)$ is also commonly assumed to be uniform, so it can likewise be dropped.
This leaves:</p>

\[P(d \mid q) \propto P(q \mid d)\]

<p>In other words, we rank documents by the likelihood that the document’s language model would generate the query.
Retrieval becomes a query-generation problem: the higher the likelihood, the more relevant the document.</p>

<p>The most common way to do this is using the unigram language model with MLE:</p>

\[P(q \mid M_d) = \prod_{t \in V}P_{ML}(t \mid M_d)\]

<p>This assumes that each term in the query is generated independently, so we multiply their probabilities.
Different ways of estimating $P(t \mid M_d)$ lead to different retrieval functions, and MLE is the simplest one.</p>

<p>In practice we can think of QLM as a three step process:</p>
<ol>
  <li>Infer a LM for each document.</li>
  <li>Estimate $P(q \mid M_{d_{i}})$, the probability of generating the query according to each of these document models.</li>
  <li>Rank the documents according to these probabilities.</li>
</ol>

<p>Here’s an example.
Suppose the query is “chocolate milkshake”, and we have two documents, $d1$ and $d2$.
By inferencing the document LMs for both documents, we get:</p>

<table>
  <thead>
    <tr>
      <th>$t$</th>
      <th>the</th>
      <th>a</th>
      <th>chocolate</th>
      <th>milkshake</th>
      <th>…</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>$M_{d_1}$</td>
      <td>0.2</td>
      <td>0.1</td>
      <td>0.03</td>
      <td>0.02</td>
      <td>…</td>
    </tr>
    <tr>
      <td>$M_{d_2}$</td>
      <td>0.2</td>
      <td>0.1</td>
      <td>0.02</td>
      <td>0.01</td>
      <td>…</td>
    </tr>
  </tbody>
</table>

<p>Using MLE, we can then compute the term probabilities in each document model:</p>

<ul>
  <li>$P(\text{chocolcate milkshake}\mid M_{d_1})=0.03 \times 0.02=0.0006$</li>
  <li>$P(\text{chocolcate milkshake}\mid M_{d_2})=0.02 \times 0.01=0.0002$</li>
</ul>

<p>Since $0.0006 \gt 0.0002$, we rank $d_1 \gt d_2$.</p>

<p>In practice, multiplying many small probabilities leads to floating-point underflow, where the outcome is so small that it can no longer be represented by the computer.
To avoid this, sums of log probabilities is often used:</p>

\[f(q,d) = \log{P(q \mid M_d)} = \sum_{i=1}^{n}\log{P(t_i \mid M_d)} = \sum_{t \in V}tf_{t,q}\log{P(t \mid M_d)}\]

<p>Where $tf_{t,q}$ is the (raw) term frequency of term $t$ in query $q$.</p>

<p>This transformation preserves ranking, since the logarithm is a monotonic function.</p>

<p>A major limitation of the basic query likelihood approach arises when a query term does not appear in a document.
Under MLE, it gives $P(t \mid M_d)=0$, making the entire query likelihood zero.
Even in log space, MLE assigns $\log{P(t \mid M_d)}=-\infty$, making the entire query likelihood $-\infty$.
The result is an overly strict conjunctive (AND-style) retrieval where a document contributes only if it contains every query term.
In addition, even observed terms are poorly estimated under MLE.
Rare terms tend to be overestimated because their single occurrence may be due to chance.
The solution is smoothing.</p>

<h2 id="smoothing">Smoothing</h2>

<p>Smoothing is a way to discount non-zero probabilities and to give some probability mass to unseen terms.
The general approach is to assign a non-zero probability to unseen terms based on the whole collection.
That is:</p>

\[P(t \mid d) =
\begin{cases}
P(t \mid M_d), &amp; \text{if } tf_{t,d} &gt; 0,\\
\alpha_{d}\,P(t \mid M_c), &amp; \text{otherwise}
\end{cases}\]

<p>Where:</p>
<ul>
  <li>$\alpha_d$ is a smoothing factor of document $d$ for unseen terms.</li>
  <li>$M_c$ is a language model built from the entire collection.</li>
</ul>

<p>For a term that doesn’t appear in document $d$, we fall back on the collection probability:</p>

\[P(t \mid M_c)=\frac{cf_t}{T}\]

<p>Where:</p>
<ul>
  <li>$cf_t$ is the raw count of the term in the collection</li>
  <li>$T$ is the raw size (number of terms) of the entire collection</li>
</ul>

<p>With this idea, the ranking function becomes:</p>

\[\begin{aligned}
\log P(q \mid d) &amp;= \sum_{t \in V}tf_{t,q}\log{P(t \mid M_d)} \\
&amp;= \sum_{t \in V, tf_{t,d}&gt;0}tf_{t,q}\log{P(t \mid M_d)} + \sum_{t \in V, tf_{t,d}=0}tf_{t,q}\log\alpha_{d}{P(t \mid M_c)} \\
&amp;= \sum_{t \in V, tf_{t,d}&gt;0}tf_{t,q}\log{P(t \mid M_d)} + (\sum_{t \in V}tf_{t,q}\log\alpha_{d}{P(t \mid M_c)} - \sum_{t \in V, tf_{t,d}&gt;0}tf_{t,q}\log\alpha_{d}{P(t \mid M_c)}) \\
&amp;= \sum_{t \in V, tf_{t,d}&gt;0}tf_{t,q}\log{\frac{P(t \mid M_d)}{\alpha_{d}{P(t \mid M_c)}}} + \vert q \vert \log\alpha_{d} + \sum_{t \in V}tf_{t,q}\log{P(t \mid M_c)}
\end{aligned}\]

<p>We can make this equation simpler by removing the $\sum_{t \in V}tf_{t,q}\log{P(t \mid M_c)}$ part as it’s going to be the same for every document and doesn’t affect ranking.
So:</p>

\[\log P(q \mid d) = \sum_{t \in V, tf_{t,d}&gt;0}tf_{t,q}\log{\frac{P(t \mid M_d)}{\alpha_{d}{P(t \mid M_c)}}} + \vert q \vert \log\alpha_{d}\]

<p>This transformation automatically provides two benefits:</p>
<ol>
  <li>More efficient computation as scoring is only based on matched query terms</li>
  <li>The effects of TF-IDF weighting and document length normalization:
    <ul>
      <li>$P(t \mid M_d)$ behaves like TF.</li>
      <li>$\frac{1}{P(t \mid M_c)}$ behaves like IDF.</li>
      <li>$\vert q \vert \log\alpha_{d}$ acts like document length normalization, because longer documents usually need a smaller $\alpha_{d}$.</li>
    </ul>
  </li>
</ol>

<p>A takeaway here is that the role of smoothing is not only to avoid zero probabilities.
The smoothing of terms actually implements major parts of the term weighting component.</p>

<p>Next up are the specific smoothing methods that have been shown to perform well in IR experiments:</p>
<ul>
  <li>Jelinek-Mercer smoothing</li>
  <li>Dirichilet Prior smoothing</li>
</ul>

<h3 id="jelinek-mercer-smoothing">Jelinek-Mercer Smoothing</h3>

<p>Jelinek-Mercer smoothing, also referred to as as linear interpolation smoothing, is the simpleest smoothing method.
The idea is to mix two sources of information.
One comes from the document itself and the other comes from the whole collection:</p>

\[P(t \mid d)=(1-\lambda)P(t \mid M_d) + \lambda P(t \mid M_c)\]

<p>Where $\lambda$ is a smoothing factor that controls the extent of smoothing ($0 &lt; \lambda &lt; 1$).
A large value of $\lambda$ means more smoothing.</p>

<p>A useful property is that $\lambda$ doesn’t have to be a fixed constant and you can make it depend on the query length.
For example:</p>
<ul>
  <li>For short queries: use a small $\lambda$</li>
  <li>For long queries: use a larger $\lambda$</li>
</ul>

<p>If we substitute the JM formula into our scoring function, we get:</p>

\[\begin{aligned}
f_{JM}(q \mid d) &amp;= \sum_{t \in V, tf_{t,d}&gt;0}tf_{t,q}\log{\frac{(1-\lambda)P(t \mid M_d) + \lambda P(t \mid M_c)}{\lambda{P(t \mid M_c)}}} + \vert q \vert \log\lambda \\
&amp;= \sum_{t \in V, tf_{t,d}&gt;0}tf_{t,q}\log{\frac{(1-\lambda)P(t \mid M_d) + \lambda P(t \mid M_c)}{\lambda{P(t \mid M_c)}}} + \vert q \vert \log\lambda\\
&amp;= \sum_{t \in V, tf_{t,d}&gt;0}tf_{t,q}\log{(1+\frac{1-\lambda}{\lambda}\frac{tf_{t,d}}{L_d P(t \mid M_c)})} + \vert q \vert \log\lambda
\end{aligned}\]

<p>Since, $\vert q \vert \log\lambda$ is the same for every document, we can drop it for ranking purposes.
So:</p>

\[f_{JM}(q \mid d) = \sum_{t \in V, tf_{t,d}&gt;0}tf_{t,q}\log{(1+\frac{1-\lambda}{\lambda}\frac{tf_{t,d}}{L_d P(t \mid M_c)})}\]

<p>Below are the interpretations of some parts in this equation:</p>
<ul>
  <li>$\lambda$ plays the role of the smoothing factor (similar to $\alpha_d$).</li>
  <li>$tf_{t,q}$ acts like TF weighting.</li>
  <li>$\frac{1}{P(t \mid M_c)}$ acts like IDF weighting.</li>
  <li>$\frac{1}{L_d}$ provides document length normalization.</li>
</ul>

<p>So even though this looks like a language-modeling formula, it ends up recreating a lot of the same behavior as classic TF-IDF ranking.</p>

<p>Here’s an example.
Suppose our collection has two documents:</p>
<ul>
  <li>$d_1$: “Here is a recipe for a classic, creamy chocolate milkshake”</li>
  <li>$d_2$: “Dark chocolate is a little bitter but very delicious”</li>
</ul>

<p>We’ll use basic unigram MLEs for both the documents and the whole collection, and we’ll set $\lambda = 0.5$.
Now take the query: “chocolate milkshake”</p>

<p>Then:</p>

\[f(q \mid d_1) = \log\left(1 + \frac{1 - 0.5}{0.5} \cdot \frac{1}{10 \cdot \frac{2}{19}}\right) + \log\left(1 + \frac{1 - 0.5}{0.5} \cdot \frac{1}{10 \cdot \frac{1}{19}}\right) = 1.73\]

\[f(q \mid d_2) = \log\left(1 + \frac{1 - 0.5}{0.5} \cdot \frac{1}{9 \cdot \frac{2}{19}}\right) + \log\left(1 + \frac{1 - 0.5}{0.5} \cdot \frac{0}{9 \cdot \frac{1}{19}}\right) = 0.72\]

<p>Only $d_1$ contains “milkshake”, so it gets a strong boost in score.
The ranking ends up as $d_1 \gt d_2$.
Notice that even though $d_2$ does not contain “milkshake” at all it still receives a non zero score because smoothing gives it some probability mass.</p>

<h3 id="dirichilet-prior-smoothing">Dirichilet Prior Smoothing</h3>

<p>Dirichilet Prior smoothing, also called as Bayesian smoothing, takes a different angle from Jelinek–Mercer.
Instead of mixing two models with a fixed weight, it treats the collection language model as a prior and then updates it using evidence from the document.</p>

<p>The formula is:</p>

\[P(t \mid d)=\frac{L_d}{L_d+\alpha}P(t \mid M_d) + \frac{\alpha}{L_d+\alpha} P(t \mid M_c)= \frac{tf_{t,d} + \alpha P(t \mid M_c)}{L_d + \alpha}\]

<p>Where</p>
<ul>
  <li>$L_d$ is the number of terms in document $d$</li>
  <li>$\alpha$ is a smoothing factor ($\alpha \in [0, +\infty)$). A large value of $\alpha$ means more smoothing.</li>
</ul>

<p>This equation has an intuitive interpretation:</p>
<ul>
  <li>If a document is short, $L_d$ is small, so the model leans more toward the collection (more smoothing).</li>
  <li>If a document is long, $L_d$ dominates, so the model trusts the document more (less smoothing).</li>
</ul>

<p>Using this expression, the scoring function becomes:</p>

\[\begin{aligned}
f_{DIR}(q \mid d) &amp;= \sum_{t \in V, tf_{t,d}&gt;0}tf_{t,q}\log{\frac{\frac{tf_{w,d}+\alpha P(t \mid M_c)}{L_d+\alpha}}{\frac{\alpha P(t \mid M_c)}{L_d+\alpha}}} + \vert q \vert \log\frac{\alpha}{L_d+\alpha} \\
&amp;= \sum_{t \in V, tf_{t,d}&gt;0}tf_{t,q}\log{(1+\frac{tf_{t,d}}{\alpha P(t \mid M_c)})}+\vert q \vert \log\frac{\alpha}{L_d+\alpha}
\end{aligned}\]

<p>Just like JM smoothing, this method naturally gives tf-idf weighting and document length normalization.
Specifically:</p>
<ul>
  <li>$tf_{t,q}$ -&gt; TF weighting</li>
  <li>$\frac{1}{P(t \mid M_c)}$ -&gt; IDF weighting</li>
  <li>$\vert q \vert \log\frac{\alpha}{L_d+\alpha}$ -&gt; document length normalization</li>
</ul>

<p>A key difference from JM smoothing is that the effective weight ($\alpha_d$) becomes $\frac{\alpha}{L_d+\alpha}$ instead of the fixed linear coefficient $\lambda$.
This means the model automatically smooths short documents more and long documents less, which makes the interpolation adaptive rather than fixed.</p>

<h2 id="other-language-modeling-approaches">Other Language Modeling Approaches</h2>

<p>Up to this point, we’ve focused on a single perspective that is query likelihood.
However, there are other language modeling approaches worth mentioning.</p>

<p><img src="/assets/posts/42/LM_approaches.png" alt="Three ways of developing the language modeling approach" /></p>

<h3 id="document-likelihood">Document Likelihood</h3>

<p>Document likelihood looks at how likely a query language model $M_q$ is to generate a document $d$:</p>

\[P(d \mid M_q)\]

<p>In theory, it’s a symmetric idea of query likelihood.
In practice, though, it’s not well suited for direct document ranking.
Queries are typically short, providing too little data to estimate a reliable query model.
As a result, the model becomes unstable and performs poorly when used on its own.</p>

<p>The upside is that this approach naturally supports relevance feedback.
We can expand the query model with terms from documents judged relevant, making it easier to refine the query representation.</p>

<h3 id="model-comparison">Model Comparison</h3>

<p>Another approach builds a language model for both the document and the query, then measures how different the two models are.
One common way to quantify this difference (or “risk”) of returning document $d$ for query $q$ is the Kullback-Leibler (KL) divergence:</p>

\[R(d;q)=KL(M_d \mid\mid M_q)=\sum_{t \in V}(P \mid M_q)\log{\frac{P(t \mid M_q)}{P(t \mid M_d)}}\]

<p>The greater the divergence, the less the document model resembles the query model, and the poorer the match.</p>

<p>A limitation of KL-based scoring is that the scores aren’t comparable across different queries.
This is fine for ad hoc retrieval, where we only care about ranking documents within a single query.
But in applications like topic tracking, where we need one global threshold to decide whether a document belongs to a topic across many queries, the lack of score comparability becomes a significant drawback.</p>

<h2 id="lm-similarities-supported-by-elasticsearch">LM Similarities Supported by Elasticsearch</h2>

<p>Elasticsearch, a widely used search engine built on top of Lucene, provides built-in language-model-based similarity modules that support both Jelinek Mercer smoothing and Dirichlet Prior smoothing.</p>

<p>Below is a minimal example (tested on Elasticsearch 8.19) showing how to set up an index that applies different scoring strategies to identical text.</p>

<p>The index declares three text fields, each tied to a different similarity:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">PUT</span><span class="w"> </span><span class="err">/my-index</span><span class="w">
</span><span class="p">{</span><span class="w">
  </span><span class="nl">"settings"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"index"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"similarity"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"jm_similarity"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
          </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"LMJelinekMercer"</span><span class="p">,</span><span class="w">
          </span><span class="nl">"lambda"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0.1"</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="nl">"dirichlet_similarity"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
          </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"LMDirichlet"</span><span class="p">,</span><span class="w">
          </span><span class="nl">"mu"</span><span class="p">:</span><span class="w"> </span><span class="mi">2000</span><span class="w">
        </span><span class="p">}</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"mappings"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"bm25"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"text"</span><span class="w">
      </span><span class="p">},</span><span class="w">
      </span><span class="nl">"jm"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"text"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"similarity"</span><span class="p">:</span><span class="w"> </span><span class="s2">"jm_similarity"</span><span class="w">
      </span><span class="p">},</span><span class="w">
      </span><span class="nl">"dirichlet"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"text"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"similarity"</span><span class="p">:</span><span class="w"> </span><span class="s2">"dirichlet_similarity"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">bm25</code> uses the default BM25 similarity.</li>
  <li><code class="language-plaintext highlighter-rouge">jm</code> uses Jelinek–Mercer smoothing.</li>
  <li><code class="language-plaintext highlighter-rouge">dirichlet</code> uses Dirichlet prior smoothing.</li>
</ul>

<p>By indexing the same content into three fields, you can directly compare how each method ranks the same documents for the same query.</p>

<p>Add sample documents:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">PUT</span><span class="w"> </span><span class="err">/my-index/_doc/</span><span class="mi">1</span><span class="w">
</span><span class="p">{</span><span class="w">
  </span><span class="nl">"bm25"</span><span class="p">:</span><span class="w"> </span><span class="s2">"foo bar baz"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"jm"</span><span class="p">:</span><span class="w"> </span><span class="s2">"foo bar baz"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"dirichlet"</span><span class="p">:</span><span class="w"> </span><span class="s2">"foo bar baz"</span><span class="w">
</span><span class="p">}</span><span class="w">

</span><span class="err">PUT</span><span class="w"> </span><span class="err">/my-index/_doc/</span><span class="mi">2</span><span class="w">
</span><span class="p">{</span><span class="w">
  </span><span class="nl">"bm25"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Lorem ipsum dolor sit amet"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"jm"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Lorem ipsum dolor sit amet"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"dirichlet"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Lorem ipsum dolor sit amet"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>This query searches the <code class="language-plaintext highlighter-rouge">dirichlet</code> field for the term “foo”:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">GET</span><span class="w"> </span><span class="err">my-index/_search</span><span class="w">
</span><span class="p">{</span><span class="w">
    </span><span class="nl">"query"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"match"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
          </span><span class="nl">"dirichlet"</span><span class="p">:</span><span class="w"> </span><span class="s2">"foo"</span><span class="w">
        </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Document 1 matches, but the key point is the scoring behavior.
Even though the underlying text is identical across fields, each similarity computes a different score:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>BM25</th>
      <th>JM</th>
      <th>Dirichlet</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>score</td>
      <td>0.7721133</td>
      <td>2.6741486</td>
      <td>0.00074859645</td>
    </tr>
  </tbody>
</table>

<h2 id="conclusion">Conclusion</h2>

<p>According to Ponte and Croft (1998), the language modeling approach outperformed their baseline tf-idf based term weighting approach.
They pointed out that the language modeling approach gives a new way to score how well a query matches a document.
The idea is that by using a probabilistic model of language, we can assign better weights to terms and, as a result, improve retrieval performance.
The major issue is estimation of the document model, such as choices of how to smooth it effectively [An Introduction to Information Retrieval].</p>

<p>Despite many advances in probabilistic IR, BM25 still remains competitive and often hard to beat.
In practice, BM25 and query likelihood models tend to perform similarly.
However, BM25 has a clear practical advantage because it lets us directly and intuitively control how term frequency and document length affect the final score.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://nlp.stanford.edu/IR-book/information-retrieval-book.html">An Introduction to Information Retrieval by Christopher D. Manning, Prabhakar Raghavan, and Hinrich Schütze</a></li>
  <li><a href="https://www.coursera.org/learn/text-retrieval">Text Retrieval and Search Engines by University of Illinois Urbana-Champaign</a></li>
  <li><a href="https://www.elastic.co/blog/language-models-in-elasticsearch">Language Models in Elasticsearch - Elastic blog</a></li>
  <li><a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.19/index-modules-similarity.html">Similarity module - Elasticsearch</a></li>
</ul>]]></content><author><name>Mienxiu</name></author><category term="elasticsearch" /><category term="ir" /><summary type="html"><![CDATA[In information retrieval (henceforth IR), the language modeling approach is a form of probabilistic retrieval. Unlike traditional probabilistic methods, which explicitly estimate the probability that a document is relevant to a query, the language modeling approach takes a different perspective. Instead of modeling relevance directly, it builds a probabilistic language model for each document and ranks documents based on the probability of the model generating the query.]]></summary></entry><entry><title type="html">Virtualization</title><link href="https://mienxiu.com/virtualization/" rel="alternate" type="text/html" title="Virtualization" /><published>2026-03-21T00:00:00+00:00</published><updated>2026-03-21T00:00:00+00:00</updated><id>https://mienxiu.com/virtualization</id><content type="html" xml:base="https://mienxiu.com/virtualization/"><![CDATA[<p>In the 1960s, early computers like IBM mainframes typically ran only one operating system at a time.
This design had several drawbacks:</p>
<ul>
  <li>Different workloads could not easily share the same hardware.</li>
  <li>Hardware was often idle.</li>
  <li>Testing a new OS or switching OSes required another machine or a reboot.</li>
</ul>

<p>Virtualization was created to solve these problems.
With virtualization, multiple operating systems and applications can run at the same time on the same hardware.
Each one acts like it has its own machine, without forcing everyone to use the same operating system.</p>

<h2 id="virtual-machines-and-the-hypervisor">Virtual Machines and the Hypervisor</h2>

<p><img src="/assets/posts/41/virtual_machine.png" alt="Virtual Machine" /></p>

<h3 id="virtual-machines">Virtual Machines</h3>

<p>An early definition of a virtual machine is:</p>

<blockquote>
  <p>A virtual machine is taken to be an efficient, isolated duplicate of the real machine.
–<cite><a href="https://www.cs.cornell.edu/courses/cs6411/2018sp/papers/popek-goldberg.pdf">Popek and Goldber, 1974</a></cite></p>
</blockquote>

<p>A virtual machine (VM) is a software-defined machine that shares the physical CPU, memory, and devices.
A VM is also called a guest or a domain.</p>

<p>The operating system running inside a VM is called the guest operating system (guest OS).
From the guest operating system’s point of view, it is running on a real machine.
Therefore, the guest OS does not know (and ideally does not care) that it is sharing physical resources with other VMs.</p>

<h3 id="hypervisors">Hypervisors</h3>

<p>To run multiple VMs safely, you need a control layer that manages hardware and keeps VMs apart.
This layer is called virtual machine monitor (VMM), or hypervisor.</p>

<p class="notice--info">In most virtualization literature, VMM means virtual machine monitor (the hypervisor).
But, some products also use VMM to mean virtual machine manager (a management tool).</p>

<p>A hypervisor is software that sits between the physical hardware and the virtual machines.
It is responsible for mainly three goals:</p>
<ol>
  <li>Fidelity: The VM should look like a real machine. The guest OS should not need special changes just to boot and run.</li>
  <li>Performance: The hypervisor should not slow down normal execution.</li>
  <li>Safety: The hypervisor must control shared resources.</li>
</ol>

<p>In general, the hypervisor stays out of the way during ordinary execution.
It mainly steps in when a guest tries to do something that must be controlled, such as:</p>
<ul>
  <li>Accessing privileged CPU registers</li>
  <li>Changing page tables</li>
  <li>Performing certain I/O operations</li>
</ul>

<h2 id="benefits-and-drawbacks">Benefits and Drawbacks</h2>

<p>Virtualization is widely used because it has multiple benefits:</p>
<ul>
  <li>Consolidation: Run many servers on one machine to increase utilization.</li>
  <li>Cost reduction: Fewer machines means less power, cooling, and hardware cost.</li>
  <li>Portability: A VM is easy to copy, move, or back up.</li>
  <li>Fast provisioning: You can create new VMs quickly from templates.</li>
  <li>Maintenance and migration: You can move VMs during hardware maintenance or load changes.</li>
  <li>Fault isolation: A crash in one VM does not have to crash others.</li>
  <li>Testing and debugging: OS experiments are easier when you can snapshot and roll back.</li>
  <li>Legacy support: Old OSes can run without dedicating old hardware.</li>
</ul>

<p>However, virtualization also has drawbacks:</p>
<ul>
  <li>Performance overhead compared to running directly on hardware.</li>
  <li>Increased system complexity and management overhead.</li>
  <li>Limited direct resource sharing due to isolation.</li>
  <li>Host failure can impact many VMs unless redundancy is used.</li>
  <li>Licensing and virtualization platform costs may be significant.</li>
</ul>

<h2 id="types-of-vms">Types of VMs</h2>

<p>VMM implementations differ widely.
Common approaches are:</p>
<ul>
  <li>Type 1</li>
  <li>Type 2</li>
  <li>Paravirtualization</li>
  <li>Containerization</li>
</ul>

<p class="notice--info">In some systems like IBM LPARs and Oracle LDOMs, virtualization support is built directly into the hardware or firmware, rather than implemented as a traditional software hypervisor.
These systems are often called Type 0 hypervisors.</p>

<h3 id="type-1-bare-metal-hypervisor">Type 1: Bare-metal hypervisor</h3>

<p>In type 1, the hypervisor runs directly on the hardware, without a host operating system underneath.
It has full control over the machine and usually provides better performance and stronger isolation.
The VMs run on top of hypervisor.</p>

<p><img src="/assets/posts/41/type1_virtualization.png" alt="Type 1 virtualization" /></p>

<p>Type 1 is common in data centers and cloud platforms.</p>

<p>Examples of type 1 virtualization are:</p>
<ul>
  <li>VMware ESXi</li>
  <li>XenServer</li>
  <li>Linux KVM</li>
  <li>Microsoft Hyper-V</li>
</ul>

<h3 id="type-2-hosted-virtualization">Type 2: Hosted virtualization</h3>

<p>In type 2, a normal host OS runs on the hardware first.
The virtualization software runs on top of that OS.
The host OS then supplies device drivers and many I/O services.
And the hypervisor is hosted by the OS.</p>

<p><img src="/assets/posts/41/type2_virtualization.png" alt="Type 2 virtualization" /></p>

<p>Type 2 is common on personal computers and for development or testing.</p>

<p>Examples of type 2 virtualization are:</p>
<ul>
  <li>VMware Fusion and Workstation</li>
  <li>Oracle VirtualBox</li>
  <li>Parallels Desktop</li>
</ul>

<h3 id="paravirtualization">Paravirtualization</h3>

<p>Paravirtualization does not try to fully imitate real hardware.
Instead, the guest OS is modified to work in cooperation with the VMM to optimize performance.</p>

<p>In a paravirtualized system, the guest kernel replaces some privileged operations with hypercalls.
A hypercall is like a system call, but instead of entering the OS kernel, it enters the hypervisor.
Examples of privileged operations that are commonly done via hypercalls include:</p>
<ul>
  <li>Updating page tables</li>
  <li>Configuring interrupts</li>
  <li>Performing device I/O operations</li>
</ul>

<p>Paravirtualization can be faster because hypercalls avoid replying on traps or instruction rewriting.
The downside is that the guest OS must be modified and maintained.
It can also be less portable, because it depends on a particular hypervisor interface.</p>

<p>XenServer popularized this approach.</p>

<h3 id="containerization">Containerization</h3>

<p>Sometimes the goal is not to run a full guest OS, but to isolate and manage just applications.
If all applications run on the same OS, full virtualization may be unnecessary.
Instead, the system can use containerization (or application containment).</p>

<p><img src="/assets/posts/41/containerization.png" alt="Containerization" /></p>

<p>A container is an isolated environment for running applications while sharing the host OS kernel.
Unlike a virtual machine, a container does not include a guest OS and does not virtualize hardware.
Instead, it bundles only what the application needs to run, such as the application binaries, libraries, and configuration files.</p>

<p>Compared to other virtualization approaches, its main advantage is efficiency.
Containers are much lighter than VMs.
They use fewer resources and can start and stop quickly—more like processes than full virtual machines.</p>

<p>Containerization is popular in software development and deployment, particularly in cloud systems.
It is commonly managed with tools like Docker and Kubernetes.</p>

<h2 id="building-blocks">Building Blocks</h2>

<p>Whether virtualization is possible basically depends a lot on the CPU’s features.</p>

<p>To virtualize a system, the hypervisor must provide an exact duplicate of the underlying machine.
This is especially difficult on older systems that support only two CPU modes: user mode and kernel mode.</p>

<p>For example, classic x86 defines four privilege levels, called rings:</p>
<ul>
  <li>Ring 0: most privileged, used by the OS kernel</li>
  <li>(Rings 1 and 2 exist, but they are rarely used.)</li>
  <li>Ring 3: least privileged, used by user programs</li>
</ul>

<p>On CPUs with only these privilege rings, it’s hard to fit a hypervisor in the right place.
The hypervisor needs to run with the highest privileges so it can keep control.
But a normal OS is written assuming it also runs with the highest privileges (ring 0).
If you push the guest OS down to a less-privileged ring (like ring 3), some operations won’t behave the same way, and the hypervisor may not be able to catch and handle them correctly.</p>

<!-- hardware assistance -->
<p>To fix this limitation, modern x86 CPUs add hardware support for virtualization (Intel VT-x and AMD-V).
They introduce two new modes:</p>
<ul>
  <li>Root mode: used by the hypervisor</li>
  <li>Non-root mode: used by guest operating systems</li>
</ul>

<p>With this setup, the guest kernel can still run in ring 0, but it runs in non-root mode.
In other words, it looks like ring 0 to the guest, but the CPU still keeps it on a leash.</p>

<p>When the guest executes certain sensitive instructions, the CPU automatically traps into the hypervisor.
Control switches from non-root to root mode, the hypervisor handles the request, and then the CPU returns to the guest.</p>

<p>On top of this hardware foundation, VMMs use techniques such as trap-and-emulate and binary translation to handle privileged behavior efficiently.</p>

<p>Another important concept is the virtual CPU (vCPU).
A vCPU does not execute instructions by itself.
Instead, it stores what the guest thinks the CPU state is, including things like:</p>
<ul>
  <li>Register values</li>
  <li>Program counter</li>
  <li>CPU flags</li>
</ul>

<p>For each VM, the VMM keeps one or more vCPU data structures.
When the VMM schedules a guest to run on a real CPU core, it does a simple save/restore cycle:</p>
<ol>
  <li>Load: copy the saved CPU state from the vCPU into the real CPU (registers, instruction pointer, flags, etc.).</li>
  <li>Run: let the guest execute for a time slice, until it traps, blocks, or gets preempted.</li>
  <li>Save: copy the updated CPU state back into the vCPU so the guest can resume later from the same point.</li>
</ol>

<h3 id="trap-and-emulate">Trap-and-Emulate</h3>

<p>On a typical CPU with only two modes (user and kernel) without extra hardware support, the guest kernel can execute only in user mode.
Because the VM must imitate the physical machine, the VMM provides virtual modes:</p>
<ul>
  <li>Virtual user mode</li>
  <li>Virtual kernel mode</li>
</ul>

<p>Both virtual modes still execute in physical user mode.</p>

<p>For example, the following actions cause a transfer from user mode to kernel mode on a real machine:</p>
<ul>
  <li>a system call</li>
  <li>an interrupt</li>
  <li>a privileged instruction</li>
</ul>

<p>When the same actions occur inside the guest kernel, the VM must also transfer from virtual user mode to virtual kernel mode.
This is done using trap-and-emulate:</p>
<ol>
  <li>The guest kernel executes a privileged instruction.</li>
  <li>Because the guest kernel is in physical user mode, it causes a trap to the VMM (in the real machine).</li>
  <li>The VMM executes (or emulates) the privileged instruction of the guest kernel.</li>
  <li>It then returns control to the virtual machine.</li>
</ol>

<p><img src="/assets/posts/41/trap_and_emulate.png" alt="Trap-and-emulate" /></p>

<p>The downside is overhead.
Nonprivileged instructions run at near native speed, but privileged instructions trap to the VMM and must be emulated.
Sharing the CPU across multiple VMs can also make performance unpredictable.</p>

<p>However, as hardware has improved, trap-and-emulate has gotten faster, and the system needs it less often.
For example, modern CPUs add extra modes for virtualization beyond the typical user and kernel modes.
This lets the CPU handle more of the work directly at the hardware level, so the VMM has less to do.</p>

<h3 id="binary-translation">Binary Translation</h3>

<p>Trap-and-emulate works only on CPUs where all sensitive instructions trap when executed in unprivileged mode.
If some sensitive instructions can run without trapping, trap-and-emulate alone is not sufficient.</p>

<p>Binary translation fixes this by rewriting guest kernel code on the fly.
Assume there are some sensitive instructions that do not automatically trap.
Call them special instructions. Then:</p>
<ul>
  <li>If the guest vCPU is in virtual user mode, the guest can run its instructions natively on a physical CPU.</li>
  <li>If the guest vCPU is in virtual kernel mode, the VMM inspects every instruction by the guest:
    <ul>
      <li>Non-special instructions run natively.</li>
      <li>Special instructions are translated (rewrited) into safe sequences that produce the same effect</li>
    </ul>
  </li>
</ul>

<p><img src="/assets/posts/41/binary_translation.png" alt="Binary translation" /></p>

<p>Even though most instructions run natively, special instructions still add overhead because they must be translated.
To reduce this cost, the VMM saves translated code in a translation cache.
When the same code runs again, the VMM can reuse the cached translation instead of translating it again.</p>

<h2 id="core-operating-system-components-in-virtualization">Core Operating-System Components in Virtualization</h2>

<h3 id="cpu-scheduling">CPU Scheduling</h3>

<p>The effect of virtualization on scheduling varies a lot.
This is because virtualization can make even a single-CPU system look like a multiprocessor sytem.
For example, even if a host has six physical CPUs, the VMM can present twelve vCPUs across its guests.
This implies:</p>
<ul>
  <li>If there are enough physical CPUs to satisfy each guest’s requested vCPUs, the VMM can treat the allocation as effectively dedicated. It can schedule only a given guest’s threads on that guest’s assigned CPUs. In this situation, the guests behave much like native operating systems running on native hardware.</li>
  <li>If the total number of requested vCPUs exceeds the available physical CPUs (overcommitment), the VMM relies on a standard scheduling algorithm so that guests continue to perceive adequate CPU availability. For instance, with six physical CPUs and twelve vCPUs total, the VMM can distribute CPU time proportionally, so each guest effectively receives about half of the host’s CPU capacity.</li>
</ul>

<p>Under CPU overcommitment, some performance degradation is expected because vCPUs must time-share physical CPUs and may spend time waiting to be scheduled.</p>

<h3 id="memory-management">Memory Management</h3>

<p>VMMs typically overcommit memory, so the total memory allocated to guests can exceed the amount of physical memory available.
This increases memory pressure because there are more guest operating systems and their applications, plus the VMM itself.
Therefore, the VMM must preserve the illusion that the guest has that amount of memory, even when physical memory is smaller.
To do that:</p>
<ol>
  <li>The VMM first evaluates each guest’s maximum memory size so that the guest OSes continue to believe they have what they asked for.</li>
  <li>The VMM then computes how much memory to actually give it right now based on current load and overcommitment.</li>
  <li>The VMM then reclaims memory from the guests when the host system is running low on memory.</li>
</ol>

<p>For reclaiming memory, the most commonly used technique is <a href="https://www.vmware.com/docs/perf-vsphere-memory_management">memory ballooning, developed by VMware</a>.
Here, a balloon driver is loaded into the guest as a pseudo-device driver.</p>

<p class="notice--info">A pseudo–device driver uses device-driver interfaces, appearing to the kernel to be a device driver, but does not actually control a device. Rather, it is an easy way to add kernel-mode code without directly modifying the kernel.</p>

<p>The role of a balloon driver is to make the guests aware of the low memory status of the host.
It does this by polling the hypervisor and getting a target balloon size.
Without this driver, the VM cannot detect host memory pressure because the VM is isolated from the host.
When the host is low on memory:</p>
<ol>
  <li>The hypervisor sets a target balloon size based on how much memory it wants to reclaim.</li>
  <li>The balloon driver “inflates” the balloon inside the guest. It allocates and pins that amount of guest physical memory to the balloon. This makes the guest OS treat those pages as in use.</li>
  <li>The balloon driver reports the pinned page frame numbers to the hypervisor. The hypervisor can then reclaim the host physical pages backing those frames.</li>
</ol>

<p>When the host has enough memory again, the hypervisor “deflates” the balloon, and the balloon driver releases pages back to the guest.</p>

<p><img src="/assets/posts/41/memory_ballooning.png" alt="Memory ballooning" /></p>

<p>The key benefit of ballooning is that it lets the guest operating system decide which pages to page out.
The hypervisor does not need to be involved in that decision.</p>

<h3 id="storage-management">Storage Management</h3>

<p>In a virtualized system, storage must be handled differently from a native operating system.</p>

<p>The exact approach depends on the type of hypervisor:</p>
<ul>
  <li>Type 1 hypervisors store each guest’s root disk and configuration data as one or more files in file systems managed by the VMM.</li>
  <li>Type 2 hypervisors store the same information as files in the host operating system’s file systems.</li>
</ul>

<p>In practice, a guest’s entire root disk is packaged into a single disk image file managed by the VMM. Although this design can introduce performance overhead, it is highly practical. It makes copying, backing up, and moving virtual machines straightforward.
For example, if an administrator wants to create a duplicate guest for testing, they can simply copy the disk image file and register the new copy with the VMM.
Similarly, moving a virtual machine between systems running the same VMM is easy: shut down the guest, copy the disk image to the new system, and start the guest there.</p>

<p>Many VMMs also support converting systems between physical and virtual systems:</p>
<ul>
  <li>Physical-to-virtual (P-to-V) conversion: converts a running physical machine into a virtual machine. This is done by copying its disk contents into virtual disk image files.</li>
  <li>Virtual-to-physical (V-to-P) conversion: converts a virtual machine into a physical system. This is useful for debugging a problem where the virtualization layer itself may be contributing to the issue.</li>
</ul>

<h3 id="io-management">I/O Management</h3>

<p>I/O virtualization is easier than CPU or memory virtualization because operating systems already handle many kinds of devices through device drivers.
A hypervisor uses this by providing specific virtualized devices to guests.</p>

<p>Common ways a VMM handles I/O:</p>
<ul>
  <li>Pass-through (dedicated device): one real device is directly dedicated to one guest. This can be close to native speed, but other guests cannot use that device.</li>
  <li>Shared device: many guests share one real device. The VMM must sit in the middle for every I/O to keep guests isolated and send data to the right place.</li>
  <li>Split-device: the guest uses a simple driver that talks to the VMM, and the VMM talks to the real hardware. This is often faster and easier to manage than fully emulating real devices.</li>
</ul>

<p>Direct access can be fast in type 0 hypervisor, but type 1 and type 2 hypervisors usually need hardware support.
Without it, I/O slows down because the hypervisor must handle many extra events, especially interrupts.</p>

<p>For networking, each guest must have at least one IP address to communicate with other systems.
The VMM then acts like a virtual switch to route the packets to the right guest.
Guests can connect using:</p>
<ul>
  <li>Bridging: the guest appears directly on the external network with its own IP.</li>
  <li>NAT: the guest uses an internal IP, and the VMM translates and forwards traffic.</li>
</ul>

<p>VMMs often also provide basic firewall rules to control traffic between guests and to the outside network.</p>]]></content><author><name>Mienxiu</name></author><category term="docker" /><category term="os" /><summary type="html"><![CDATA[In the 1960s, early computers like IBM mainframes typically ran only one operating system at a time. This design had several drawbacks: Different workloads could not easily share the same hardware. Hardware was often idle. Testing a new OS or switching OSes required another machine or a reboot.]]></summary></entry><entry><title type="html">I/O Management</title><link href="https://mienxiu.com/io-management/" rel="alternate" type="text/html" title="I/O Management" /><published>2026-03-07T00:00:00+00:00</published><updated>2026-03-07T00:00:00+00:00</updated><id>https://mienxiu.com/io-management</id><content type="html" xml:base="https://mienxiu.com/io-management/"><![CDATA[<p>A core responsibility of an operating system is managing the computer’s hardware.
This responsibility covers not only the CPU and memory, but also input/output (I/O) devices.</p>

<h2 id="io-devices">I/O Devices</h2>

<p><img src="/assets/posts/40/io_devices.png" alt="I/O devices" /></p>

<p>I/O devices cover a wide range of hardware.
Common examples are storage devices such as SSDs, network interface cards (NICs), and devices used for human interaction, including displays, keyboards, speakers, and microphones.</p>

<p>Since I/O devices behave very differently from one another, the operating system needs to hide these differences from applications.
To make this separation possible, operating systems rely on two key ideas:</p>
<ul>
  <li>Standard interfaces</li>
  <li>Device drivers</li>
</ul>

<h3 id="standard-interfaces">Standard Interfaces</h3>

<p>Because I/O devices are very different, applications cannot communicate with them directly.
Instead, the operating system defines standard interfaces for broad classes of devices, such as storage devices, network devices, or display devices.
These interfaces define what operations a device supports (for example, read, write, send, receive) and how those operations are requested.
Applications interact only with these interfaces, not with the hardware itself.
This allows the same application code to work with many different devices of the same type.</p>

<p>At a lower level, devices must physically connect to the CPU and memory.
In modern systems, this is done using device interconnects, most commonly PCI Express (PCIe).</p>

<p><img src="/assets/posts/40/pcie_bus.png" alt="PCIe bus" /></p>

<p>PCIe provides a high-speed, point-to-point connection between devices and the CPU.
Unlike the older shared PCI bus, PCIe uses dedicated links (lanes) for each device.
Through PCIe, devices can exchange data with the CPU, memory, and the rest of the system in a standardized way.</p>

<p>Other types of buses are also used even in a PCIe system, depending on the device.
For example:</p>

<ul>
  <li>SATA controllers for certain storage devices</li>
  <li>Simpler peripheral buses (such as USB) for devices like keyboards and mice</li>
</ul>

<p>The choice of bus affects performance, how devices are discovered at boot time, and how the operating system manages and schedules access to hardware.</p>

<h3 id="device-drivers">Device Drivers</h3>

<p>A device driver is software that knows how to control a specific hardware device.
Each driver contains all the device-specific code needed to configure the device, send commands to it, move data in and out, and respond to events such as interrupts or errors.</p>

<p><img src="/assets/posts/40/device_drivers.png" alt="Device drivers" /></p>

<p>When an application makes an I/O request through a standard interface, the operating system forwards that request to the appropriate driver.
This design allows the operating system and applications to remain unchanged even when hardware is replaced, as long as the correct driver is available.
From the operating system’s perspective, supporting a new device usually means adding a new driver, not rewriting the rest of the system.</p>

<p>Typically, the operating system defines a standard device driver framework, and hardware manufacturers provide drivers that fit into this framework.
This is why users often need to install drivers when adding new hardware, such as printers.</p>

<p>At the hardware level, a device controller contains built-in logic and firmware that directly run the physical device.
It receives commands from the device driver, performs the requested actions, transfers data between the device and memory, and signals completion or errors via interrupts.</p>

<h2 id="devices-as-files">Devices as Files</h2>

<p>Many operating systems represent devices as files.
This means applications can interact with devices using familiar operations such as read and write.</p>

<p>On Unix-like systems, devices appear as special files under the <code class="language-plaintext highlighter-rouge">/dev</code> directory.
For example, running <code class="language-plaintext highlighter-rouge">echo "hello, world" &gt; /dev/lp0</code> sends the text <code class="language-plaintext highlighter-rouge">hello, world</code> to the first line printer (<code class="language-plaintext highlighter-rouge">lp0</code>).
Although this looks like a normal file write, the kernel intercepts the operation and translates it into the appropriate device-specific commands handled by the printer driver.</p>

<p class="notice--info">Unix-like operating systems provide a virtual device known as the null device, exposed as <code class="language-plaintext highlighter-rouge">/dev/null</code>.
Any data written to this device is discarded by the kernel.</p>

<h2 id="typical-device-access-flow">Typical Device Access Flow</h2>

<p>Typically, user programs cannot access hardware devices directly.
Instead, device access goes through the operating system:</p>
<ol>
  <li>When a process needs to use a device—such as sending a network packet or reading a disk file—it makes a system call. This transfers control from the process to the kernel.</li>
  <li>Inside the kernel, the operating system runs the relevant internal code:
    <ul>
      <li>Networking requests go through the TCP/IP stack.</li>
      <li>File requests go through the file system to locate disk blocks.</li>
      <li>The kernel converts the high-level request into a low-level device operation.</li>
    </ul>
  </li>
  <li>The kernel then calls the device driver, which understands the hardware details. The driver:
    <ul>
      <li>Translates requests into device-specific commands</li>
      <li>Configures the device by writing to its registers</li>
      <li>Manages pending requests safely</li>
    </ul>
  </li>
  <li>The driver uses mechanisms like programmed I/O or DMA to transfer data. Once configured, the device performs the operation, such as transmitting data or reading from disk.</li>
  <li>When the operation finishes, the result travels back through the driver and kernel to the user process.</li>
</ol>

<h3 id="os-bypass">OS Bypass</h3>

<p>Some devices support a different model in which applications can access hardware without going through the kernel on every operation.
This is called operating system bypass (OS bypass).</p>

<p>In this model, the operating system is involved only during setup.
It maps device memory and registers into the process’s address space and establishes permissions.
After setup, the data path goes directly between the user process and the device.</p>

<p>Because the kernel is no longer in the data path, applications use a user-level driver, typically a library provided by the device vendor, to issue device commands.</p>

<h2 id="blocking-nonblocking-and-asynchronous-io">Blocking, Nonblocking, and Asynchronous I/O</h2>

<p>When a process issues an I/O request, the device will eventually return some response.
What happens to the calling thread depends on whether the I/O is synchronous or asynchronous.</p>

<p><img src="/assets/posts/40/sync_and_async_io.png" alt="Synchronous and asynchronous I/O" /></p>

<p>An important design choice in the system-call interface is how I/O operations interact with a running program.
The key question is whether the calling thread should wait for I/O to finish or continue running while the I/O happens in the background.</p>

<h3 id="blocking-synchronous-io">Blocking (synchronous) I/O</h3>

<p>In blocking I/O, a system call pauses the calling thread until the I/O operation completes.</p>

<p>Here is what happens step by step:</p>
<ol>
  <li>The application issues a blocking system call (for example, <code class="language-plaintext highlighter-rouge">read()</code>).</li>
  <li>The operating system suspends the calling thread.</li>
  <li>The thread is moved from the run queue to a wait queue.</li>
  <li>When the I/O finishes, the thread is moved back to the run queue.</li>
  <li>When the thread runs again, it receives the return values from the system call.</li>
</ol>

<p>Although I/O devices work asynchronously at the hardware level and may take an unpredictable amount of time, operating systems still offer blocking calls.
The reason is simple: blocking code is easier to write and reason about.</p>

<h3 id="nonblocking-io">Nonblocking I/O</h3>

<p>Some operating systems provide nonblocking I/O system calls.
These calls do not suspend the thread for a long time.</p>

<p>A nonblocking call:</p>
<ul>
  <li>Returns immediately.</li>
  <li>Reports how many bytes were transferred.</li>
  <li>May return fewer bytes than requested, or even zero, if no data is available yet.</li>
</ul>

<p>The thread can then decide whether to try again later or do something else.
This avoids wasting CPU time and allows computation to overlap with I/O.</p>

<p>One way to handle this problem is multithreading.
An application can create multiple threads:</p>
<ul>
  <li>Some threads perform blocking I/O.</li>
  <li>Other threads continue running useful work.</li>
</ul>

<p>This approach works well but increases complexity due to synchronization and shared data management.</p>

<h3 id="asynchronous-io">Asynchronous I/O</h3>

<p>Another option is asynchronous I/O, which goes one step further.</p>

<p>With an asynchronous system call:</p>
<ul>
  <li>The call returns immediately.</li>
  <li>The operating system guarantees that the entire I/O request will be completed later.</li>
  <li>The thread continues executing without waiting.</li>
</ul>

<p>When the I/O finishes, the operating system notifies the application using one of several methods:</p>
<ul>
  <li>Updating a variable in the process’s address space</li>
  <li>Sending a signal or software interrupt</li>
  <li>Invoking a callback function</li>
</ul>

<p>Asynchronous operations are common inside modern operating systems, even when applications are not directly aware of them.</p>

<h2 id="block-device-stack">Block Device Stack</h2>

<p>The block device stack describes how an I/O request follows from an application down to the physical storage device.</p>

<p><img src="/assets/posts/40/block_device_stack.png" alt="Block device stack" /></p>

<p>At the top of the stack are applications.=
As explained eariler, applications do not work with disks directly.
Instead, they operate on files, which are logical objects provided by the operating system.
Programs use system calls such as <code class="language-plaintext highlighter-rouge">open</code>, <code class="language-plaintext highlighter-rouge">read</code>, and <code class="language-plaintext highlighter-rouge">write</code> to access files without knowing where or how the data is stored.</p>

<p>These system calls are handled by the file system.
The file system manages files and directories, checks permissions, and translates file-level operations into accesses to disk blocks.
Before accessing the disk, the operating system usually checks the page cache.
If the requested data is already in memory, it can be returned immediately.
If not, the request continues down the stack.</p>

<p>Below the file system and page cache is the generic block layer.
This layer provides a common interface for different block devices such as HDDs, SSDs, and USB drives.
Even though these devices work differently internally, the block layer hides those differences and schedules read and write requests.</p>

<p>At the bottom of the stack are the device drivers.
Drivers communicate directly with the hardware and handle device-specific commands, errors, and interrupts.</p>

<p>A typical storage request flows down the stack as follows:</p>

<ol>
  <li>Application calls read or write</li>
  <li>Operating system enters the file system. If data is present in page cache, return immediately.</li>
  <li>File system maps the request to disk blocks</li>
  <li>Block layer schedules the request</li>
  <li>Device driver accesses the hardware</li>
</ol>

<h2 id="virtual-file-system">Virtual File System</h2>

<p>Modern operating systems support many different kinds of storage.
Files may live on different disks, use different file system formats, or even be stored on remote drives and accessed over a network.
Despite this complexity, applications should not need to know where a file is stored or how it is managed internally.</p>

<p>To solve this problem, operating systems such as Linux use a Virtual File System (VFS) layer.
The VFS sits between applications and the actual file systems.
Applications interact with files using a standard interface, such as the POSIX API, and the VFS takes care of translating these requests to the appropriate underlying file system.</p>

<p><img src="/assets/posts/40/virtual_file_system.png" alt="Virtual file system" /></p>

<p>This separation makes it easy to add new file systems or switch storage devices without modifying application code.</p>

<p>To support many file systems in a uniform way, the VFS defines a small set of common abstractions.</p>

<h3 id="files-and-file-descriptors">Files and File Descriptors</h3>

<p>A file is the basic object that the VFS operates on.
When a process opens a file, the operating system creates a file descriptor, which is a small integer used to identify that open file.</p>

<p>All file operations—such as reading, writing, locking, or closing—are performed using the file descriptor.
The file descriptor exists only while the file is open and is specific to the process that opened it.</p>

<h3 id="inodes">Inodes</h3>

<p><img src="/assets/posts/40/inode.png" alt="Inode" /></p>

<p>An inode (short for index node) is the core data structure a file system uses to manage files.
Internally, each file is identified by an inode number, not by its file name.
The file name is just a human-friendly label used to locate the corresponding inode.</p>

<p>An inode contains a list of disk block numbers that store the file’s actual data.
In this sense, the inode acts as an index: it does not store the file contents itself, but instead stores pointers to the blocks that hold the contents.
Reading those blocks in the correct order reconstructs the file.</p>

<p>When more data is written to a file and extra space is needed, the file system:</p>
<ol>
  <li>Allocates a free disk block</li>
  <li>Adds its block number to the inode</li>
  <li>Updates the inode on disk</li>
</ol>

<p>The inode always reflects the current layout of the file.</p>

<p>Besides block pointers, an inode also stores metadata such as:</p>
<ul>
  <li>Access permissions</li>
  <li>Ownership</li>
  <li>Locking or status information</li>
</ul>

<p>This metadata is used to enforce access rules and manage concurrent use.</p>

<p>This inode-based design supports efficient access patterns.
Sequential access simply follows the list of blocks in order, while random access directly computes the required block from the file offset.</p>

<p>The main drawback of this simple design is that it limits how large a file can be.
Consider this example:</p>
<ul>
  <li>Inode size: 128 bytes</li>
  <li>Each block pointer: 4 bytes</li>
  <li>Maximum number of block pointers: 128 / 4 = 32</li>
  <li>Block size: 1 KB</li>
</ul>

<p>With only direct block pointers and no space for metadata, the largest file that can be addressed is 32 blocks * 1 KB = 32 KB.</p>

<p>A common way to overcome the file-size limitation of simple inodes is to use indirect pointers.</p>

<p><img src="/assets/posts/40/inodes_indirect_pointers.png" alt="Inodes with indirect pointers" /></p>

<p>An inode still begins with metadata, followed by pointers to data blocks.
The first pointers are direct pointers, which point straight to blocks containing file data.</p>

<p>An indirect pointer does not point to data directly.
Instead, it points to a block that contains only block addresses.
A 1 KB block can store 256 such addresses, allowing a single indirect pointer to reference 256 KB of file data.
For larger files, double or even triple indirect pointers are used.
A double indirect pointer, for example, points to a block of pointers to other pointer blocks, which then point to data blocks.
With 256 pointers per block, double indirect addressing supports up to 64 MB of data.</p>

<p>The trade-off of using indirect pointers is that it increases file size but also increases access cost.</p>

<h3 id="directories-and-dentires">Directories and Dentires</h3>

<p>Files are organized into directories.
From the VFS perspective, a directory is simply a special kind of file.
The difference is that the contents of a directory describe file names and the inodes they refer to.</p>

<p>Dentires are cached in memory in what is known as the dentry cache.
This cache avoids repeated disk accesses when the same directories are accessed multiple times.
Dentires are not stored on disk.
They are temporary objects maintained only in memory.</p>

<h3 id="superblock">Superblock</h3>

<p>Every file system has a superblock, which describes how the file system is laid out on disk.
The superblock contains information such as:</p>
<ul>
  <li>Where inodes are stored</li>
  <li>Where data blocks are located</li>
  <li>How free space is managed</li>
</ul>

<p>The superblock acts as a map that allows the operating system to interpret the on-disk data structures correctly.</p>

<p>Many VFS structures, such as file descriptors and dentries, exist only in memory.
Others, including file data, inodes, and superblocks, must be stored persistently on disk.</p>

<p>A widely used modern file system in Linux is ext4 (4th extended file system), which is the successor to ext2 and ext3.</p>

<h3 id="disk-access-optimizations">Disk Access Optimizations</h3>

<p>Disk operations are much slower than memory operations, so file systems use several techniques to reduce disk access and improve I/O performance.
These techniques focus on keeping data in memory, reducing disk head movement, and avoiding unnecessary random accesses.</p>

<p>Key techniques include:</p>
<ul>
  <li>buffer caching: Keep recently accessed file data in memory so most reads and writes avoid disk access; changes are flushed to disk periodically.</li>
  <li>I/O scheduling: Reorder disk requests to minimize disk head movement and favor sequential access over random access.</li>
  <li>prefetching: Load nearby file blocks into memory in advance, increasing cache hits and reducing future access latency.</li>
  <li>journaling: Record updates in a sequential log before writing them to their final disk locations, improving reliability while limiting random disk writes.</li>
</ul>]]></content><author><name>Mienxiu</name></author><category term="os" /><summary type="html"><![CDATA[A core responsibility of an operating system is managing the computer’s hardware. This responsibility covers not only the CPU and memory, but also input/output (I/O) devices.]]></summary></entry><entry><title type="html">Deploying Self-managed Elasticsearch 8 Cluster with Docker on AWS</title><link href="https://mienxiu.com/deploying-self-managed-elasticsearch-8-cluster-with-docker-on-aws/" rel="alternate" type="text/html" title="Deploying Self-managed Elasticsearch 8 Cluster with Docker on AWS" /><published>2026-01-03T00:00:00+00:00</published><updated>2026-01-03T00:00:00+00:00</updated><id>https://mienxiu.com/deploying-self-managed-elasticsearch-8-cluster-with-docker-on-aws</id><content type="html" xml:base="https://mienxiu.com/deploying-self-managed-elasticsearch-8-cluster-with-docker-on-aws/"><![CDATA[<p>Deploying a self-managed Elasticsearch cluster for production is a bit of pain.
It’s not just about understanding the core components.
You also have to manually configure availability and security.
To do that, you end up jumping between many pages in the official documentation to piece everything together.</p>

<p>This post is a comprehensive walkthrough of deploying a self-managed Elasticsearch cluster using one of <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.19/install-elasticsearch.html#elasticsearch-deployment-options">the supported installation methods</a>: Docker.
It also aims to follow production best practices and recommendations.</p>

<h2 id="planning-elasticsearch-cluster">Planning Elasticsearch Cluster</h2>

<p>By the end of this tutorial, you’ll have the following setup in place:</p>

<p><img src="/assets/posts/39/production_cluster_and_monitoring_node.png" alt="Production cluster and monitoring node" /></p>

<p>We will deploy:</p>

<ul>
  <li>A production cluster with:
    <ul>
      <li>Three master nodes, as <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.19/modules-discovery-voting.html#_even_numbers_of_master_eligible_nodes">there should normally be an odd number of master-eligible nodes in a cluster</a>.</li>
      <li>Three data nodes</li>
    </ul>
  </li>
  <li>A dedicated monitoring cluster with Kibana and Elastic Agent</li>
  <li>An optional load balancer in front of the production cluster.</li>
</ul>

<h3 id="about-node-roles">About Node Roles</h3>

<p>Here’s a quick note on node roles in this setup:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">master</code>: Responsible for lightweight cluster-wide actions such as creating or deleting an index, tracking which nodes are part of the cluster, and deciding which shards to allocate to which nodes.</li>
  <li><code class="language-plaintext highlighter-rouge">data</code>: Responsible for holding data and performing data related operations such as CRUD, search, and aggregations.</li>
</ul>

<p>We won’t use <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.19/node-roles-overview.html#coordinating-only-node-role">coordinating only nodes</a> in this setup, since data nodes can happily serve the same purpose.
For more details on node roles, refer to <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.19/node-roles-overview.html">Node roles</a>.</p>

<h3 id="why-use-a-dedicated-monitoring-cluster">Why Use a Dedicated Monitoring Cluster?</h3>

<p>Using a dedicated monitoring cluster is the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.19/monitoring-overview.html">recommended configuration</a> for two key reasons:</p>
<ul>
  <li>Production outages do not impact access to monitoring data.</li>
  <li>Monitoring workloads are isolated and cannot degrade production cluster performance.</li>
</ul>

<p>Elastic also recommends using <a href="https://www.elastic.co/guide/en/kibana/8.19/monitoring-data.html">a separate Kibana instance</a> to view a separate monitoring cluster.</p>

<p>For the sake of operational simplicity, this setup uses a single-node monitoring cluster.
When operating at scale, however, you can also configure a more resilient multi-node monitoring cluster by following this tutorial.</p>

<h2 id="infrastructure-setup">Infrastructure Setup</h2>

<p>You can skip this part if you already have servers ready to host the Elasticsearch cluster.</p>

<p>This tutorial has been demonstrated using the following setup:</p>

<ul>
  <li>Elastic Stack version: 8.19.7.</li>
  <li>VPN access: A VPN for secure configuration and access to the Elasticsearch cluster, using the CIDR block <code class="language-plaintext highlighter-rouge">10.0.0.0/8</code></li>
</ul>

<h3 id="create-a-security-group">Create a Security Group</h3>

<p>Log in to the AWS Management Console and navigate to EC2 -&gt; Security Groups -&gt; Create security group.</p>

<p>Create a security group named <code class="language-plaintext highlighter-rouge">my-elasticsearch-node</code> for the Elasticsearch nodes, and configure the following inbound rules:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Type</th>
      <th style="text-align: left">Protocol</th>
      <th style="text-align: left">Port range</th>
      <th style="text-align: left">Source</th>
      <th style="text-align: left">Description - optional</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left">SSH</td>
      <td style="text-align: left">TCP</td>
      <td style="text-align: left">22</td>
      <td style="text-align: left">10.0.0.0/8</td>
      <td style="text-align: left"> </td>
    </tr>
    <tr>
      <td style="text-align: left">Custom TCP</td>
      <td style="text-align: left">TCP</td>
      <td style="text-align: left">5601</td>
      <td style="text-align: left">10.0.0.0/8</td>
      <td style="text-align: left">Kibana</td>
    </tr>
    <tr>
      <td style="text-align: left">Custom TCP</td>
      <td style="text-align: left">TCP</td>
      <td style="text-align: left">9200</td>
      <td style="text-align: left">10.0.0.0/8</td>
      <td style="text-align: left">HTTP port for REST clients to communicate with Elasticsearch</td>
    </tr>
    <tr>
      <td style="text-align: left">Custom TCP</td>
      <td style="text-align: left">TCP</td>
      <td style="text-align: left">9300</td>
      <td style="text-align: left">10.0.0.0/8</td>
      <td style="text-align: left">Transport port for nodes to communicate with one another</td>
    </tr>
  </tbody>
</table>

<h3 id="create-a-launch-template">Create a Launch Template</h3>

<p>Create a Launch Template to make it easier to add multiple nodes to the cluster.</p>

<p>Go to EC2 → Launch Templates → Create launch template, then configure the following:</p>

<ul>
  <li>Launch template name: <code class="language-plaintext highlighter-rouge">my-elasticsearch-node</code></li>
  <li>Launch template contents:
    <ul>
      <li>Amazon Machine Image (AMI): <code class="language-plaintext highlighter-rouge">Ubuntu Server 24.04 LTS (HVM), SSD Volume Type</code></li>
      <li>Architecture: <code class="language-plaintext highlighter-rouge">64-bit (Arm)</code></li>
    </ul>
  </li>
  <li>Network settings:
    <ul>
      <li>Security groups: <code class="language-plaintext highlighter-rouge">my-elasticsearch-node</code></li>
    </ul>
  </li>
  <li>Storage:
    <ul>
      <li>Size: 100 GiB</li>
    </ul>
  </li>
</ul>

<p>This configuration is just an example.
You can adjust the values to match your own requirements.
Just make sure to select the security group created in the previous step, that is <code class="language-plaintext highlighter-rouge">my-elasticsearch-node</code>.</p>

<h3 id="launch-instances">Launch Instances</h3>

<ol>
  <li>Go to EC2 -&gt; Instances -&gt; Launch instance from template and configure the following:
    <ul>
      <li>Source template: <code class="language-plaintext highlighter-rouge">my-elasticsearch-node</code></li>
      <li>Instance type: (Your choice. For this tutorial, the monitoring node requires at least 8 GiB of RAM.)</li>
      <li>Key pair (login): Select your key pair.</li>
      <li>Resource tags:
        <ul>
          <li>Key: <code class="language-plaintext highlighter-rouge">Name</code> (for identifying instances)</li>
          <li>Value: <code class="language-plaintext highlighter-rouge">my-elasticsearch-node</code></li>
        </ul>
      </li>
      <li>Number of instances: <code class="language-plaintext highlighter-rouge">7</code> (6 for the production cluster and 1 for the monitoring node)</li>
    </ul>
  </li>
  <li>Click <code class="language-plaintext highlighter-rouge">Launch instance</code></li>
  <li>Go to EC2 -&gt; Instances -&gt; Search <code class="language-plaintext highlighter-rouge">my-elasticsearch-node</code> and update the <code class="language-plaintext highlighter-rouge">Name</code> tags as follows:
    <ul>
      <li><code class="language-plaintext highlighter-rouge">my-elasticsearch-node-master-1</code></li>
      <li><code class="language-plaintext highlighter-rouge">my-elasticsearch-node-master-2</code></li>
      <li><code class="language-plaintext highlighter-rouge">my-elasticsearch-node-master-3</code></li>
      <li><code class="language-plaintext highlighter-rouge">my-elasticsearch-node-data-1</code></li>
      <li><code class="language-plaintext highlighter-rouge">my-elasticsearch-node-data-2</code></li>
      <li><code class="language-plaintext highlighter-rouge">my-elasticsearch-node-data-3</code></li>
      <li><code class="language-plaintext highlighter-rouge">my-elasticsearch-node-monitoring</code></li>
    </ul>
  </li>
  <li>Retrieve the private IP addresses of all EC2 instances using the AWS CLI:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> aws ec2 describe-instances <span class="se">\</span>
     <span class="nt">--filters</span> <span class="s2">"Name=tag:Name,Values=my-elasticsearch-node-*"</span> <span class="se">\</span>
     <span class="nt">--query</span> <span class="s2">"Reservations[].Instances[].[Tags[?Key=='Name'].Value | [0], PrivateIpAddress]"</span> <span class="se">\</span>
     <span class="nt">--output</span> text <span class="se">\</span>
     <span class="nt">--no-cli-pager</span>
</code></pre></div>    </div>
    <p>Output:</p>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> my-elasticsearch-node-master-1   10.0.0.1
 my-elasticsearch-node-master-2   10.0.0.2
 my-elasticsearch-node-master-3   10.0.0.3
 my-elasticsearch-node-data-1     10.0.1.1
 my-elasticsearch-node-data-2     10.0.1.2
 my-elasticsearch-node-data-3     10.0.1.3
 my-elasticsearch-node-monitoring        10.0.2.1
</code></pre></div>    </div>
  </li>
  <li>Update SSH config file (usually <code class="language-plaintext highlighter-rouge">~/.ssh/config</code>) to connect to the instances from your local environment:
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> Host my-elasticsearch-node-*
     User ubuntu
     IdentityFile your_key.pem
 Host my-elasticsearch-node-master-1
     HostName 10.0.0.1
 Host my-elasticsearch-node-master-2
     HostName 10.0.0.2
 Host my-elasticsearch-node-master-3
     HostName 10.0.0.3
 Host my-elasticsearch-node-data-1
     HostName 10.0.1.1
 Host my-elasticsearch-node-data-2
     HostName 10.0.1.2
 Host my-elasticsearch-node-data-3
     HostName 10.0.1.3
 Host my-elasticsearch-node-monitoring
     HostName 10.0.2.1
</code></pre></div>    </div>
  </li>
</ol>

<p>The IP addresses used in this tutorial are simplified for clarity.
Your IP addresses will differ depending on your VPC and subnet configuration.</p>

<h2 id="deploying-an-elasticsearch-cluster">Deploying an Elasticsearch cluster</h2>

<h3 id="initializing-the-cluster-with-master-nodes">Initializing the Cluster with Master Nodes</h3>

<ol>
  <li>Connect to <code class="language-plaintext highlighter-rouge">my-elasticsearch-node-master-1</code>:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> ssh my-elasticsearch-node-master-1
</code></pre></div>    </div>
  </li>
  <li>Install <a href="https://docs.docker.com/engine/install/ubuntu/">Docker</a>:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="nb">sudo </span>apt-get update
 <span class="nb">sudo </span>apt-get <span class="nb">install</span> <span class="nt">-y</span> ca-certificates curl
 <span class="nb">sudo install</span> <span class="nt">-m</span> 0755 <span class="nt">-d</span> /etc/apt/keyrings
 <span class="nb">sudo </span>curl <span class="nt">-fsSL</span> https://download.docker.com/linux/ubuntu/gpg <span class="nt">-o</span> /etc/apt/keyrings/docker.asc
 <span class="nb">sudo chmod </span>a+r /etc/apt/keyrings/docker.asc
 <span class="nb">echo</span> <span class="se">\</span>
     <span class="s2">"deb [arch=</span><span class="si">$(</span>dpkg <span class="nt">--print-architecture</span><span class="si">)</span><span class="s2"> signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu </span><span class="se">\</span><span class="s2">
     </span><span class="si">$(</span><span class="nb">.</span> /etc/os-release <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"</span><span class="k">${</span><span class="nv">UBUNTU_CODENAME</span><span class="k">:-</span><span class="nv">$VERSION_CODENAME</span><span class="k">}</span><span class="s2">"</span><span class="si">)</span><span class="s2"> stable"</span> | <span class="se">\</span>
     <span class="nb">sudo tee</span> /etc/apt/sources.list.d/docker.list <span class="o">&gt;</span> /dev/null
 <span class="nb">sudo </span>apt-get update
 <span class="nb">sudo </span>apt-get <span class="nb">install</span> <span class="nt">-y</span> docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
 <span class="nb">sudo </span>usermod <span class="nt">-aG</span> docker <span class="k">${</span><span class="nv">USER</span><span class="k">}</span>
</code></pre></div>    </div>
  </li>
  <li><a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.19/vm-max-map-count.html">Set <code class="language-plaintext highlighter-rouge">vm.max_map_count</code> to at least <code class="language-plaintext highlighter-rouge">262144</code></a>:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="nb">sudo </span>sysctl <span class="nt">-w</span> vm.max_map_count<span class="o">=</span>262144
 <span class="c"># Add `vm.max_map_count` to `sysctl.conf`</span>
 <span class="nb">echo</span> <span class="s2">"vm.max_map_count=262144"</span> | <span class="nb">sudo tee</span> <span class="nt">-a</span> /etc/sysctl.conf
</code></pre></div>    </div>
    <ul>
      <li>This is to increase the limits on <code class="language-plaintext highlighter-rouge">mmap</code> counts to avoid out of memory exceptions:</li>
    </ul>
  </li>
  <li><a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-elasticsearch-docker-prod#_configuration_files_must_be_readable_by_the_elasticsearch_user">Prepare local directories for storing data and logs through a bind-mount</a>:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="nb">mkdir </span>esdatadir <span class="o">&amp;&amp;</span> <span class="nb">chmod </span>g+rwx esdatadir <span class="o">&amp;&amp;</span> <span class="nb">sudo chgrp </span>0 esdatadir
 <span class="nb">mkdir </span>eslogsdir <span class="o">&amp;&amp;</span> <span class="nb">chmod </span>g+rwx eslogsdir <span class="o">&amp;&amp;</span> <span class="nb">sudo chgrp </span>0 eslogsdir
</code></pre></div>    </div>
  </li>
  <li>Prepare a certificate directory:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="nb">mkdir </span>certs <span class="o">&amp;&amp;</span> <span class="nb">chmod </span>g+rwx certs <span class="o">&amp;&amp;</span> <span class="nb">sudo chgrp </span>0 certs
</code></pre></div>    </div>
  </li>
  <li>Generate CA (only if it doesn’t exist - first node should create and share with others):
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="nb">sudo </span>apt-get <span class="nb">install</span> <span class="nt">-y</span> unzip
 <span class="nb">sudo </span>docker run <span class="nt">--rm</span> <span class="se">\</span>
     <span class="nt">-v</span> <span class="nv">$HOME</span>/certs:/usr/share/elasticsearch/config/certs <span class="se">\</span>
     docker.elastic.co/elasticsearch/elasticsearch:8.19.7 <span class="se">\</span>
     bin/elasticsearch-certutil ca <span class="nt">--silent</span> <span class="nt">--pem</span> <span class="nt">--out</span> /usr/share/elasticsearch/config/certs/ca.zip
 <span class="nb">cd</span> <span class="nv">$HOME</span>/certs <span class="o">&amp;&amp;</span> unzip <span class="nt">-o</span> ca.zip <span class="o">&amp;&amp;</span> <span class="nb">mv </span>ca/<span class="k">*</span> <span class="nb">.</span> <span class="o">&amp;&amp;</span> <span class="nb">rmdir </span>ca <span class="o">&amp;&amp;</span> <span class="nb">rm </span>ca.zip
</code></pre></div>    </div>
  </li>
  <li>Generate node certificate signed by CA:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="nv">ES_NETWORK_HOST</span><span class="o">=</span><span class="si">$(</span><span class="nb">hostname</span> <span class="nt">-I</span> | <span class="nb">awk</span> <span class="s1">'{print $1}'</span><span class="si">)</span>
 <span class="nb">sudo </span>docker run <span class="nt">--rm</span> <span class="se">\</span>
     <span class="nt">-v</span> <span class="nv">$HOME</span>/certs:/usr/share/elasticsearch/config/certs <span class="se">\</span>
     docker.elastic.co/elasticsearch/elasticsearch:8.19.7 <span class="se">\</span>
     bin/elasticsearch-certutil cert <span class="nt">--silent</span> <span class="nt">--pem</span> <span class="se">\</span>
     <span class="nt">--ca-cert</span> /usr/share/elasticsearch/config/certs/ca.crt <span class="se">\</span>
     <span class="nt">--ca-key</span> /usr/share/elasticsearch/config/certs/ca.key <span class="se">\</span>
     <span class="nt">--dns</span> master-1,localhost <span class="se">\</span>
     <span class="nt">--ip</span> <span class="k">${</span><span class="nv">ES_NETWORK_HOST</span><span class="k">}</span>,127.0.0.1 <span class="se">\</span>
     <span class="nt">--out</span> /usr/share/elasticsearch/config/certs/node.zip
 <span class="nb">cd</span> <span class="nv">$HOME</span>/certs <span class="o">&amp;&amp;</span> unzip <span class="nt">-o</span> node.zip <span class="o">&amp;&amp;</span> <span class="nb">mv </span>instance/<span class="k">*</span> <span class="nb">.</span> <span class="o">&amp;&amp;</span> <span class="nb">rmdir </span>instance <span class="o">&amp;&amp;</span> <span class="nb">rm </span>node.zip
</code></pre></div>    </div>
  </li>
  <li>Create a custom <code class="language-plaintext highlighter-rouge">elasticsearch.yml</code> in your home directory with <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.19/important-settings.html">important settings</a>:
    <div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="na">cluster.name</span><span class="pi">:</span> <span class="s">my-elasticsearch-cluster</span>
 <span class="na">node.roles</span><span class="pi">:</span> <span class="s">master</span>
 <span class="na">node.name</span><span class="pi">:</span> <span class="s">master-1</span>
 <span class="na">network.host</span><span class="pi">:</span> <span class="s">0.0.0.0</span>
 <span class="na">discovery.seed_hosts</span><span class="pi">:</span> <span class="s">10.0.0.1,10.0.0.2,10.0.0.3</span>
 <span class="na">cluster.initial_master_nodes</span><span class="pi">:</span> <span class="s">master-1,master-2,master-3</span>
 <span class="na">xpack.security.enabled</span><span class="pi">:</span> <span class="no">true</span>
 <span class="na">xpack.security.transport.ssl.enabled</span><span class="pi">:</span> <span class="no">true</span>
 <span class="na">xpack.security.transport.ssl.verification_mode</span><span class="pi">:</span> <span class="s">certificate</span>
 <span class="na">xpack.security.transport.ssl.key</span><span class="pi">:</span> <span class="s">certs/instance.key</span>
 <span class="na">xpack.security.transport.ssl.certificate</span><span class="pi">:</span> <span class="s">certs/instance.crt</span>
 <span class="na">xpack.security.transport.ssl.certificate_authorities</span><span class="pi">:</span> <span class="s">certs/ca.crt</span>
</code></pre></div>    </div>
    <ul>
      <li>The configuration files should contain settings which are node-specific (such as <code class="language-plaintext highlighter-rouge">node.name</code> and paths), or settings which a node requires in order to be able to join a cluster (such as <code class="language-plaintext highlighter-rouge">cluster.name</code> and <code class="language-plaintext highlighter-rouge">network.host</code>), as <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.19/settings.html">most settings can be changed on a running cluster using the Cluster update settings API</a>.</li>
      <li><code class="language-plaintext highlighter-rouge">cluster.name</code>: A node can only join a cluster when it shares its <code class="language-plaintext highlighter-rouge">cluster.name</code> with all the other nodes in the cluster. The default name is <code class="language-plaintext highlighter-rouge">elasticsearch</code>, but you should change it to an appropriate name that describes the purpose of the cluster.</li>
      <li><code class="language-plaintext highlighter-rouge">node.roles</code>: We set the role to <code class="language-plaintext highlighter-rouge">master</code> to create a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.19/node-roles-overview.html#dedicated-master-node">dedicated master-eligible node</a>. This is the most reliable way to avoid overloading the master with other tasks.</li>
      <li><code class="language-plaintext highlighter-rouge">node.name</code>: Elasticsearch uses <code class="language-plaintext highlighter-rouge">node.name</code> as a human-readable identifier for a particular instance of Elasticsearch. The node name defaults to the hostname of the machine when Elasticsearch starts.</li>
      <li><code class="language-plaintext highlighter-rouge">network.host</code>: Sets the address of this node for both HTTP and transport traffic.</li>
      <li><code class="language-plaintext highlighter-rouge">discovery.seed_hosts</code>: When you want to form a cluster with nodes on other hosts, this setting provides a list of other nodes in the cluster that are master-eligible and likely to be live and contactable to seed the discovery process.</li>
      <li><code class="language-plaintext highlighter-rouge">cluster.initial_master_nodes</code>: A list of node names of master-eligible nodes whose votes are counted in the first election. Do not configure this setting on master-ineligible nodes or nodes joining an existing cluster.</li>
      <li><code class="language-plaintext highlighter-rouge">xpack.security.enabled</code>: Set <code class="language-plaintext highlighter-rouge">true</code> to install Elastic Agent for stack monitoring.</li>
      <li><code class="language-plaintext highlighter-rouge">xpack.security.transport.ssl.enabled</code>: Set <code class="language-plaintext highlighter-rouge">true</code> as <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.19/bootstrap-checks-xpack.html#bootstrap-checks-tls">transport SSL must be enabled if security is enabled</a>.</li>
      <li>For a full list of configuration options, refer to <a href="https://www.elastic.co/docs/reference/elasticsearch/configuration-reference">Elasticsearch configuration reference</a>.</li>
    </ul>
  </li>
  <li>Run Elasticsearch container:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>docker run <span class="nt">-d</span> <span class="se">\</span>
    <span class="nt">--restart</span> always <span class="se">\</span>
    <span class="nt">--group-add</span> 0 <span class="se">\</span>
    <span class="nt">--volume</span> <span class="nv">$HOME</span>/esdatadir:/usr/share/elasticsearch/data <span class="se">\</span>
    <span class="nt">--volume</span> <span class="nv">$HOME</span>/eslogsdir:/usr/share/elasticsearch/logs <span class="se">\</span>
    <span class="nt">--volume</span> <span class="nv">$HOME</span>/certs:/usr/share/elasticsearch/config/certs:ro <span class="se">\</span>
    <span class="nt">--ulimit</span> <span class="nv">nofile</span><span class="o">=</span>65535:65535 <span class="se">\</span>
    <span class="nt">--env</span> <span class="s2">"bootstrap.memory_lock=true"</span> <span class="nt">--ulimit</span> <span class="nv">memlock</span><span class="o">=</span><span class="nt">-1</span>:-1 <span class="se">\</span>
    <span class="nt">--publish-all</span> <span class="se">\</span>
    <span class="nt">--network</span> host <span class="se">\</span>
    <span class="nt">--volume</span> <span class="nv">$HOME</span>/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml <span class="se">\</span>
    <span class="nt">--env</span> <span class="nv">TZ</span><span class="o">=</span>Asia/Seoul <span class="se">\</span>
    <span class="nt">--env</span> <span class="nv">ELASTIC_PASSWORD</span><span class="o">=</span>elasticpassword <span class="se">\</span>
    <span class="nt">--name</span> elasticsearch <span class="se">\</span>
    docker.elastic.co/elasticsearch/elasticsearch:8.19.7
</code></pre></div>    </div>
    <ul>
      <li><code class="language-plaintext highlighter-rouge">--restart always</code>: to automatically restarts the container if it stops.</li>
      <li><a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-elasticsearch-docker-prod#_configuration_files_must_be_readable_by_the_elasticsearch_user"><code class="language-plaintext highlighter-rouge">--group-add 0</code></a>: to ensure that the user under which Elasticsearch is running has file permissions to <code class="language-plaintext highlighter-rouge">esdatadir</code>.</li>
      <li><code class="language-plaintext highlighter-rouge">--volume $HOME/...</code>: to persist data, logs, and certificates across container restarts.</li>
      <li><a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-elasticsearch-docker-prod#_increase_ulimits_for_nofile_and_nproc"><code class="language-plaintext highlighter-rouge">--ulimit nofile=65535:65535</code></a>: to increase the file descriptor limit required for production workloads.</li>
      <li><code class="language-plaintext highlighter-rouge">--publish-all</code>: to randomize published ports, which is <a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-elasticsearch-docker-prod#_randomize_published_ports">recommended for production clusters</a>.</li>
      <li><code class="language-plaintext highlighter-rouge">--network host</code>: to <a href="https://docs.docker.com/engine/network/drivers/host/">optimize performance</a>. The <code class="language-plaintext highlighter-rouge">host</code> network only works on Linux hosts and <code class="language-plaintext highlighter-rouge">--publish-all</code> option is ignored when it is enabled.</li>
      <li><code class="language-plaintext highlighter-rouge">--volume $HOME/...</code>: to bind-mount custom <code class="language-plaintext highlighter-rouge">elasticsearch.yml</code>, <a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-elasticsearch-docker-configure">the preferred approach for production setups</a>.</li>
      <li><a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-elasticsearch-docker-prod#_disable_swapping"><code class="language-plaintext highlighter-rouge">--env "bootstrap.memory_lock=true" --ulimit memlock=-1:-1</code></a>: to disable swapping for performance and node stability.</li>
      <li><code class="language-plaintext highlighter-rouge">--env TZ=Asia/Seoul</code>: Sets the container timezone (adjust as needed).</li>
      <li><code class="language-plaintext highlighter-rouge">ELASTIC_PASSWORD</code>: Replace it with a password for your production Elasticsearch cluster.</li>
      <li>Some setups use <code class="language-plaintext highlighter-rouge">ES_JAVA_OPTS</code>,  but this is <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.19/advanced-configuration.html">not recommended for production</a>. <a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-elasticsearch-docker-prod#docker-set-heap-size">Use the default Elasticsearch memory settings instead</a>.</li>
    </ul>
  </li>
</ol>

<p>At this stage, the cluster isn’t fully formed yet, so you’ll see a warning in the container logs like:</p>
<pre><code class="language-log">master not discovered yet, this node has not previously joined a bootstrapped cluster, and this node must discover master-eligible nodes [master-1, master-2, master-3] to bootstrap a cluster: ...
</code></pre>

<p class="notice--info">You won’t see this error if you’re using only a single master-eligible node.</p>

<p>This happens because we configured three initial master nodes—<code class="language-plaintext highlighter-rouge">master-1</code>, <code class="language-plaintext highlighter-rouge">master-2</code>, and <code class="language-plaintext highlighter-rouge">master-3</code>—but only <code class="language-plaintext highlighter-rouge">master-1</code> has joined the cluster so far. Elasticsearch is still waiting for the remaining master-eligible nodes before it can complete the bootstrap process.
To finish forming the cluster, you need to bring up and join the other master-eligible nodes (<code class="language-plaintext highlighter-rouge">master-2</code> and <code class="language-plaintext highlighter-rouge">master-3</code>) as well.</p>

<h4 id="joining-master-nodes-to-the-cluster">Joining Master Nodes to the Cluster</h4>

<ol>
  <li>Copy <code class="language-plaintext highlighter-rouge">certs/ca.crt</code> and <code class="language-plaintext highlighter-rouge">certs/ca.key</code> files to your local machine or another secure location. These will be reused when setting up the remaining nodes.</li>
  <li>Connect to <code class="language-plaintext highlighter-rouge">my-elasticsearch-node-master-2</code>:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> ssh my-elasticsearch-node-master-2
</code></pre></div>    </div>
  </li>
  <li>Copy <code class="language-plaintext highlighter-rouge">ca.crt</code> and <code class="language-plaintext highlighter-rouge">ca.key</code> into the home directory on this node.</li>
  <li>Install docker, set <code class="language-plaintext highlighter-rouge">vm.max_map_count</code>, prepare local directories, and generate node certificate:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="c"># install docker</span>
 <span class="nb">sudo </span>apt-get update
 <span class="nb">sudo </span>apt-get <span class="nb">install</span> <span class="nt">-y</span> ca-certificates curl unzip
 <span class="nb">sudo install</span> <span class="nt">-m</span> 0755 <span class="nt">-d</span> /etc/apt/keyrings
 <span class="nb">sudo </span>curl <span class="nt">-fsSL</span> https://download.docker.com/linux/ubuntu/gpg <span class="nt">-o</span> /etc/apt/keyrings/docker.asc
 <span class="nb">sudo chmod </span>a+r /etc/apt/keyrings/docker.asc
 <span class="nb">echo</span> <span class="se">\</span>
     <span class="s2">"deb [arch=</span><span class="si">$(</span>dpkg <span class="nt">--print-architecture</span><span class="si">)</span><span class="s2"> signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu </span><span class="se">\</span><span class="s2">
     </span><span class="si">$(</span><span class="nb">.</span> /etc/os-release <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"</span><span class="k">${</span><span class="nv">UBUNTU_CODENAME</span><span class="k">:-</span><span class="nv">$VERSION_CODENAME</span><span class="k">}</span><span class="s2">"</span><span class="si">)</span><span class="s2"> stable"</span> | <span class="se">\</span>
     <span class="nb">sudo tee</span> /etc/apt/sources.list.d/docker.list <span class="o">&gt;</span> /dev/null
 <span class="nb">sudo </span>apt-get update
 <span class="nb">sudo </span>apt-get <span class="nb">install</span> <span class="nt">-y</span> docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
 <span class="nb">sudo </span>usermod <span class="nt">-aG</span> docker <span class="k">${</span><span class="nv">USER</span><span class="k">}</span>

 <span class="c"># Set `vm.max_map_count` to at least 262144</span>
 <span class="nb">sudo </span>sysctl <span class="nt">-w</span> vm.max_map_count<span class="o">=</span>262144
 <span class="nb">echo</span> <span class="s2">"vm.max_map_count=262144"</span> | <span class="nb">sudo tee</span> <span class="nt">-a</span> /etc/sysctl.conf

 <span class="c"># Prepare a local directory for storing data and logs through a bind-mount</span>
 <span class="nb">mkdir </span>esdatadir <span class="o">&amp;&amp;</span> <span class="nb">chmod </span>g+rwx esdatadir <span class="o">&amp;&amp;</span> <span class="nb">sudo chgrp </span>0 esdatadir
 <span class="nb">mkdir </span>eslogsdir <span class="o">&amp;&amp;</span> <span class="nb">chmod </span>g+rwx eslogsdir <span class="o">&amp;&amp;</span> <span class="nb">sudo chgrp </span>0 eslogsdir

 <span class="c"># Prepare certificate directory</span>
 <span class="nb">mkdir </span>certs <span class="o">&amp;&amp;</span> <span class="nb">chmod </span>g+rwx certs <span class="o">&amp;&amp;</span> <span class="nb">sudo chgrp </span>0 certs

 <span class="nb">mv </span>ca.crt certs/
 <span class="nb">mv </span>ca.key certs/

 <span class="c"># Generate node certificate signed by CA</span>
 <span class="nv">ES_NETWORK_HOST</span><span class="o">=</span><span class="si">$(</span><span class="nb">hostname</span> <span class="nt">-I</span> | <span class="nb">awk</span> <span class="s1">'{print $1}'</span><span class="si">)</span>
 <span class="nb">sudo </span>docker run <span class="nt">--rm</span> <span class="se">\</span>
     <span class="nt">-v</span> <span class="nv">$HOME</span>/certs:/usr/share/elasticsearch/config/certs <span class="se">\</span>
     docker.elastic.co/elasticsearch/elasticsearch:8.19.7 <span class="se">\</span>
     bin/elasticsearch-certutil cert <span class="nt">--silent</span> <span class="nt">--pem</span> <span class="se">\</span>
     <span class="nt">--ca-cert</span> /usr/share/elasticsearch/config/certs/ca.crt <span class="se">\</span>
     <span class="nt">--ca-key</span> /usr/share/elasticsearch/config/certs/ca.key <span class="se">\</span>
     <span class="nt">--dns</span> master-2,localhost <span class="se">\</span>
     <span class="nt">--ip</span> <span class="k">${</span><span class="nv">ES_NETWORK_HOST</span><span class="k">}</span>,127.0.0.1 <span class="se">\</span>
     <span class="nt">--out</span> /usr/share/elasticsearch/config/certs/node.zip

 <span class="nb">cd</span> <span class="nv">$HOME</span>/certs <span class="o">&amp;&amp;</span> unzip <span class="nt">-o</span> node.zip <span class="o">&amp;&amp;</span> <span class="nb">mv </span>instance/<span class="k">*</span> <span class="nb">.</span> <span class="o">&amp;&amp;</span> <span class="nb">rmdir </span>instance <span class="o">&amp;&amp;</span> <span class="nb">rm </span>node.zip
</code></pre></div>    </div>
  </li>
  <li>Create a custom <code class="language-plaintext highlighter-rouge">elasticsearch.yml</code> in your home directory:
    <div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="na">cluster.name</span><span class="pi">:</span> <span class="s">my-elasticsearch-cluster</span>
 <span class="na">node.roles</span><span class="pi">:</span> <span class="s">master</span>
 <span class="na">node.name</span><span class="pi">:</span> <span class="s">master-2</span>
 <span class="na">network.host</span><span class="pi">:</span> <span class="s">0.0.0.0</span>
 <span class="na">discovery.seed_hosts</span><span class="pi">:</span> <span class="s">10.0.0.1,10.0.0.2,10.0.0.3</span>
 <span class="na">xpack.security.enabled</span><span class="pi">:</span> <span class="no">true</span>
 <span class="na">xpack.security.transport.ssl.enabled</span><span class="pi">:</span> <span class="no">true</span>
 <span class="na">xpack.security.transport.ssl.verification_mode</span><span class="pi">:</span> <span class="s">certificate</span>
 <span class="na">xpack.security.transport.ssl.key</span><span class="pi">:</span> <span class="s">certs/instance.key</span>
 <span class="na">xpack.security.transport.ssl.certificate</span><span class="pi">:</span> <span class="s">certs/instance.crt</span>
 <span class="na">xpack.security.transport.ssl.certificate_authorities</span><span class="pi">:</span> <span class="s">certs/ca.crt</span>
</code></pre></div>    </div>
    <ul>
      <li><code class="language-plaintext highlighter-rouge">node.name</code>: now set to <code class="language-plaintext highlighter-rouge">master-2</code></li>
    </ul>
  </li>
  <li>Run Elasticsearch container:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="nb">sudo </span>docker run <span class="nt">-d</span> <span class="se">\</span>
     <span class="nt">--restart</span> always <span class="se">\</span>
     <span class="nt">--group-add</span> 0 <span class="se">\</span>
     <span class="nt">--volume</span> <span class="nv">$HOME</span>/esdatadir:/usr/share/elasticsearch/data <span class="se">\</span>
     <span class="nt">--volume</span> <span class="nv">$HOME</span>/eslogsdir:/usr/share/elasticsearch/logs <span class="se">\</span>
     <span class="nt">--volume</span> <span class="nv">$HOME</span>/certs:/usr/share/elasticsearch/config/certs:ro <span class="se">\</span>
     <span class="nt">--ulimit</span> <span class="nv">nofile</span><span class="o">=</span>65535:65535 <span class="se">\</span>
     <span class="nt">--env</span> <span class="s2">"bootstrap.memory_lock=true"</span> <span class="nt">--ulimit</span> <span class="nv">memlock</span><span class="o">=</span><span class="nt">-1</span>:-1 <span class="se">\</span>
     <span class="nt">--publish-all</span> <span class="se">\</span>
     <span class="nt">--network</span> host <span class="se">\</span>
     <span class="nt">--volume</span> <span class="nv">$HOME</span>/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml <span class="se">\</span>
     <span class="nt">--env</span> <span class="nv">TZ</span><span class="o">=</span>Asia/Seoul <span class="se">\</span>
     <span class="nt">--env</span> <span class="nv">ELASTIC_PASSWORD</span><span class="o">=</span>elasticpassword <span class="se">\</span>
     <span class="nt">--name</span> elasticsearch <span class="se">\</span>
     docker.elastic.co/elasticsearch/elasticsearch:8.19.7
</code></pre></div>    </div>
    <ul>
      <li><code class="language-plaintext highlighter-rouge">--env TZ=Asia/Seoul</code>: Sets the container timezone (adjust as needed).</li>
      <li><code class="language-plaintext highlighter-rouge">ELASTIC_PASSWORD</code>: Replace it with a password for your production Elasticsearch cluster.</li>
    </ul>
  </li>
  <li>Connect to <code class="language-plaintext highlighter-rouge">my-elasticsearch-node-master-3</code> and repeat steps 13-16, making sure to set <code class="language-plaintext highlighter-rouge">node.name</code> to <code class="language-plaintext highlighter-rouge">master-3</code> in the <code class="language-plaintext highlighter-rouge">elasticsearch.yml</code> file.</li>
  <li>Verify that the master nodes have formed a cluster:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> curl <span class="nt">-u</span> elastic:elasticpassword <span class="s2">"http://localhost:9200/_cat/nodes?v&amp;s=name"</span>
</code></pre></div>    </div>
    <p>Output:</p>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> ip             heap.percent ram.percent cpu load_1m load_5m load_15m node.role master name
 10.0.0.1                 40          98   0    0.00    0.05     0.07 m         *      master-1
 10.0.0.2                 26          98   0    0.07    0.17     0.10 m         -      master-2
 10.0.0.3                 24          98   0    0.39    0.31     0.13 m         -      master-3
</code></pre></div>    </div>
  </li>
</ol>

<p>Since there are no data nodes to host shards yet, the cluster health will show up as <code class="language-plaintext highlighter-rouge">red</code>:</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl http://elastic:elasticpassword@localhost:9200/_cluster/health
</code></pre></div></div>
<p>Output:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"cluster_name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"my-elasticsearch-cluster"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"red"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"timed_out"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w">
    </span><span class="nl">"number_of_nodes"</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="p">,</span><span class="w">
    </span><span class="nl">"number_of_data_nodes"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
    </span><span class="nl">"active_primary_shards"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
    </span><span class="nl">"active_shards"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
    </span><span class="nl">"relocating_shards"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
    </span><span class="nl">"initializing_shards"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
    </span><span class="nl">"unassigned_shards"</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w">
    </span><span class="nl">"unassigned_primary_shards"</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w">
    </span><span class="nl">"delayed_unassigned_shards"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
    </span><span class="nl">"number_of_pending_tasks"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
    </span><span class="nl">"number_of_in_flight_fetch"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
    </span><span class="nl">"task_max_waiting_in_queue_millis"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
    </span><span class="nl">"active_shards_percent_as_number"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.0</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>This is expected.
Once the data nodes join the cluster, Elasticsearch will be able to allocate shards and the status should move to <code class="language-plaintext highlighter-rouge">yellow</code> or <code class="language-plaintext highlighter-rouge">green</code> depending on replica allocation.</p>

<h3 id="joining-data-nodes-to-the-cluster">Joining Data Nodes to the Cluster</h3>

<p>The process for adding a data node is very similar to adding a master node, with a few small changes in the <code class="language-plaintext highlighter-rouge">elasticsearch.yml</code> configuration.</p>

<ol>
  <li>Connect to <code class="language-plaintext highlighter-rouge">my-elasticsearch-node-data-1</code>:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> ssh my-elasticsearch-node-data-1
</code></pre></div>    </div>
  </li>
  <li>Copy <code class="language-plaintext highlighter-rouge">ca.crt</code> and <code class="language-plaintext highlighter-rouge">ca.key</code> into the home directory on this node.</li>
  <li>Install docker, set <code class="language-plaintext highlighter-rouge">vm.max_map_count</code>, prepare local directories, and generate node certificate:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="c"># install docker</span>
 <span class="nb">sudo </span>apt-get update
 <span class="nb">sudo </span>apt-get <span class="nb">install</span> <span class="nt">-y</span> ca-certificates curl unzip
 <span class="nb">sudo install</span> <span class="nt">-m</span> 0755 <span class="nt">-d</span> /etc/apt/keyrings
 <span class="nb">sudo </span>curl <span class="nt">-fsSL</span> https://download.docker.com/linux/ubuntu/gpg <span class="nt">-o</span> /etc/apt/keyrings/docker.asc
 <span class="nb">sudo chmod </span>a+r /etc/apt/keyrings/docker.asc
 <span class="nb">echo</span> <span class="se">\</span>
     <span class="s2">"deb [arch=</span><span class="si">$(</span>dpkg <span class="nt">--print-architecture</span><span class="si">)</span><span class="s2"> signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu </span><span class="se">\</span><span class="s2">
     </span><span class="si">$(</span><span class="nb">.</span> /etc/os-release <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"</span><span class="k">${</span><span class="nv">UBUNTU_CODENAME</span><span class="k">:-</span><span class="nv">$VERSION_CODENAME</span><span class="k">}</span><span class="s2">"</span><span class="si">)</span><span class="s2"> stable"</span> | <span class="se">\</span>
     <span class="nb">sudo tee</span> /etc/apt/sources.list.d/docker.list <span class="o">&gt;</span> /dev/null
 <span class="nb">sudo </span>apt-get update
 <span class="nb">sudo </span>apt-get <span class="nb">install</span> <span class="nt">-y</span> docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
 <span class="nb">sudo </span>usermod <span class="nt">-aG</span> docker <span class="k">${</span><span class="nv">USER</span><span class="k">}</span>

 <span class="c"># Set `vm.max_map_count` to at least 262144</span>
 <span class="nb">sudo </span>sysctl <span class="nt">-w</span> vm.max_map_count<span class="o">=</span>262144
 <span class="nb">echo</span> <span class="s2">"vm.max_map_count=262144"</span> | <span class="nb">sudo tee</span> <span class="nt">-a</span> /etc/sysctl.conf

 <span class="c"># Prepare a local directory for storing data and logs through a bind-mount</span>
 <span class="nb">mkdir </span>esdatadir <span class="o">&amp;&amp;</span> <span class="nb">chmod </span>g+rwx esdatadir <span class="o">&amp;&amp;</span> <span class="nb">sudo chgrp </span>0 esdatadir
 <span class="nb">mkdir </span>eslogsdir <span class="o">&amp;&amp;</span> <span class="nb">chmod </span>g+rwx eslogsdir <span class="o">&amp;&amp;</span> <span class="nb">sudo chgrp </span>0 eslogsdir

 <span class="c"># Prepare certificate directory</span>
 <span class="nb">mkdir </span>certs <span class="o">&amp;&amp;</span> <span class="nb">chmod </span>g+rwx certs <span class="o">&amp;&amp;</span> <span class="nb">sudo chgrp </span>0 certs

 <span class="nb">mv </span>ca.crt certs/
 <span class="nb">mv </span>ca.key certs/

 <span class="c"># Generate node certificate signed by CA</span>
 <span class="nv">ES_NETWORK_HOST</span><span class="o">=</span><span class="si">$(</span><span class="nb">hostname</span> <span class="nt">-I</span> | <span class="nb">awk</span> <span class="s1">'{print $1}'</span><span class="si">)</span>
 <span class="nb">sudo </span>docker run <span class="nt">--rm</span> <span class="se">\</span>
     <span class="nt">-v</span> <span class="nv">$HOME</span>/certs:/usr/share/elasticsearch/config/certs <span class="se">\</span>
     docker.elastic.co/elasticsearch/elasticsearch:8.19.7 <span class="se">\</span>
     bin/elasticsearch-certutil cert <span class="nt">--silent</span> <span class="nt">--pem</span> <span class="se">\</span>
     <span class="nt">--ca-cert</span> /usr/share/elasticsearch/config/certs/ca.crt <span class="se">\</span>
     <span class="nt">--ca-key</span> /usr/share/elasticsearch/config/certs/ca.key <span class="se">\</span>
     <span class="nt">--dns</span> data-1,localhost <span class="se">\</span>
     <span class="nt">--ip</span> <span class="k">${</span><span class="nv">ES_NETWORK_HOST</span><span class="k">}</span>,127.0.0.1 <span class="se">\</span>
     <span class="nt">--out</span> /usr/share/elasticsearch/config/certs/node.zip

 <span class="nb">cd</span> <span class="nv">$HOME</span>/certs <span class="o">&amp;&amp;</span> unzip <span class="nt">-o</span> node.zip <span class="o">&amp;&amp;</span> <span class="nb">mv </span>instance/<span class="k">*</span> <span class="nb">.</span> <span class="o">&amp;&amp;</span> <span class="nb">rmdir </span>instance <span class="o">&amp;&amp;</span> <span class="nb">rm </span>node.zip
</code></pre></div>    </div>
  </li>
  <li>Create a custom <code class="language-plaintext highlighter-rouge">elasticsearch.yml</code> in your home directory:
    <div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="na">cluster.name</span><span class="pi">:</span> <span class="s">my-elasticsearch-cluster</span>
 <span class="na">node.roles</span><span class="pi">:</span> <span class="s">data</span>
 <span class="na">node.name</span><span class="pi">:</span> <span class="s">data-1</span>
 <span class="na">network.host</span><span class="pi">:</span> <span class="s">0.0.0.0</span>
 <span class="na">discovery.seed_hosts</span><span class="pi">:</span> <span class="s">10.0.0.1,10.0.0.2,10.0.0.3</span>
 <span class="na">xpack.security.enabled</span><span class="pi">:</span> <span class="no">true</span>
 <span class="na">xpack.security.transport.ssl.enabled</span><span class="pi">:</span> <span class="no">true</span>
 <span class="na">xpack.security.transport.ssl.verification_mode</span><span class="pi">:</span> <span class="s">certificate</span>
 <span class="na">xpack.security.transport.ssl.key</span><span class="pi">:</span> <span class="s">certs/instance.key</span>
 <span class="na">xpack.security.transport.ssl.certificate</span><span class="pi">:</span> <span class="s">certs/instance.crt</span>
 <span class="na">xpack.security.transport.ssl.certificate_authorities</span><span class="pi">:</span> <span class="s">certs/ca.crt</span>
</code></pre></div>    </div>
    <ul>
      <li><code class="language-plaintext highlighter-rouge">node.roles</code>: now set to <code class="language-plaintext highlighter-rouge">data</code>.</li>
      <li><code class="language-plaintext highlighter-rouge">node.name</code>: now set to <code class="language-plaintext highlighter-rouge">data-1</code></li>
    </ul>
  </li>
  <li>Run Elasticsearch container:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="nb">sudo </span>docker run <span class="nt">-d</span> <span class="se">\</span>
     <span class="nt">--restart</span> always <span class="se">\</span>
     <span class="nt">--group-add</span> 0 <span class="se">\</span>
     <span class="nt">--volume</span> <span class="nv">$HOME</span>/esdatadir:/usr/share/elasticsearch/data <span class="se">\</span>
     <span class="nt">--volume</span> <span class="nv">$HOME</span>/eslogsdir:/usr/share/elasticsearch/logs <span class="se">\</span>
     <span class="nt">--volume</span> <span class="nv">$HOME</span>/certs:/usr/share/elasticsearch/config/certs:ro <span class="se">\</span>
     <span class="nt">--ulimit</span> <span class="nv">nofile</span><span class="o">=</span>65535:65535 <span class="se">\</span>
     <span class="nt">--env</span> <span class="s2">"bootstrap.memory_lock=true"</span> <span class="nt">--ulimit</span> <span class="nv">memlock</span><span class="o">=</span><span class="nt">-1</span>:-1 <span class="se">\</span>
     <span class="nt">--publish-all</span> <span class="se">\</span>
     <span class="nt">--network</span> host <span class="se">\</span>
     <span class="nt">--volume</span> <span class="nv">$HOME</span>/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml <span class="se">\</span>
     <span class="nt">--env</span> <span class="nv">TZ</span><span class="o">=</span>Asia/Seoul <span class="se">\</span>
     <span class="nt">--env</span> <span class="nv">ELASTIC_PASSWORD</span><span class="o">=</span>elasticpassword <span class="se">\</span>
     <span class="nt">--name</span> elasticsearch <span class="se">\</span>
     docker.elastic.co/elasticsearch/elasticsearch:8.19.7
</code></pre></div>    </div>
    <ul>
      <li><code class="language-plaintext highlighter-rouge">--env TZ=Asia/Seoul</code>: Sets the container timezone (adjust as needed).</li>
      <li><code class="language-plaintext highlighter-rouge">ELASTIC_PASSWORD</code>: Replace it with a password for your production Elasticsearch cluster.</li>
      <li>Joining the cluster can take a little time. Once a data node joins the cluster, the health status transitions from <code class="language-plaintext highlighter-rouge">red</code> to <code class="language-plaintext highlighter-rouge">green</code>:
        <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="w">  </span><span class="p">{</span><span class="w">
      </span><span class="nl">"cluster_name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"my-elasticsearch-cluster"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"green"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"timed_out"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w">
      </span><span class="nl">"number_of_nodes"</span><span class="p">:</span><span class="w"> </span><span class="mi">4</span><span class="p">,</span><span class="w">
      </span><span class="nl">"number_of_data_nodes"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
      </span><span class="nl">"active_primary_shards"</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="p">,</span><span class="w">
      </span><span class="nl">"active_shards"</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="p">,</span><span class="w">
      </span><span class="nl">"relocating_shards"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
      </span><span class="nl">"initializing_shards"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
      </span><span class="nl">"unassigned_shards"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
      </span><span class="nl">"unassigned_primary_shards"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
      </span><span class="nl">"delayed_unassigned_shards"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
      </span><span class="nl">"number_of_pending_tasks"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
      </span><span class="nl">"number_of_in_flight_fetch"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
      </span><span class="nl">"task_max_waiting_in_queue_millis"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
      </span><span class="nl">"active_shards_percent_as_number"</span><span class="p">:</span><span class="w"> </span><span class="mf">100.0</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span></code></pre></div>        </div>
      </li>
    </ul>
  </li>
  <li>Repeat steps 1-5 for <code class="language-plaintext highlighter-rouge">my-elasticsearch-node-data-2</code> and <code class="language-plaintext highlighter-rouge">my-elasticsearch-node-data-3</code>, updating <code class="language-plaintext highlighter-rouge">node.name</code> to match each node.</li>
  <li>From any node, verify that all nodes have successfully joined the cluster:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> curl <span class="nt">-u</span> elastic:elasticpassword <span class="s2">"http://localhost:9200/_cat/nodes?v&amp;s=name"</span>
</code></pre></div>    </div>
    <p>Output:</p>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> ip             heap.percent ram.percent cpu load_1m load_5m load_15m node.role master name
 10.0.1.1                 41          97   0    0.00    0.06     0.08 d         -      data-1
 10.0.1.2                 36          98   0    0.22    0.33     0.16 d         -      data-2
 10.0.1.3                 33          98   0    0.14    0.24     0.11 d         -      data-3
 10.0.0.1                  8          98   0    0.00    0.00     0.00 m         *      master-1
 10.0.0.2                 44          97   0    0.00    0.00     0.00 m         -      master-2
 10.0.0.3                 42          97   0    0.00    0.00     0.00 m         -      master-3
</code></pre></div>    </div>
  </li>
</ol>

<p>At this point, all master and data nodes should be present, and the cluster should be fully formed and healthy.</p>

<h3 id="updating-custom-elasticsearchyml">Updating custom <code class="language-plaintext highlighter-rouge">elasticsearch.yml</code></h3>

<p>Updating a custom <code class="language-plaintext highlighter-rouge">elasticsearch.yml</code> in a Docker-based setup is straightforward:</p>
<ol>
  <li>Make your changes in <code class="language-plaintext highlighter-rouge">elasticsearch.yml</code> in your home directory.</li>
  <li>Restart the container:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> docker restart elasticsearch
</code></pre></div>    </div>
  </li>
</ol>

<p>Be aware that restarting the container will cause downtime.
For production clusters, plan configuration changes carefully and apply them during a maintenance window, especially if the setting you’re changing affects cluster behavior or node roles.</p>

<h2 id="stack-monitoring">Stack Monitoring</h2>

<p>Stack Monitoring lets you collect logs and metrics from Elastic products, most importantly your production Elasticsearch nodes, and also things like Kibana.
At a minimum, you need monitoring data for the production Elasticsearch cluster.
<a href="https://www.elastic.co/guide/en/kibana/8.19/monitoring-data.html">Once that’s in place, Kibana can show monitoring data for other products in the Stack Monitoring page</a>.</p>

<p class="notice--info">The legacy Elasticsearch Monitoring plugin approach (enabled via <code class="language-plaintext highlighter-rouge">xpack.monitoring.collection.enabled</code>) was deprecated in 7.16.
These days, Elastic recommends using Elastic Agent or Metricbeat to collect and ship monitoring data to a monitoring cluster.</p>

<p>Next, we’ll deploy a single-node monitoring cluster and use Elastic Agent to monitor our production cluster.</p>

<h3 id="deploying-elasticsearch">Deploying Elasticsearch</h3>

<ol>
  <li>Connect to <code class="language-plaintext highlighter-rouge">my-elasticsearch-monitoring</code>
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> ssh my-elasticsearch-node-monitoring
</code></pre></div>    </div>
  </li>
  <li>Install docker, set <code class="language-plaintext highlighter-rouge">vm.max_map_count</code>, and prepare local directories for data persistency:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="c"># install docker</span>
 <span class="nb">sudo </span>apt-get update
 <span class="nb">sudo </span>apt-get <span class="nb">install</span> <span class="nt">-y</span> ca-certificates curl
 <span class="nb">sudo install</span> <span class="nt">-m</span> 0755 <span class="nt">-d</span> /etc/apt/keyrings
 <span class="nb">sudo </span>curl <span class="nt">-fsSL</span> https://download.docker.com/linux/ubuntu/gpg <span class="nt">-o</span> /etc/apt/keyrings/docker.asc
 <span class="nb">sudo chmod </span>a+r /etc/apt/keyrings/docker.asc
 <span class="nb">echo</span> <span class="se">\</span>
     <span class="s2">"deb [arch=</span><span class="si">$(</span>dpkg <span class="nt">--print-architecture</span><span class="si">)</span><span class="s2"> signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu </span><span class="se">\</span><span class="s2">
     </span><span class="si">$(</span><span class="nb">.</span> /etc/os-release <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"</span><span class="k">${</span><span class="nv">UBUNTU_CODENAME</span><span class="k">:-</span><span class="nv">$VERSION_CODENAME</span><span class="k">}</span><span class="s2">"</span><span class="si">)</span><span class="s2"> stable"</span> | <span class="se">\</span>
     <span class="nb">sudo tee</span> /etc/apt/sources.list.d/docker.list <span class="o">&gt;</span> /dev/null
 <span class="nb">sudo </span>apt-get update
 <span class="nb">sudo </span>apt-get <span class="nb">install</span> <span class="nt">-y</span> docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
 <span class="nb">sudo </span>usermod <span class="nt">-aG</span> docker <span class="k">${</span><span class="nv">USER</span><span class="k">}</span>

 <span class="c"># Set `vm.max_map_count` to at least 262144</span>
 <span class="nb">sudo </span>sysctl <span class="nt">-w</span> vm.max_map_count<span class="o">=</span>262144
 <span class="nb">echo</span> <span class="s2">"vm.max_map_count=262144"</span> | <span class="nb">sudo tee</span> <span class="nt">-a</span> /etc/sysctl.conf

 <span class="c"># Prepare a local directory for storing data and logs through a bind-mount</span>
 <span class="nb">mkdir </span>esdatadir <span class="o">&amp;&amp;</span> <span class="nb">chmod </span>g+rwx esdatadir <span class="o">&amp;&amp;</span> <span class="nb">sudo chgrp </span>0 esdatadir
 <span class="nb">mkdir </span>eslogsdir <span class="o">&amp;&amp;</span> <span class="nb">chmod </span>g+rwx eslogsdir <span class="o">&amp;&amp;</span> <span class="nb">sudo chgrp </span>0 eslogsdir
</code></pre></div>    </div>
  </li>
  <li>Create a custom <code class="language-plaintext highlighter-rouge">elasticsearch.yml</code> in your home directory:
    <div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="na">discovery.type</span><span class="pi">:</span> <span class="s">single-node</span>
 <span class="na">xpack.security.enabled</span><span class="pi">:</span> <span class="no">true</span>
 <span class="na">network.host</span><span class="pi">:</span> <span class="s">0.0.0.0</span>
</code></pre></div>    </div>
  </li>
  <li>Run Elasticsearch container:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="nb">sudo </span>docker run <span class="nt">-d</span> <span class="se">\</span>
     <span class="nt">--restart</span> always <span class="se">\</span>
     <span class="nt">--group-add</span> 0 <span class="se">\</span>
     <span class="nt">--volume</span> <span class="nv">$HOME</span>/esdatadir:/usr/share/elasticsearch/data <span class="se">\</span>
     <span class="nt">--volume</span> <span class="nv">$HOME</span>/eslogsdir:/usr/share/elasticsearch/logs <span class="se">\</span>
     <span class="nt">--ulimit</span> <span class="nv">nofile</span><span class="o">=</span>65535:65535 <span class="se">\</span>
     <span class="nt">--env</span> <span class="s2">"bootstrap.memory_lock=true"</span> <span class="nt">--ulimit</span> <span class="nv">memlock</span><span class="o">=</span><span class="nt">-1</span>:-1 <span class="se">\</span>
     <span class="nt">--publish-all</span> <span class="se">\</span>
     <span class="nt">--network</span> host <span class="se">\</span>
     <span class="nt">--volume</span> <span class="nv">$HOME</span>/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml <span class="se">\</span>
     <span class="nt">--env</span> <span class="nv">TZ</span><span class="o">=</span>Asia/Seoul <span class="se">\</span>
     <span class="nt">--env</span> <span class="nv">ELASTIC_PASSWORD</span><span class="o">=</span>elasticpassword <span class="se">\</span>
     <span class="nt">--name</span> elasticsearch <span class="se">\</span>
     docker.elastic.co/elasticsearch/elasticsearch:8.19.7
</code></pre></div>    </div>
    <ul>
      <li><code class="language-plaintext highlighter-rouge">--env TZ=Asia/Seoul</code>: Sets the container timezone (adjust as needed).</li>
      <li><code class="language-plaintext highlighter-rouge">ELASTIC_PASSWORD</code>: Replace it with a password for your monitoring Elasticsearch node.</li>
    </ul>
  </li>
  <li>Verify:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> curl <span class="nt">-u</span> elastic:elasticpassword <span class="s2">"http://localhost:9200/_cat/nodes?v&amp;s=name"</span>
</code></pre></div>    </div>
    <p>Output</p>
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> ip             heap.percent ram.percent cpu load_1m load_5m load_15m node.role   master name
 10.0.2.1                 22          97  44    0.92    0.31     0.11 cdfhilmrstw *      ip-10-0-2-1
</code></pre></div>    </div>
    <ul>
      <li>Note that the node is assigned <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.19/high-availability-cluster-small-clusters.html#high-availability-cluster-design-one-node">every role as it must do everything</a>.</li>
    </ul>
  </li>
</ol>

<h3 id="deploying-kibana">Deploying Kibana</h3>

<ol>
  <li>Set a password for <code class="language-plaintext highlighter-rouge">kibana_system</code> user:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> curl <span class="nt">-s</span> <span class="nt">-X</span> POST <span class="nt">-u</span> <span class="s2">"elastic:elasticpassword"</span> <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> http://localhost:9200/_security/user/kibana_system/_password <span class="nt">-d</span> <span class="s2">"{</span><span class="se">\"</span><span class="s2">password</span><span class="se">\"</span><span class="s2">:</span><span class="se">\"</span><span class="s2">kibanapassword</span><span class="se">\"</span><span class="s2">}"</span>
</code></pre></div>    </div>
    <ul>
      <li><code class="language-plaintext highlighter-rouge">kibanapassword</code>: Replace it with your password.</li>
    </ul>
  </li>
  <li>Create custom <code class="language-plaintext highlighter-rouge">kibana.yml</code>:
    <div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="na">server.host</span><span class="pi">:</span> <span class="s2">"</span><span class="s">0.0.0.0"</span>
 <span class="na">server.shutdownTimeout</span><span class="pi">:</span> <span class="s2">"</span><span class="s">5s"</span>
 <span class="na">elasticsearch.hosts</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">http://localhost:9200"</span><span class="pi">]</span>
 <span class="na">elasticsearch.username</span><span class="pi">:</span> <span class="s2">"</span><span class="s">kibana_system"</span>
 <span class="na">elasticsearch.password</span><span class="pi">:</span> <span class="s2">"</span><span class="s">kibanapassword"</span>
 <span class="na">xpack.encryptedSavedObjects.encryptionKey</span><span class="pi">:</span> <span class="s2">"</span><span class="s">d7x9s2k5v8y4b3m6n1q0w7e2r5t8y9u1"</span> <span class="c1"># Replace this with your own key. You can generate one using `./kibana-encryption-keys generate`.</span>
 <span class="na">server.name</span><span class="pi">:</span> <span class="s">kibana</span>
</code></pre></div>    </div>
    <ul>
      <li><code class="language-plaintext highlighter-rouge">elasticsearch.hosts</code>: Use <code class="language-plaintext highlighter-rouge">http://localhost:9200</code> since Elasticsearch is running on the same machine.</li>
    </ul>
  </li>
  <li>Run Kibana container:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="nb">sudo </span>docker run <span class="nt">-d</span> <span class="se">\</span>
     <span class="nt">--restart</span> always <span class="se">\</span>
     <span class="nt">--network</span> host <span class="se">\</span>
     <span class="nt">--env</span> <span class="nv">TZ</span><span class="o">=</span>Asia/Seoul <span class="se">\</span>
     <span class="nt">--volume</span> <span class="nv">$HOME</span>/kibana.yml:/usr/share/kibana/config/kibana.yml <span class="se">\</span>
     <span class="nt">--name</span> kibana <span class="se">\</span>
     docker.elastic.co/kibana/kibana:8.19.7
</code></pre></div>    </div>
  </li>
  <li>
    <p>Verify the deployment by opening Kibana (<code class="language-plaintext highlighter-rouge">http://10.0.2.1:5601</code> in this case) in your browser:</p>

    <p><img src="/assets/posts/39/kibana_login_page.png" alt="Kibana login page" /></p>
  </li>
</ol>

<h3 id="installing-elasticsearch-integration">Installing Elasticsearch Integration</h3>

<ol>
  <li><a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.19/configuring-elastic-agent.html#_prerequisites_11">Create a user on the production cluster that has the <code class="language-plaintext highlighter-rouge">remote_monitoring_collector</code> built-in role</a>.
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> curl <span class="nt">-X</span> POST <span class="s2">"http://localhost:9200/_security/user/elastic_agent?pretty"</span> <span class="se">\</span>
     <span class="nt">-u</span> elastic:elasticpassword <span class="se">\</span>
     <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
     <span class="nt">-d</span> <span class="s1">'{
         "password" : "changeme",
         "roles" : [ "remote_monitoring_collector"]
     }'</span>
</code></pre></div>    </div>
    <ul>
      <li><code class="language-plaintext highlighter-rouge">elastic_agent</code>: This is just a username for Elastic Agent. Feel free to use a different one.</li>
      <li><code class="language-plaintext highlighter-rouge">password</code>: Choose a secure password for this user.</li>
    </ul>
  </li>
  <li>Go to Kibana in your browser.</li>
  <li>Login with:
    <ul>
      <li>Username: <code class="language-plaintext highlighter-rouge">elastic</code></li>
      <li>Password: Your <code class="language-plaintext highlighter-rouge">ELASTIC_PASSWORD</code></li>
    </ul>
  </li>
  <li>Click <code class="language-plaintext highlighter-rouge">Add integrations</code>.</li>
  <li>
    <p>In the query bar, search for and select the <code class="language-plaintext highlighter-rouge">Elasticsearch</code> integration for Elastic Agent:</p>

    <p><img src="/assets/posts/39/integrations.png" alt="Integrations" /></p>
  </li>
  <li>Click <code class="language-plaintext highlighter-rouge">Add Elasticsearch</code>.</li>
  <li>Configure the integration name and optionally add a description. Make sure you configure all required settings:
    <ul>
      <li>Go to Metrics (Stack monitoring) -&gt; Change defaults:
        <ul>
          <li>Hosts: Specify data nodes, or <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.19/configuring-elastic-agent.html#_add_elasticsearch_monitoring_data">a load balancer that routes to master-ineligible nodes</a>:
            <ul>
              <li><code class="language-plaintext highlighter-rouge">http://10.0.1.1:9200</code></li>
              <li><code class="language-plaintext highlighter-rouge">http://10.0.1.2:9200</code></li>
              <li><code class="language-plaintext highlighter-rouge">http://10.0.1.3:9200</code></li>
            </ul>
          </li>
          <li>Advanced options:
            <ul>
              <li>Username: <code class="language-plaintext highlighter-rouge">elastic_agent</code></li>
              <li>Password: <code class="language-plaintext highlighter-rouge">changeme</code></li>
              <li>Scope: <code class="language-plaintext highlighter-rouge">cluster</code> (If set to <code class="language-plaintext highlighter-rouge">node</code>, Elastic Agent only collects metrics from the specified hosts.)</li>
            </ul>
          </li>
        </ul>
      </li>
    </ul>
  </li>
  <li>Click <code class="language-plaintext highlighter-rouge">Save and continue</code>. This may take a minute or two. When it finishes, an agent policy with the Elasticsearch monitoring integration will be created.</li>
  <li>Click <code class="language-plaintext highlighter-rouge">Add Elastic Agent later</code>.</li>
</ol>

<h3 id="deploying-elastic-agent">Deploying Elastic Agent</h3>

<p>There are two ways to run Elastic Agent:</p>

<ul>
  <li>Fleet-managed: Elastic Agent is centrally managed through Fleet in Kibana.</li>
  <li>Standalone: Elastic Agent is configured locally using YAML files.</li>
</ul>

<p>In this tutorial, we’ll use the standalone approach to avoid the extra dependency on Fleet.</p>

<ol>
  <li>Go to Kibana for the monitoring cluster in your browser.</li>
  <li>Go to <code class="language-plaintext highlighter-rouge">Fleet</code> -&gt; <code class="language-plaintext highlighter-rouge">Agents</code> -&gt; <code class="language-plaintext highlighter-rouge">Add Agent</code> -&gt; <code class="language-plaintext highlighter-rouge">Run standalone</code></li>
  <li>Select <code class="language-plaintext highlighter-rouge">Agent Policy 1</code>.</li>
  <li>Click <code class="language-plaintext highlighter-rouge">Create API key</code> to generate an API key for Elastic Agent.
    <ul>
      <li>By default, the output host is set to <code class="language-plaintext highlighter-rouge">http://localhost:9200</code>, which is where Elastic Agent will send monitoring data.</li>
    </ul>
  </li>
  <li>Click <code class="language-plaintext highlighter-rouge">Copy to clipboard</code> or <code class="language-plaintext highlighter-rouge">Download policy</code></li>
  <li>Connect to monitoring node:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> ssh my-elasticsearch-node-monitoring
</code></pre></div>    </div>
  </li>
  <li>Create an <code class="language-plaintext highlighter-rouge">elastic-agent.yml</code> file and paste the configuration from the clipboard, or copy the downloaded policy from step 5.</li>
  <li>Run Elastic Agent container:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code> docker run <span class="se">\</span>
     <span class="nt">-d</span> <span class="se">\</span>
     <span class="nt">--restart</span> always <span class="se">\</span>
     <span class="nt">--name</span> elastic-agent <span class="se">\</span>
     <span class="nt">--network</span> host <span class="se">\</span>
     <span class="nt">--user</span> root <span class="se">\</span>
     <span class="nt">--volume</span> <span class="nv">$HOME</span>/elastic-agent.yml:/usr/share/elastic-agent/elastic-agent.yml <span class="se">\</span>
     docker.elastic.co/beats/elastic-agent:8.19.7
</code></pre></div>    </div>
    <ul>
      <li><code class="language-plaintext highlighter-rouge">--network host</code>: Allows the agent to connect to Elasticsearch via <code class="language-plaintext highlighter-rouge">http://localhost:9200</code>.</li>
      <li>Startup usually takes a few seconds.</li>
    </ul>
  </li>
  <li>Go to Kibana for the monitoring cluster in your browser.</li>
  <li>Click <code class="language-plaintext highlighter-rouge">Stack Monitoring</code> in the left menu, then select <code class="language-plaintext highlighter-rouge">Remind me later</code> when prompted.</li>
</ol>

<p>Once everything is set up successfully, Elastic Agent will start collecting and shipping monitoring data from the production Elasticsearch cluster.
With this data in place, Kibana can display monitoring information for Elasticsearch and other Elastic Stack components:</p>

<p><img src="/assets/posts/39/stack_monitoring.png" alt="Stack Monitoring" /></p>

<p>Click <code class="language-plaintext highlighter-rouge">Nodes</code> for node monitoring:</p>

<p><img src="/assets/posts/39/nodes_monitoring.png" alt="Nodes monitoring" /></p>

<p>Click specific node for detailed metrics:</p>

<p><img src="/assets/posts/39/node_monitoring.png" alt="Node monitoring" /></p>

<p>So far, Elastic Agent runs on the same node as the monitoring Elasticsearch and Kibana, but it can be deployed on any other node as well.
To do that, update the <code class="language-plaintext highlighter-rouge">Outputs</code> in the agent policy:</p>

<ol>
  <li>Go to <code class="language-plaintext highlighter-rouge">Fleet</code> -&gt; <code class="language-plaintext highlighter-rouge">Settings</code> -&gt; <code class="language-plaintext highlighter-rouge">Outputs</code> -&gt; <code class="language-plaintext highlighter-rouge">Add output</code></li>
  <li>Set the monitoring Elasticsearch host to <code class="language-plaintext highlighter-rouge">http://10.0.2.1:9200</code>.</li>
</ol>

<p>Updating the output will result in an <code class="language-plaintext highlighter-rouge">elastic-agent.yml</code> similar to the following:</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">...</span>
<span class="na">outputs</span><span class="pi">:</span>
  <span class="na">default</span><span class="pi">:</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">elasticsearch</span>
    <span class="na">hosts</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">http://10.0.2.1:9200</span>
<span class="nn">...</span>
</code></pre></div></div>

<p>When new nodes are added to the production cluster, Elastic Agent will automatically start collecting their metrics without requiring any output changes.</p>

<p>In practice, however, it’s advisable to place <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.19/configuring-elastic-agent.html#_add_elasticsearch_monitoring_data">a load-balancing proxy in front of master-ineligible nodes</a>.
This distributes monitoring traffic evenly and prevents overloading master nodes, which <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.19/node-roles-overview.html#master-node-role">must remain stable</a>.</p>

<h2 id="creating-a-load-balancer">Creating a Load Balancer</h2>

<p>Below is an example of setting up a load balancer with a TLS listener that forwards traffic only to master-ineligible data nodes in your Elasticsearch cluster:</p>
<ol>
  <li>Create a target group:
    <ul>
      <li>Target type: <code class="language-plaintext highlighter-rouge">Instances</code></li>
      <li>Target group name: <code class="language-plaintext highlighter-rouge">my-elasticsearch</code></li>
      <li>Protocol: <code class="language-plaintext highlighter-rouge">TCP</code></li>
      <li>Port: <code class="language-plaintext highlighter-rouge">9200</code></li>
      <li>Health check protocol: <code class="language-plaintext highlighter-rouge">TCP</code></li>
      <li>Health check port: <code class="language-plaintext highlighter-rouge">Trafic port</code> (9200 in this case)</li>
      <li>Targets:
        <ul>
          <li><code class="language-plaintext highlighter-rouge">my-elasticsearch-node-data-1</code></li>
          <li><code class="language-plaintext highlighter-rouge">my-elasticsearch-node-data-2</code></li>
          <li><code class="language-plaintext highlighter-rouge">my-elasticsearch-node-data-3</code></li>
        </ul>
      </li>
    </ul>
  </li>
  <li>Create a networkd load balancer. This is a good choice for Elasticsearch because it has low latency and works well with TCP/TLS passthrough:
    <ul>
      <li>Load balancer name: <code class="language-plaintext highlighter-rouge">my-elasticsearch</code></li>
      <li>Scheme: <code class="language-plaintext highlighter-rouge">Internal</code> (recommended for private networks)</li>
      <li>VPC: (Use the same VPC as your Elasticsearch nodes)</li>
      <li>Security group: (Allow inbound traffic on TCP 443 from trusted sources)</li>
      <li>Listeners:
        <ul>
          <li>Protocol: <code class="language-plaintext highlighter-rouge">TLS</code></li>
          <li>Port: <code class="language-plaintext highlighter-rouge">443</code></li>
          <li>Target group: <code class="language-plaintext highlighter-rouge">my-elasticsearch</code></li>
          <li>Default SSL/TLS server certificate: (Your ACM or imported certificate)</li>
        </ul>
      </li>
      <li>With this setup, clients connect over HTTPS on port 443, and the NLB forwards traffic directly to port 9200 on the data nodes.</li>
    </ul>
  </li>
  <li>Create a DNS record (for example, in Route 53):
    <ul>
      <li>Use the hostname covered by your TLS certificate</li>
      <li>Route to the network load balancer</li>
    </ul>
  </li>
</ol>

<p>After this, clients can reach Elasticsearch using a simple HTTPS URL.
You can use this URL as the Elasticsearch hosts for Elastic Agent instead of listing the IP addresses of individual data nodes.
When you add or remove data nodes, the only thing you need to update is the target group.
The client configuration stays the same, and the load balancer automatically handles routing traffic to the available data nodes.</p>

<h2 id="shell-scripts-for-easier-deployment">Shell Scripts for Easier Deployment</h2>

<p>Often, we need to scale out the cluster to handle more traffic.
Manually repeating every step in this guide is daunting.</p>

<p>To make life easier, here are complete scripts you can use:</p>

<ul>
  <li>For initializing a fresh Elasticsearch cluster: <a href="/assets/posts/39/init_cluster.sh"><code class="language-plaintext highlighter-rouge">init_cluster.sh</code></a></li>
  <li>For joining nodes to an exisiting Elasticsearch cluster: <a href="/assets/posts/39/join_cluster.sh"><code class="language-plaintext highlighter-rouge">join_cluster.sh</code></a></li>
  <li>For initializing a single-node monitoring cluster: <a href="/assets/posts/39/init_monitoring_stack.sh"><code class="language-plaintext highlighter-rouge">init_monitoring_stack.sh</code></a></li>
</ul>

<p>Before running them, make sure you update these values to match your environment:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">VERSION</code></li>
  <li><code class="language-plaintext highlighter-rouge">Timezone</code></li>
  <li><code class="language-plaintext highlighter-rouge">ELASTIC_PASSWORD</code></li>
  <li><code class="language-plaintext highlighter-rouge">ca.crt</code> and <code class="language-plaintext highlighter-rouge">ca.key</code> files (refer to <code class="language-plaintext highlighter-rouge">REPLACE IT</code> in the <code class="language-plaintext highlighter-rouge">join_cluster.sh</code> file)</li>
</ul>

<h2 id="zone-awareness">Zone Awareness</h2>

<p>To improve resilience against infrastructure failures, it is highly recommended to deploy an Elasticsearch cluster across multiple availability zones.
However, simply placing nodes in multiple availability zones does not automatically make the cluster resilient.
The cluster operator must ensure that shards are distributed across zones so that, even if one or more zones fail, the remaining shard copies are still available.</p>

<p>Elasticsearch provides a convenient way to achieve this through <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.19/shard-allocation-awareness.html#forced-awareness">shard allocation awareness</a>.
The configuration is simple:</p>
<ol>
  <li>Add a custom node attribute to each node’s <code class="language-plaintext highlighter-rouge">elasticsearch.yml</code> file:
    <div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="na">node.attr.zone</span><span class="pi">:</span> <span class="s">ap-northeast-2a</span> <span class="c1"># For nodes in ap-northeast-2a</span>
</code></pre></div>    </div>
    <p>or</p>
    <div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="na">node.attr.zone</span><span class="pi">:</span> <span class="s">ap-northeast-2b</span> <span class="c1"># For nodes in ap-northeast-2b</span>
</code></pre></div>    </div>
  </li>
  <li>Tell Elasticsearch to allocate shards based on that awareness attribute by setting <code class="language-plaintext highlighter-rouge">cluster.routing.allocation.awareness.attributes</code> through the cluster update settings API:
    <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="w"> </span><span class="err">PUT</span><span class="w"> </span><span class="err">/_cluster/settings</span><span class="w">
 </span><span class="p">{</span><span class="w">
     </span><span class="nl">"persistent"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
         </span><span class="nl">"cluster.routing.allocation.awareness.attributes"</span><span class="p">:</span><span class="w"> </span><span class="s2">"zone"</span><span class="w">
     </span><span class="p">}</span><span class="w">
 </span><span class="p">}</span><span class="w">
</span></code></pre></div>    </div>

    <p class="notice--warning">Once setting is enabled, Elasticsearch starts relocating shards from nodes without the specified awareness attribute to nodes that have it.
 So, make sure all nodes have already joined the cluster with the appropriate custom attribute before enabling this setting.</p>
  </li>
</ol>

<p>Note that for a cluster with three master-eligible nodes, each node should be placed in a different availability zone.
If only two availability zones are used, quorum may be lost when the zone containing two master-eligible nodes fails.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Running a self-managed cluster means you own everything.
You operate it, you watch it, and you fix it when it breaks.
Scaling, monitoring, and compliance are all on you.
That work costs time and money, and there’s no way around that.</p>

<p>The upside is what you don’t have.
No extra control planes, no operators, no Kubernetes glue code deciding things on your behalf.
Systems like ECK (Elastic Cloud on Kubernetes) add layers, and layers add complexity.
Complexity creates edge cases, and edge cases could fail in unexpected ways.</p>

<p>If your environment can handle the operational load and you care about clarity, predictability, and knowing exactly how your system behaves, a self-managed cluster can be a good choice.</p>]]></content><author><name>Mienxiu</name></author><category term="aws" /><category term="docker" /><category term="elasticsearch" /><summary type="html"><![CDATA[Deploying a self-managed Elasticsearch cluster for production is a bit of pain. It’s not just about understanding the core components. You also have to manually configure availability and security. To do that, you end up jumping between many pages in the official documentation to piece everything together.]]></summary></entry><entry><title type="html">Systematic Evaluation for Information Retrieval: MAP and nDCG</title><link href="https://mienxiu.com/systematic-evaluation-for-ir-map-and-ndcg/" rel="alternate" type="text/html" title="Systematic Evaluation for Information Retrieval: MAP and nDCG" /><published>2025-09-10T00:00:00+00:00</published><updated>2025-09-10T00:00:00+00:00</updated><id>https://mienxiu.com/systematic-evaluation-for-ir-map-and-ndcg</id><content type="html" xml:base="https://mienxiu.com/systematic-evaluation-for-ir-map-and-ndcg/"><![CDATA[<p>The effectiveness (or accuracy) of an information retrieval system refers to how well it ranks relevant documents higher than non-relevant ones.
Evaluating this effectiveness is important because it allows us to compare different algorithms, helping researchers and developers figure out which ideas and methods actually work better.</p>

<p>So, how do we measure it?
Since information retrieval is fundamentally an empirical problem, its effectiveness has to be judged by users in some way.
One obvious way is to look at what real users do.
Metrics like click-through rate (CTR) and conversion rate fall into this category.
These are often called online metrics.
A/B testing is a popular way to compare different systems using this kind of data.</p>

<p>However, online experiments require deploying systems to actual users, which isn’t always practical or even possible in research settings.
This is where systematic approaches based on offline metrics come in.</p>

<script>
  MathJax = {
    output: {
      displayOverflow: 'scroll'
    },
    tex: {
      inlineMath: {'[+]': [['$', '$']]}
    }
  };
</script>

<script id="MathJax-script" async="" src="https://cdn.jsdelivr.net/npm/mathjax@4/tex-mml-chtml.js"></script>

<h2 id="cranfield-evaluation-methodology">Cranfield Evaluation Methodology</h2>

<p>Cranfield evaluation methodology is a framework for systematically evaluating the effectiveness of information retrieval systems, developed in the 1960s by Cyril Cleverdon at Cranfield University.</p>

<p>This methodology is built upon three key components:</p>
<ul>
  <li>A collection of documents: a standardized set of documents to search against.</li>
  <li>A set of sample queries: representative queries that simulate user needs.</li>
  <li>Human-judged relevance judgments: a definitive list of which documents are considered relevant for each query.</li>
</ul>

<p>Note that relevance judgments is by far the most important and the most labor-intensive part of the evaluation since they rely on human assessors.
A common strategy to manage this is pooling, where assessors only judge a subset of results pulled together from several existing systems.
However, this approach can introduce selection bias.
If a new system retrieves relevant documents that never made it into the pool, those documents remain unjudged, and the system ends up being unfairly penalized.</p>

<p>Once we have all three components, the question becomes: how do we quantify the effectiveness?</p>

<h3 id="precision-and-recall">Precision and Recall</h3>

<p>Precision and recall are core metrics to quantify the effectiveness:</p>
<ul>
  <li>Precision: Of all the documents the system retrieved, how many were actually relevant?</li>
  <li>Recall: Of all the relevant documents, how many did the system retrieve?</li>
</ul>

<p>These concepts can be illustrated using a confusion matrix:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th style="text-align: center">Retrieved</th>
      <th style="text-align: center">Not Retrieved</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Relevant</td>
      <td style="text-align: center">True Positive</td>
      <td style="text-align: center">False Negative</td>
    </tr>
    <tr>
      <td>Not Relevant</td>
      <td style="text-align: center">False Positive</td>
      <td style="text-align: center">True Negative</td>
    </tr>
  </tbody>
</table>

<p>From this matrix, precision and recall are defined as follows:</p>
<ul>
  <li>$Precision = \frac{TP}{TP + FP} = \frac{\text{Number of relevant retrieved}}{\text{Total number of retrieved}}$</li>
  <li>$Recall = \frac{TP}{TP + FN} = \frac{\text{Number of relevant retrieved}}{\text{Total number of relevant}}$</li>
</ul>

<p>Here’s an example.</p>

<p>Suppose we have a small test collection of 10 documents about software:</p>

<table>
  <thead>
    <tr>
      <th>Document ID</th>
      <th>Title</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>d1</td>
      <td>Mastering Design Patterns in Python</td>
    </tr>
    <tr>
      <td>d2</td>
      <td>Writing Unit Tests with pytest</td>
    </tr>
    <tr>
      <td>d3</td>
      <td>Introduction to TypeScript</td>
    </tr>
    <tr>
      <td>d4</td>
      <td>Refactoring Legacy Codebases</td>
    </tr>
    <tr>
      <td>d5</td>
      <td>The Practical Test Pyramid</td>
    </tr>
    <tr>
      <td>d6</td>
      <td>Code Review Best Practices</td>
    </tr>
    <tr>
      <td>d7</td>
      <td>SQL Optimization Techniques</td>
    </tr>
    <tr>
      <td>d8</td>
      <td>Static Analysis with ruff</td>
    </tr>
    <tr>
      <td>d9</td>
      <td>Principles of Agile Development</td>
    </tr>
    <tr>
      <td>d10</td>
      <td>Test-Driven Development Explained</td>
    </tr>
  </tbody>
</table>

<p>Our query is “software testing practices”</p>

<p>A human evaluator goes through the entire collection and determines which documents are relevant to the query:</p>

<table>
  <thead>
    <tr>
      <th>Document ID</th>
      <th>Title</th>
      <th>Relevant</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>d1</td>
      <td>Mastering Design Patterns in Python</td>
      <td>0</td>
    </tr>
    <tr>
      <td>d2</td>
      <td>Writing Unit Tests with pytest</td>
      <td>1</td>
    </tr>
    <tr>
      <td>d3</td>
      <td>Introduction to TypeScript</td>
      <td>0</td>
    </tr>
    <tr>
      <td>d4</td>
      <td>Refactoring Legacy Codebases</td>
      <td>0</td>
    </tr>
    <tr>
      <td>d5</td>
      <td>The Practical Test Pyramid</td>
      <td>1</td>
    </tr>
    <tr>
      <td>d6</td>
      <td>Code Review Best Practices</td>
      <td>1</td>
    </tr>
    <tr>
      <td>d7</td>
      <td>SQL Optimization Techniques</td>
      <td>0</td>
    </tr>
    <tr>
      <td>d8</td>
      <td>Static Analysis with ruff</td>
      <td>1</td>
    </tr>
    <tr>
      <td>d9</td>
      <td>Principles of Agile Development</td>
      <td>0</td>
    </tr>
    <tr>
      <td>d10</td>
      <td>Test-Driven Development Explained</td>
      <td>1</td>
    </tr>
  </tbody>
</table>

<p>So, there are a total of 5 relevant documents in the collection.</p>

<p>Now, let’s say our retrieval system returns these 4 documents for the query:</p>

<ul>
  <li>d2: Writing Unit Tests with pytest</li>
  <li>d5: The Practical Test Pyramid</li>
  <li>d9: Principles of Agile Development</li>
  <li>d10: Test-Driven Development Explained</li>
</ul>

<p>Out of these 4, three are actually relevant (d2, d5, d10), while one (d9) is a false alarm.
So:</p>
<ul>
  <li>Precision = 3 / 4 = 0.75</li>
  <li>Recall = 3 / 5 = 0.6</li>
</ul>

<p>In Python:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">retrieved_docs</span> <span class="o">=</span> <span class="p">[</span><span class="s">"d2"</span><span class="p">,</span> <span class="s">"d5"</span><span class="p">,</span> <span class="s">"d9"</span><span class="p">,</span> <span class="s">"d10"</span><span class="p">]</span>
<span class="n">relevant_docs</span> <span class="o">=</span> <span class="p">[</span><span class="s">"d2"</span><span class="p">,</span> <span class="s">"d5"</span><span class="p">,</span> <span class="s">"d6"</span><span class="p">,</span> <span class="s">"d8"</span><span class="p">,</span> <span class="s">"d10"</span><span class="p">]</span>


<span class="k">def</span> <span class="nf">get_precision</span><span class="p">(</span><span class="n">retrieved</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">],</span> <span class="n">relevant</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
    <span class="n">true_positives</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="nb">set</span><span class="p">(</span><span class="n">retrieved</span><span class="p">)</span> <span class="o">&amp;</span> <span class="nb">set</span><span class="p">(</span><span class="n">relevant</span><span class="p">))</span>
    <span class="n">false_positives</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="nb">set</span><span class="p">(</span><span class="n">retrieved</span><span class="p">)</span> <span class="o">-</span> <span class="nb">set</span><span class="p">(</span><span class="n">relevant</span><span class="p">))</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">true_positives</span> <span class="o">+</span> <span class="n">false_positives</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">true_positives</span> <span class="o">/</span> <span class="p">(</span><span class="n">true_positives</span> <span class="o">+</span> <span class="n">false_positives</span><span class="p">)</span>
    <span class="k">return</span> <span class="mi">0</span>


<span class="k">def</span> <span class="nf">get_recall</span><span class="p">(</span><span class="n">retrieved</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">],</span> <span class="n">relevant</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
    <span class="n">true_positives</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="nb">set</span><span class="p">(</span><span class="n">retrieved</span><span class="p">)</span> <span class="o">&amp;</span> <span class="nb">set</span><span class="p">(</span><span class="n">relevant</span><span class="p">))</span>
    <span class="n">false_negatives</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="nb">set</span><span class="p">(</span><span class="n">relevant</span><span class="p">)</span> <span class="o">-</span> <span class="nb">set</span><span class="p">(</span><span class="n">retrieved</span><span class="p">))</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">true_positives</span> <span class="o">+</span> <span class="n">false_negatives</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">true_positives</span> <span class="o">/</span> <span class="p">(</span><span class="n">true_positives</span> <span class="o">+</span> <span class="n">false_negatives</span><span class="p">)</span>
    <span class="k">return</span> <span class="mi">0</span>

<span class="n">precision</span> <span class="o">=</span> <span class="n">get_precision</span><span class="p">(</span><span class="n">retrieved_docs</span><span class="p">,</span> <span class="n">relevant_docs</span><span class="p">)</span>
<span class="n">recall</span> <span class="o">=</span> <span class="n">get_recall</span><span class="p">(</span><span class="n">retrieved_docs</span><span class="p">,</span> <span class="n">relevant_docs</span><span class="p">)</span>

<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Precision: </span><span class="si">{</span><span class="n">precision</span><span class="si">:</span><span class="p">.</span><span class="mi">2</span><span class="n">f</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Recall: </span><span class="si">{</span><span class="n">recall</span><span class="si">:</span><span class="p">.</span><span class="mi">2</span><span class="n">f</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div></div>

<p>Output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Precision: 0.75
Recall: 0.60
</code></pre></div></div>

<p>In practice, we often use a <em>cutoff</em> at a fixed number of top-ranked results.
For example:</p>
<ul>
  <li>Precision@5 (P@5): The precision when considering only the top 5 retrieved documents.</li>
  <li>Recall@10 (R@10): The recall when considering only the top 10 results.</li>
</ul>

<p>The ideal system would achieve a precision and recall of 1, meaning it retrieved all relevant documents and no non-relevant ones.
However, these two metrics has a classic trade-off: increasing recall by retrieving more documents often leads to a decrease in precision, and vice-versa.
To balance the two, we use the F-measure.</p>

<h2 id="f-measure">F-Measure</h2>

<p>F-measure (or F-score) is a way to combine precision and recall into a single score.
Instead of just simply averaging them like (P+R)/2, it ensures that both precision and recall matter.
The formula is:</p>

\[F_{\beta} = \frac{(\beta^2 + 1) \times \text{P} \times \text{R}}{(\beta^2 \times \text{P}) + \text{R}}\]

<p>Here, the parameter $\beta$ lets you put more weight on recall (if $\beta&gt;1$) or on precision (if $\beta&lt;1$).</p>

<p>The most common variant is the F1-measure, which calculates the harmonic mean of the two:</p>

\[F_{1} = \frac{2 \times \text{P} \times \text{R}}{\text{P} + \text{R}}\]

<p>In Python:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">get_f_score</span><span class="p">(</span><span class="n">precision</span><span class="p">:</span> <span class="nb">float</span><span class="p">,</span> <span class="n">recall</span><span class="p">:</span> <span class="nb">float</span><span class="p">,</span> <span class="n">beta</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">1.0</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">precision</span> <span class="o">+</span> <span class="n">recall</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">:</span>
        <span class="k">return</span> <span class="p">(</span><span class="n">beta</span><span class="o">**</span><span class="mi">2</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span> <span class="o">*</span> <span class="n">precision</span> <span class="o">*</span> <span class="n">recall</span> <span class="o">/</span> <span class="p">(</span><span class="n">beta</span><span class="o">**</span><span class="mi">2</span> <span class="o">*</span> <span class="n">precision</span> <span class="o">+</span> <span class="n">recall</span><span class="p">)</span>
    <span class="k">return</span> <span class="mi">0</span>
</code></pre></div></div>

<p>With F1-measure, a system cannot achieve a high F1-score without having both reasonably high precision and high recall.
For example,</p>

<table>
  <thead>
    <tr>
      <th>Precision</th>
      <th>Recall</th>
      <th>$F_{1}$-score</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0.5</td>
      <td>0.5</td>
      <td>0.5</td>
    </tr>
    <tr>
      <td>0.9</td>
      <td>0.1</td>
      <td>0.18</td>
    </tr>
  </tbody>
</table>

<p>Notice how the last row shows the penalty in action: even though precision is high (0.9), the low recall (0.1) drags the F1-score way down.</p>

<h3 id="limitation">Limitation</h3>

<p>The main drawback of F-measure is that it’s set-based so it ignores the order of results.
A system that ranks the most relevant document at the top gets the same score as one that hides it at the last, even though the user experience is completely different.</p>

<p>To evaluate a ranked list, we need to consider the order of the results.</p>

<h2 id="precision-recall-pr-curve">Precision-Recall (PR) Curve</h2>

<p>The Precision–Recall (PR) curve shows how well a retrieval system balances precision and recall as you move down a ranked list of results.
In simple terms, it helps you see whether the relevant documents are concentrated near the top (good) or scattered deeper in the list (not good).</p>

<p>We create the curve by plotting precision against recall at every rank position $k$.</p>

<p>Let’s walk through an example.</p>

<p>Suppose there are 8 relevant documents in total for a query, and our system manages to retrieve 6 of them within the top 10 results ($k$ = 10):</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Relevant</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>d1</td>
      <td>1</td>
    </tr>
    <tr>
      <td>d2</td>
      <td>1</td>
    </tr>
    <tr>
      <td>d3</td>
      <td>1</td>
    </tr>
    <tr>
      <td>d4</td>
      <td>0</td>
    </tr>
    <tr>
      <td>d5</td>
      <td>1</td>
    </tr>
    <tr>
      <td>d6</td>
      <td>1</td>
    </tr>
    <tr>
      <td>d7</td>
      <td>0</td>
    </tr>
    <tr>
      <td>d8</td>
      <td>1</td>
    </tr>
    <tr>
      <td>d9</td>
      <td>0</td>
    </tr>
    <tr>
      <td>d10</td>
      <td>0</td>
    </tr>
  </tbody>
</table>

<p>Now, to build the PR curve, we calculate precision and recall at each step:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Relevant</th>
      <th>Cumulative Relevant</th>
      <th>Precision</th>
      <th>Recall</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>d1</td>
      <td>1</td>
      <td>1</td>
      <td>1/1</td>
      <td>1/8</td>
    </tr>
    <tr>
      <td>d2</td>
      <td>1</td>
      <td>2</td>
      <td>2/2</td>
      <td>2/8</td>
    </tr>
    <tr>
      <td>d3</td>
      <td>1</td>
      <td>3</td>
      <td>3/3</td>
      <td>3/8</td>
    </tr>
    <tr>
      <td>d4</td>
      <td>0</td>
      <td>3</td>
      <td>3/4</td>
      <td>3/8</td>
    </tr>
    <tr>
      <td>d5</td>
      <td>1</td>
      <td>4</td>
      <td>4/5</td>
      <td>4/8</td>
    </tr>
    <tr>
      <td>d6</td>
      <td>1</td>
      <td>5</td>
      <td>5/6</td>
      <td>5/8</td>
    </tr>
    <tr>
      <td>d7</td>
      <td>0</td>
      <td>5</td>
      <td>5/7</td>
      <td>5/8</td>
    </tr>
    <tr>
      <td>d8</td>
      <td>1</td>
      <td>6</td>
      <td>6/8</td>
      <td>6/8</td>
    </tr>
    <tr>
      <td>d9</td>
      <td>0</td>
      <td>6</td>
      <td>6/9</td>
      <td>6/8</td>
    </tr>
    <tr>
      <td>d10</td>
      <td>0</td>
      <td>6</td>
      <td>6/10</td>
      <td>6/8</td>
    </tr>
  </tbody>
</table>

<p>When plotting the curve, we usually only take the points where a new relevant document appears so that the curve look smoother.
That gives us the following (recall on the x-axis, precision on the y-axis):</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Precision</th>
      <th>Recall</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>d1</td>
      <td>1/1</td>
      <td>1/8</td>
    </tr>
    <tr>
      <td>d2</td>
      <td>2/2</td>
      <td>2/8</td>
    </tr>
    <tr>
      <td>d3</td>
      <td>3/3</td>
      <td>3/8</td>
    </tr>
    <tr>
      <td>d5</td>
      <td>4/5</td>
      <td>4/8</td>
    </tr>
    <tr>
      <td>d6</td>
      <td>5/6</td>
      <td>5/8</td>
    </tr>
    <tr>
      <td>d8</td>
      <td>6/8</td>
      <td>6/8</td>
    </tr>
  </tbody>
</table>

<p>And visually:</p>

<p><img src="/assets/posts/38/pr_curve.png" alt="PR curve" /></p>

<p>The curve makes it clear how front-loaded the relevant results are.
If the curve stays high, the system surfaces relevant docs early.
If it drops quickly, it means users would have to dig deeper before finding what they’re looking for.</p>

<p>A system whose curve is higher and more to the right is better.
Therefore, an ideal system would have a constant precision of 1.0 for all recall levels.</p>

<p><img src="/assets/posts/38/ideal_pr_curve.png" alt="Ideal PR curve" /></p>

<p>PR curves are especially useful when comparing multiple retrieval systems.
Take the example below:</p>

<p><img src="/assets/posts/38/comparing_pr_curves.png" alt="Comparing PR curves" /></p>

<p>As System A’s curve is consistently above System B’s, A is clearly superior—it delivers better precision at every level of recall.</p>

<p>However, things get trickier when the curves cross.
Take the example below:</p>

<p><img src="/assets/posts/38/intersecting_pr_curves.png" alt="Intersecting PR curves" /></p>

<p>In this scenario, system A may have higher precision at low recall levels, while system B has higher precision at high recall levels.
The better system depends on the user’s task.
For example, a user doing a quick search might prefer System A’s high initial precision, whereas a researcher needing comprehensive results would prefer System B’s high recall.</p>

<h2 id="mean-average-precision-map">Mean Average Precision (MAP)</h2>

<h3 id="average-precision-ap">Average Precision (AP)</h3>

<p>When PR curves intersect, it’s useful to summarize performance with a single number.
Average Precision (AP) does this by averaging the precision values at the ranks where relevant documents appear:</p>

\[\text{AP}=\frac{1}{R}\sum_{k=i}^{N}P(k)Rel(k)\]

<p>Where:</p>
<ul>
  <li>$R$ is the total number of relevant documents for the query.</li>
  <li>$N$ is the total number of retrieved documents.</li>
  <li>$P(k)$ is the precision at the rank $k$.</li>
  <li>$Rel(k)$ is whether the document at rank $k$ is relevant (1) or not (0).</li>
</ul>

<p class="notice--warning">$R$ is NOT the number of retrieved relevant documents.
If you were to divide by the number of retrieved relevant documents instead, you’d give an unfair advantage to a system that retrieves only a few documents.
This would make the score look artificially high even if the system hardly retrieves anything useful.</p>

<p>AP is rank-sensitive: moving a relevant document higher in the list increases the score, while pushing it down lowers it.
You can also think of it as a practical approximation of the area under the PR curve.</p>

<p>Let’s compute it for our example.
We’ll take the precision at every rank where a relevant document shows up (d1, d2, d3, d5, d6, d8) and average them:</p>

<ul>
  <li>Precision@d1 = 1/1</li>
  <li>Precision@d2 = 2/2</li>
  <li>Precision@d3 = 3/3</li>
  <li>Precision@d5 = 4/5</li>
  <li>Precision@d6 = 5/6</li>
  <li>Precision@d8 = 6/8</li>
</ul>

<p>Now average these values:</p>

\[AP = \frac{\frac{1}{1} + \frac{2}{2} + \frac{3}{3} + \frac{4}{5} + \frac{5}{6} + \frac{6}{8}}{8} \;\approx\; 0.67\]

<p>In Python:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">get_average_precision</span><span class="p">(</span><span class="n">relevances</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">int</span><span class="p">],</span> <span class="n">num_relevant</span><span class="p">:</span> <span class="nb">int</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
    <span class="s">"""
    Compute Average Precision (AP) for a single query.
    """</span>
    <span class="k">if</span> <span class="n">num_relevant</span> <span class="o">==</span> <span class="mi">0</span><span class="p">:</span>
        <span class="k">return</span> <span class="mf">0.0</span>  <span class="c1"># edge case: no relevant docs for this query
</span>
    <span class="n">precisions</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="n">relevant_found</span> <span class="o">=</span> <span class="mi">0</span>
    <span class="k">for</span> <span class="n">k</span><span class="p">,</span> <span class="n">rel</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">relevances</span><span class="p">,</span> <span class="n">start</span><span class="o">=</span><span class="mi">1</span><span class="p">):</span>  <span class="c1"># rank positions start at 1
</span>        <span class="k">if</span> <span class="n">rel</span> <span class="o">==</span> <span class="mi">1</span><span class="p">:</span>
            <span class="n">relevant_found</span> <span class="o">+=</span> <span class="mi">1</span>
            <span class="n">precision_at_k</span> <span class="o">=</span> <span class="n">relevant_found</span> <span class="o">/</span> <span class="n">k</span>
            <span class="n">precisions</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">precision_at_k</span><span class="p">)</span>

    <span class="k">return</span> <span class="nb">sum</span><span class="p">(</span><span class="n">precisions</span><span class="p">)</span> <span class="o">/</span> <span class="n">num_relevant</span>


<span class="c1"># Example usage
</span><span class="n">relevances</span> <span class="o">=</span> <span class="p">[</span><span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">]</span>  <span class="c1"># system output (10 results)
</span><span class="n">num_relevant</span> <span class="o">=</span> <span class="mi">8</span>  <span class="c1"># total relevant docs
</span>
<span class="n">AP</span> <span class="o">=</span> <span class="n">get_average_precision</span><span class="p">(</span><span class="n">docs</span><span class="p">,</span> <span class="n">num_relevant</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="s">"Average Precision:"</span><span class="p">,</span> <span class="n">AP</span><span class="p">)</span>
</code></pre></div></div>

<p>Average Precision is great for judging performance on a single query, but it only tells part of the story.
To really understand how well a system works, we need to evaluate it across many queries and then aggregate the results.
That way, the score reflects overall performance rather than just one example.</p>

<h3 id="mean-average-precision-map-1">Mean Average Precision (MAP)</h3>

<p>Mean Average Precision (MAP) is simply the mean of the Average Precision (AP) scores across all queries in the test set:</p>

\[\text{MAP}=\frac{1}{Q}\sum_{q=1}^{Q}\text{AP}(q)\]

<p>Where $Q$ is the total number of queries.</p>

<p>It provides a single, comprehensive number that summarizes the system’s ranking quality.</p>

<p>An alternative is the Geometric Mean Average Precision (gMAP), which uses a geometric mean instead of an arithmetic one.
The choice between MAP and gMAP depends on the evaluation goal:</p>
<ul>
  <li>MAP (Arithmetic Mean): The final score is more heavily influenced by high AP scores. This means MAP gives more weight to performance on “easy” queries where the system does well. It is useful if the goal is to measure overall performance, particularly on popular queries that are often easier.</li>
  <li>gMAP (Geometric Mean): This average is more sensitive to low AP scores. Therefore, gMAP emphasizes performance on “difficult” queries where the system struggles. It is useful if the goal is to improve the system’s performance on its weakest queries.</li>
</ul>

<p>Here’s an example.
Suppose we evaluate three queries and get the following AP scores:</p>

<ul>
  <li>Query 1: 1.0</li>
  <li>Query 2: 0.5</li>
  <li>Query 3: 0.1</li>
</ul>

<p>Then:</p>
<ul>
  <li>MAP = $\frac{1.0 + 0.5 + 0.1}{3} \approx 0.53$</li>
  <li>gMAP = $(1.0 \times 0.5 \times 0.1)^{1/3} \approx 0.37$</li>
</ul>

<p>Here, MAP emphasizes the strong performance on Query 1, while gMAP penalizes the weak score on Query 3 more heavily.</p>

<h3 id="mean-reciprocal-rank-mrr">Mean Reciprocal Rank (MRR)</h3>

<p>For certain search tasks, there is only one correct or relevant document.
This occurs in “known-item searches,” where a user wants to find a single, specific page (e.g., the Youtube homepage), or in some question-answering tasks where there is only one right answer.</p>

<p>In this scenario, the Average Precision calculation simplifies to the Reciprocal Rank:</p>

\[\text{Reciprocal Rank} = \frac{1}{r}\]

<p>where $r$ is the rank position of the single relevant documen.
For example, if the document is at rank 1, the score is 1.
If it’s at rank 2, the score is 0.5, and so on.</p>

<p>The Mean Reciprocal Rank (MRR) is the average of these reciprocal ranks across all queries.</p>

<p>Here’s an example.
Suppose we evaluate three queries and the relevant document appears at:</p>

<ul>
  <li>Query 1: Rank 1 → Reciprocal Rank = 1/1</li>
  <li>Query 2: Rank 3 → Reciprocal Rank = 1/3</li>
  <li>Query 3: Rank 2 → Reciprocal Rank = 1/2</li>
</ul>

<p>Then</p>
<ul>
  <li>MRR = $\frac{1/1 + 1/3 + 1/2}{3} \approx 0.61$</li>
</ul>

<h3 id="limitation-1">Limitation</h3>

<p>MAP and MRR are built on the assumption of binary relevance judgments, either a document is relevant (1) or not (0).
But in reality, some documents are highly relevant, while others are only marginally relevant.</p>

<p>Let’s revisit our earlier example with the query “software testing practices”:</p>

<table>
  <thead>
    <tr>
      <th>Document ID</th>
      <th>Title</th>
      <th>Relevant</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>d1</td>
      <td>Mastering Design Patterns in Python</td>
      <td>0</td>
    </tr>
    <tr>
      <td>d2</td>
      <td>Writing Unit Tests with pytest</td>
      <td>1</td>
    </tr>
    <tr>
      <td>d3</td>
      <td>Introduction to TypeScript</td>
      <td>0</td>
    </tr>
    <tr>
      <td>d4</td>
      <td>Refactoring Legacy Codebases</td>
      <td>0</td>
    </tr>
    <tr>
      <td>d5</td>
      <td>The Practical Test Pyramid</td>
      <td>1</td>
    </tr>
    <tr>
      <td>d6</td>
      <td>Code Review Best Practices</td>
      <td>1</td>
    </tr>
    <tr>
      <td>d7</td>
      <td>SQL Optimization Techniques</td>
      <td>0</td>
    </tr>
    <tr>
      <td>d8</td>
      <td>Static Analysis with ruff</td>
      <td>1</td>
    </tr>
    <tr>
      <td>d9</td>
      <td>Principles of Agile Development</td>
      <td>0</td>
    </tr>
    <tr>
      <td>d10</td>
      <td>Test-Driven Development Explained</td>
      <td>1</td>
    </tr>
  </tbody>
</table>

<p>Here, documents like d2, d5, and d10 are highly relevant — they directly address testing practices.
On the other hand, d6 and d8 are only somewhat relevant - they touch on quality practices but don’t focus on testing itself.</p>

<p>To handle this, we need a measure that accounts for graded relevance.</p>

<h2 id="normalized-discounted-cumulative-gain-ndcg">Normalized Discounted Cumulative Gain (nDCG)</h2>

<p>Normalized Discounted Cumulative Gain (nDCG) is a metric for evaluating retrieval systems when relevance judgments are multi-level.
It measures how good a ranking is by considering both the relevance and position.</p>

<p>In nDCG, relevance is usually graded on a scale from 1 to $r$ (where $r$ &gt; 2).
For example, if $r$ = 3, you might define:</p>
<ul>
  <li>1 = not relevant</li>
  <li>2 = somewhat relevant</li>
  <li>3 = highly relevant</li>
</ul>

<p>The formula is:</p>

\[\text{nDCG@}k=\frac{\text{DCG@}k}{\text{IDCG@}k}\]

<p>Let’s walk through an example to see how this works.</p>

<h3 id="gain">Gain</h3>

<p>The first step is assigning a gain value to each document, which is simply its relevance score:</p>

\[G_i=Rel_i\]

<p>Where $G_i$ is the gain of the result at position $i$.</p>

<p>Let’s take a simple example using a 3-level relevance scale ($r$ = 3).
Suppose our system returns 5 results for a query <code class="language-plaintext highlighter-rouge">q1</code>, ranked from to to bottom:</p>

<table>
  <thead>
    <tr>
      <th>Rank</th>
      <th>Document</th>
      <th>Relevance</th>
      <th>Gain</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>d1</td>
      <td>3 (highly relevant)</td>
      <td>3</td>
    </tr>
    <tr>
      <td>2</td>
      <td>d2</td>
      <td>2 (somewhat relevant)</td>
      <td>2</td>
    </tr>
    <tr>
      <td>3</td>
      <td>d3</td>
      <td>1 (not relevant)</td>
      <td>1</td>
    </tr>
    <tr>
      <td>4</td>
      <td>d4</td>
      <td>2 (somewhat relevant)</td>
      <td>2</td>
    </tr>
    <tr>
      <td>5</td>
      <td>d5</td>
      <td>3 (highly relevant)</td>
      <td>3</td>
    </tr>
  </tbody>
</table>

<p>So far, gain is just the raw relevance score.</p>

<h3 id="cumulative-gain-cg">Cumulative Gain (CG)</h3>

<p>Cumulative Gain (CG) is the sum of gains up to rank $k$:</p>

\[\text{CG@k}=\sum_{i=1}^{k}{G_i}\]

<p>For our example:</p>

<table>
  <thead>
    <tr>
      <th>Document</th>
      <th>Gain</th>
      <th>Cumulative Gain</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>d1</td>
      <td>3</td>
      <td>3</td>
    </tr>
    <tr>
      <td>d2</td>
      <td>2</td>
      <td>3 + 2</td>
    </tr>
    <tr>
      <td>d3</td>
      <td>1</td>
      <td>3 + 2 + 1</td>
    </tr>
    <tr>
      <td>d4</td>
      <td>2</td>
      <td>3 + 2 + 1 + 2</td>
    </tr>
    <tr>
      <td>d5</td>
      <td>3</td>
      <td>3 + 2 + 1 + 2 + 3</td>
    </tr>
  </tbody>
</table>

<p>So,</p>

\[\text{CG@5}=3+2+1+2+3=11\]

<p>The problem with CG is that it ignores the rank order.
If you change the top and bottom documents, the CG@5 is still 11.
Clearly, that doesn’t reflect user experience because users care much more about the top results.</p>

<h3 id="discounted-cumulative-gain-dcg">Discounted Cumulative Gain (DCG)</h3>

<p>To make the metric rank-sensitive, the gain at each position is discounted by a logarithmic reduction factor (discount factor).
The formula is:</p>

\[\text{DCG@k}=\sum_{i=1}^{k}\frac{G_i}{\log_2(i+1)}={G_1}+\sum_{i=2}^{k}\frac{G_i}{\log_2(i+1)}\]

<p>Here, the denominator, $\log_2(i+1)$, reduces the contribution of documents as their rank gets lower.
This means a highly relevant document contributes more to the score if it is ranked higher.</p>

<p>For our example:</p>

<table>
  <thead>
    <tr>
      <th>Document</th>
      <th>Gain</th>
      <th>Discount Factor</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>d1</td>
      <td>3</td>
      <td>1</td>
    </tr>
    <tr>
      <td>d2</td>
      <td>2</td>
      <td>log3</td>
    </tr>
    <tr>
      <td>d3</td>
      <td>1</td>
      <td>log4</td>
    </tr>
    <tr>
      <td>d4</td>
      <td>2</td>
      <td>log5</td>
    </tr>
    <tr>
      <td>d5</td>
      <td>3</td>
      <td>log6</td>
    </tr>
  </tbody>
</table>

<p>So:</p>

\[\text{DCG@}5=3+\frac{2}{\log3}+\frac{1}{\log4}+\frac{2}{\log5}+\frac{3}{\log6} \approx 6.78\]

<p>To compute this with Python, we first define two dictionaries:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">qrels_dict</span> <span class="o">=</span> <span class="p">{</span><span class="s">"q1"</span><span class="p">:</span> <span class="p">{</span><span class="s">"d1"</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span> <span class="s">"d2"</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span> <span class="s">"d3"</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="s">"d4"</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span> <span class="s">"d5"</span><span class="p">:</span> <span class="mi">3</span><span class="p">}}</span>
<span class="n">run_dict</span> <span class="o">=</span> <span class="p">{</span><span class="s">"q1"</span><span class="p">:</span> <span class="p">{</span><span class="s">"d1"</span><span class="p">:</span> <span class="mf">0.9</span><span class="p">,</span> <span class="s">"d2"</span><span class="p">:</span> <span class="mf">0.8</span><span class="p">,</span> <span class="s">"d3"</span><span class="p">:</span> <span class="mf">0.7</span><span class="p">,</span> <span class="s">"d4"</span><span class="p">:</span> <span class="mf">0.6</span><span class="p">,</span> <span class="s">"d5"</span><span class="p">:</span> <span class="mf">0.5</span><span class="p">}}</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">qrels_dict</code>: the relevance judgments for each query and document.</li>
  <li><code class="language-plaintext highlighter-rouge">run_dict</code>: the system’s predicted ranking scores. The exact values don’t matter for DCG as long as they preserve the order since what we ultimately care about is the ranking of documents.</li>
</ul>

<p>A function to compute DCG:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">math</span>


<span class="k">def</span> <span class="nf">get_dcg</span><span class="p">(</span><span class="n">qrels_dict</span><span class="p">:</span> <span class="nb">dict</span><span class="p">,</span> <span class="n">run_dict</span><span class="p">:</span> <span class="nb">dict</span><span class="p">,</span> <span class="n">k</span><span class="p">:</span> <span class="nb">int</span><span class="p">,</span> <span class="n">query</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
    <span class="s">"""
    Compute DCG@k for a specific query.
    """</span>
    <span class="c1"># Extract documents for the specific query
</span>    <span class="n">query_qrels</span> <span class="o">=</span> <span class="n">qrels_dict</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="p">{})</span>
    <span class="n">query_run</span> <span class="o">=</span> <span class="n">run_dict</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="p">{})</span>

    <span class="c1"># Sort documents by their scores in run_dict (descending order)
</span>    <span class="n">sorted_docs</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">query_run</span><span class="p">.</span><span class="n">items</span><span class="p">(),</span> <span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">x</span><span class="p">:</span> <span class="n">x</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span> <span class="n">reverse</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>

    <span class="n">dcg</span> <span class="o">=</span> <span class="mf">0.0</span>
    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="nb">min</span><span class="p">(</span><span class="n">k</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">sorted_docs</span><span class="p">))):</span>
        <span class="n">doc_id</span> <span class="o">=</span> <span class="n">sorted_docs</span><span class="p">[</span><span class="n">i</span><span class="p">][</span><span class="mi">0</span><span class="p">]</span>
        <span class="n">relevance</span> <span class="o">=</span> <span class="n">query_qrels</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="n">doc_id</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>  <span class="c1"># default to 0 if doc not in qrels
</span>        <span class="n">rank</span> <span class="o">=</span> <span class="n">i</span> <span class="o">+</span> <span class="mi">1</span>
        <span class="k">if</span> <span class="n">rank</span> <span class="o">==</span> <span class="mi">1</span><span class="p">:</span>
            <span class="n">dcg</span> <span class="o">+=</span> <span class="n">relevance</span>  <span class="c1"># no log discount for rank 1
</span>        <span class="k">else</span><span class="p">:</span>
            <span class="n">dcg</span> <span class="o">+=</span> <span class="n">relevance</span> <span class="o">/</span> <span class="p">(</span><span class="n">math</span><span class="p">.</span><span class="n">log2</span><span class="p">(</span><span class="n">rank</span> <span class="o">+</span> <span class="mi">1</span><span class="p">))</span>  <span class="c1"># log base 2
</span>    <span class="k">return</span> <span class="n">dcg</span>


<span class="n">qrels_dict</span> <span class="o">=</span> <span class="p">{</span><span class="s">"q1"</span><span class="p">:</span> <span class="p">{</span><span class="s">"d1"</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span> <span class="s">"d2"</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span> <span class="s">"d3"</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="s">"d4"</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span> <span class="s">"d5"</span><span class="p">:</span> <span class="mi">3</span><span class="p">}}</span>
<span class="n">run_dict</span> <span class="o">=</span> <span class="p">{</span><span class="s">"q1"</span><span class="p">:</span> <span class="p">{</span><span class="s">"d1"</span><span class="p">:</span> <span class="mf">0.9</span><span class="p">,</span> <span class="s">"d2"</span><span class="p">:</span> <span class="mf">0.8</span><span class="p">,</span> <span class="s">"d3"</span><span class="p">:</span> <span class="mf">0.7</span><span class="p">,</span> <span class="s">"d4"</span><span class="p">:</span> <span class="mf">0.6</span><span class="p">,</span> <span class="s">"d5"</span><span class="p">:</span> <span class="mf">0.5</span><span class="p">}}</span>

<span class="n">dcg</span> <span class="o">=</span> <span class="n">get_dcg</span><span class="p">(</span><span class="n">qrels_dict</span><span class="p">,</span> <span class="n">run_dict</span><span class="p">,</span> <span class="mi">5</span><span class="p">,</span> <span class="s">"q1"</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">dcg</span><span class="si">:</span><span class="p">.</span><span class="mi">2</span><span class="n">f</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div></div>

<p>Output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>6.78
</code></pre></div></div>

<p>Now the ranking matters, but there’s still an issue.
DCG scores aren’t directly comparable across queries.
Imagine one query has only one relevant document and another has five.
Even if both systems ranked perfectly, the DCG for the “easy” query (with lots of relevant docs) will naturally be higher.
Without normalization, comparing those scores is unfair.</p>

<h3 id="normalized-discounted-cumulative-gain-ndcg-1">Normalized Discounted Cumulative Gain (nDCG)</h3>

<p>To solve this, we normalize DCG by dividing it by the ideal DCG (IDCG):</p>

\[\text{nDCG@}k=\frac{\text{DCG@}k}{\text{IDCG@}k}\]

<p>Where IDCG is the DCG of an ideal ranking where all the most relevant documents are at the top.</p>

<p>This scales the score between 0 and 1:
A system with nDCG = 1 means it produced the ideal ranking.
And values below 1 tell you how close (or far) the system is from that ideal.</p>

<p>Suppose the ideal ordering is: [3, 3, 2, 2, 1] for our example:</p>

<table>
  <thead>
    <tr>
      <th>Rank (k)</th>
      <th>Gain</th>
      <th>Discount Factor</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>3</td>
      <td>1</td>
    </tr>
    <tr>
      <td>2</td>
      <td>3</td>
      <td>log3</td>
    </tr>
    <tr>
      <td>3</td>
      <td>2</td>
      <td>log4</td>
    </tr>
    <tr>
      <td>4</td>
      <td>2</td>
      <td>log5</td>
    </tr>
    <tr>
      <td>5</td>
      <td>1</td>
      <td>log6</td>
    </tr>
  </tbody>
</table>

<p>(Note that the ideal ordering could also be [3, 3, 3, 3, 2] or even [3, 3, 3, 3, 3], depending on how many highly relevant documents exist in the collection.)</p>

<p>So:</p>

\[\text{IDCG@}5=3+\frac{3}{\log3}+\frac{2}{\log4}+\frac{2}{\log5}+\frac{1}{\log6} \approx 7.14\]

<p>Finally:</p>

\[\text{nDCG@}5=\frac{\text{DCG}@5}{\text{IDCG}@5}=\frac{6.78}{7.14} \approx 0.95\]

<p>In Python:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">math</span>


<span class="k">def</span> <span class="nf">get_dcg</span><span class="p">(</span><span class="n">qrels_dict</span><span class="p">:</span> <span class="nb">dict</span><span class="p">,</span> <span class="n">run_dict</span><span class="p">:</span> <span class="nb">dict</span><span class="p">,</span> <span class="n">k</span><span class="p">:</span> <span class="nb">int</span><span class="p">,</span> <span class="n">query</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
    <span class="s">"""
    Compute DCG@k for a specific query.
    """</span>
    <span class="c1"># Extract documents for the specific query
</span>    <span class="n">query_qrels</span> <span class="o">=</span> <span class="n">qrels_dict</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="p">{})</span>
    <span class="n">query_run</span> <span class="o">=</span> <span class="n">run_dict</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="p">{})</span>

    <span class="c1"># Sort documents by their scores in run_dict (descending order)
</span>    <span class="n">sorted_docs</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">query_run</span><span class="p">.</span><span class="n">items</span><span class="p">(),</span> <span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">x</span><span class="p">:</span> <span class="n">x</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span> <span class="n">reverse</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>

    <span class="n">dcg</span> <span class="o">=</span> <span class="mf">0.0</span>
    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="nb">min</span><span class="p">(</span><span class="n">k</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">sorted_docs</span><span class="p">))):</span>
        <span class="n">doc_id</span> <span class="o">=</span> <span class="n">sorted_docs</span><span class="p">[</span><span class="n">i</span><span class="p">][</span><span class="mi">0</span><span class="p">]</span>
        <span class="n">relevance</span> <span class="o">=</span> <span class="n">query_qrels</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="n">doc_id</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>  <span class="c1"># default to 0 if doc not in qrels
</span>        <span class="n">rank</span> <span class="o">=</span> <span class="n">i</span> <span class="o">+</span> <span class="mi">1</span>
        <span class="k">if</span> <span class="n">rank</span> <span class="o">==</span> <span class="mi">1</span><span class="p">:</span>
            <span class="n">dcg</span> <span class="o">+=</span> <span class="n">relevance</span>  <span class="c1"># no log discount for rank 1
</span>        <span class="k">else</span><span class="p">:</span>
            <span class="n">dcg</span> <span class="o">+=</span> <span class="n">relevance</span> <span class="o">/</span> <span class="p">(</span><span class="n">math</span><span class="p">.</span><span class="n">log2</span><span class="p">(</span><span class="n">rank</span> <span class="o">+</span> <span class="mi">1</span><span class="p">))</span>  <span class="c1"># log base 2
</span>
    <span class="k">return</span> <span class="n">dcg</span>


<span class="k">def</span> <span class="nf">get_ndcg</span><span class="p">(</span><span class="n">qrels_dict</span><span class="p">:</span> <span class="nb">dict</span><span class="p">,</span> <span class="n">run_dict</span><span class="p">:</span> <span class="nb">dict</span><span class="p">,</span> <span class="n">k</span><span class="p">:</span> <span class="nb">int</span><span class="p">,</span> <span class="n">query</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
    <span class="s">"""
    Compute nDCG@k for a specific query.
    """</span>
    <span class="n">dcg</span> <span class="o">=</span> <span class="n">get_dcg</span><span class="p">(</span><span class="n">qrels_dict</span><span class="p">,</span> <span class="n">run_dict</span><span class="p">,</span> <span class="n">k</span><span class="p">,</span> <span class="n">query</span><span class="p">)</span>

    <span class="c1"># Compute ideal DCG (iDCG)
</span>    <span class="n">query_qrels</span> <span class="o">=</span> <span class="n">qrels_dict</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="p">{})</span>
    <span class="n">ideal_relevances</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">query_qrels</span><span class="p">.</span><span class="n">values</span><span class="p">(),</span> <span class="n">reverse</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>

    <span class="n">idcg</span> <span class="o">=</span> <span class="mf">0.0</span>
    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="nb">min</span><span class="p">(</span><span class="n">k</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">ideal_relevances</span><span class="p">))):</span>
        <span class="n">relevance</span> <span class="o">=</span> <span class="n">ideal_relevances</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
        <span class="n">rank</span> <span class="o">=</span> <span class="n">i</span> <span class="o">+</span> <span class="mi">1</span>
        <span class="k">if</span> <span class="n">rank</span> <span class="o">==</span> <span class="mi">1</span><span class="p">:</span>
            <span class="n">idcg</span> <span class="o">+=</span> <span class="n">relevance</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="n">idcg</span> <span class="o">+=</span> <span class="n">relevance</span> <span class="o">/</span> <span class="p">(</span><span class="n">math</span><span class="p">.</span><span class="n">log2</span><span class="p">(</span><span class="n">rank</span> <span class="o">+</span> <span class="mi">1</span><span class="p">))</span>

    <span class="k">if</span> <span class="n">idcg</span> <span class="o">==</span> <span class="mi">0</span><span class="p">:</span>
        <span class="k">return</span> <span class="mf">0.0</span>  <span class="c1"># Avoid division by zero; if no relevant documents, nDCG is defined as 0
</span>
    <span class="n">ndcg</span> <span class="o">=</span> <span class="n">dcg</span> <span class="o">/</span> <span class="n">idcg</span>

    <span class="k">return</span> <span class="n">ndcg</span>


<span class="n">qrels_dict</span> <span class="o">=</span> <span class="p">{</span><span class="s">"q1"</span><span class="p">:</span> <span class="p">{</span><span class="s">"d1"</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span> <span class="s">"d2"</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span> <span class="s">"d3"</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="s">"d4"</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span> <span class="s">"d5"</span><span class="p">:</span> <span class="mi">3</span><span class="p">}}</span>
<span class="n">run_dict</span> <span class="o">=</span> <span class="p">{</span><span class="s">"q1"</span><span class="p">:</span> <span class="p">{</span><span class="s">"d1"</span><span class="p">:</span> <span class="mf">0.9</span><span class="p">,</span> <span class="s">"d2"</span><span class="p">:</span> <span class="mf">0.8</span><span class="p">,</span> <span class="s">"d3"</span><span class="p">:</span> <span class="mf">0.7</span><span class="p">,</span> <span class="s">"d4"</span><span class="p">:</span> <span class="mf">0.6</span><span class="p">,</span> <span class="s">"d5"</span><span class="p">:</span> <span class="mf">0.5</span><span class="p">}}</span>

<span class="n">ndcg</span> <span class="o">=</span> <span class="n">get_ndcg</span><span class="p">(</span><span class="n">qrels_dict</span><span class="p">,</span> <span class="n">run_dict</span><span class="p">,</span> <span class="mi">5</span><span class="p">,</span> <span class="s">"q1"</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">ndcg</span><span class="si">:</span><span class="p">.</span><span class="mi">2</span><span class="n">f</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div></div>

<p>Output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0.95
</code></pre></div></div>

<p>In fact, there’s a Python library <a href="https://github.com/AmenRa/ranx">ranx</a> that handles evaluation metrics like nDCG out of the box.</p>

<p>Here’s how you can compute nDCG@5 using <code class="language-plaintext highlighter-rouge">ranx</code> in just a few lines:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">ranx</span> <span class="kn">import</span> <span class="n">Qrels</span><span class="p">,</span> <span class="n">Run</span><span class="p">,</span> <span class="n">evaluate</span>

<span class="n">qrels_dict</span> <span class="o">=</span> <span class="p">{</span><span class="s">"q1"</span><span class="p">:</span> <span class="p">{</span><span class="s">"d1"</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span> <span class="s">"d2"</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span> <span class="s">"d3"</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="s">"d4"</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span> <span class="s">"d5"</span><span class="p">:</span> <span class="mi">3</span><span class="p">}}</span>
<span class="n">run_dict</span> <span class="o">=</span> <span class="p">{</span><span class="s">"q1"</span><span class="p">:</span> <span class="p">{</span><span class="s">"d1"</span><span class="p">:</span> <span class="mf">0.9</span><span class="p">,</span> <span class="s">"d2"</span><span class="p">:</span> <span class="mf">0.8</span><span class="p">,</span> <span class="s">"d3"</span><span class="p">:</span> <span class="mf">0.7</span><span class="p">,</span> <span class="s">"d4"</span><span class="p">:</span> <span class="mf">0.6</span><span class="p">,</span> <span class="s">"d5"</span><span class="p">:</span> <span class="mf">0.5</span><span class="p">}}</span>

<span class="n">qrels</span> <span class="o">=</span> <span class="n">Qrels</span><span class="p">(</span><span class="n">qrels_dict</span><span class="p">)</span>
<span class="n">run</span> <span class="o">=</span> <span class="n">Run</span><span class="p">(</span><span class="n">run_dict</span><span class="p">)</span>

<span class="n">ndcg</span> <span class="o">=</span> <span class="n">evaluate</span><span class="p">(</span><span class="n">qrels</span><span class="p">,</span> <span class="n">run</span><span class="p">,</span> <span class="n">metrics</span><span class="o">=</span><span class="s">"ndcg@5"</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">ndcg</span><span class="si">:</span><span class="p">.</span><span class="mi">2</span><span class="n">f</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div></div>

<p>Output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0.95
</code></pre></div></div>

<h3 id="limitation-2">Limitation</h3>

<p>When comparing two systems, looking only at the average nDCG score (or any single metric) isn’t enough.
The question is: “How confident are we that the observed difference isn’t just because of the particular queries we happened to test?”</p>

<p>Imagine two systems:</p>
<ul>
  <li>System A has an average score of 0.20</li>
  <li>System B has an average score of 0.40</li>
</ul>

<p>At first glance, System B looks twice as good.
But let’s dig deeper:</p>
<ul>
  <li>Experiment 1 (low variance): System B beats System A on every single query. Here, it’s safe to say B is better.</li>
  <li>Experiment 2 (high variance): Some queries favor A, others favor B, and results swing wildly. The conclusion is much shakier.</li>
</ul>

<p>This is where statistical significance tests come in.</p>

<h2 id="statistical-significance-testing">Statistical Significance Testing</h2>

<p>Statistical significance testing answers the question: “Is the observed difference real, or could it have happened by chance?”</p>

<h3 id="sign-test">Sign Test</h3>

<p>One of the simplest approaches is the sign test.
Instead of looking at averages, it counts how many times each system wins across queries.</p>

<p>Here’s an example with five queries and scores for two IR systems:</p>

<table>
  <thead>
    <tr>
      <th>Query</th>
      <th>System A</th>
      <th>System B</th>
      <th>Winner</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>q1</td>
      <td>0.28</td>
      <td>0.35</td>
      <td>B</td>
    </tr>
    <tr>
      <td>q2</td>
      <td>0.30</td>
      <td>0.20</td>
      <td>A</td>
    </tr>
    <tr>
      <td>q3</td>
      <td>0.38</td>
      <td>0.40</td>
      <td>B</td>
    </tr>
    <tr>
      <td>q4</td>
      <td>0.29</td>
      <td>0.33</td>
      <td>B</td>
    </tr>
    <tr>
      <td>q5</td>
      <td>0.23</td>
      <td>0.24</td>
      <td>B</td>
    </tr>
  </tbody>
</table>

<ul>
  <li>Wins for System A: 1</li>
  <li>Wins for System B: 4</li>
</ul>

<p>Here, System B clearly dominates by beating System A in 4 out of 5 queries.
That’s a strong sign that B is better than A, at least on this test set.</p>

<h3 id="limitation-3">Limitation</h3>

<p>But here’s where things get tricky.
Let’s extend the test set with four more queries:</p>

<table>
  <thead>
    <tr>
      <th>Query</th>
      <th>System A</th>
      <th>System B</th>
      <th>Winner</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>q1</td>
      <td>0.28</td>
      <td>0.35</td>
      <td>B</td>
    </tr>
    <tr>
      <td>q2</td>
      <td>0.30</td>
      <td>0.20</td>
      <td>A</td>
    </tr>
    <tr>
      <td>q3</td>
      <td>0.38</td>
      <td>0.40</td>
      <td>B</td>
    </tr>
    <tr>
      <td>q4</td>
      <td>0.29</td>
      <td>0.33</td>
      <td>B</td>
    </tr>
    <tr>
      <td>q5</td>
      <td>0.23</td>
      <td>0.24</td>
      <td>B</td>
    </tr>
    <tr>
      <td>q6</td>
      <td>0.30</td>
      <td>0.18</td>
      <td>A</td>
    </tr>
    <tr>
      <td>q7</td>
      <td>0.21</td>
      <td>0.24</td>
      <td>B</td>
    </tr>
    <tr>
      <td>q8</td>
      <td>0.30</td>
      <td>0.18</td>
      <td>A</td>
    </tr>
    <tr>
      <td>q9</td>
      <td>0.34</td>
      <td>0.18</td>
      <td>A</td>
    </tr>
  </tbody>
</table>

<ul>
  <li>Wins for System A: 4</li>
  <li>Wins for System B: 5</li>
</ul>

<p>Now, things don’t look so clear anymore.
Even though System B has more wins overall, the margin is just a single query.
With such small differences, it’s hard to say whether B is truly the better system or if the result is just due to chance.
The sign test doesn’t capture how big the improvements are, only who won more often.
To deal with this limitation, researchers often use more advanced significance tests, like the Wilcoxon signed-rank test.</p>

<h2 id="conclusion">Conclusion</h2>

<p>For comparing different IR systems, MAP and nDCG are useful tools.
They capture both relevance and ranking quality in a way that’s more informative than simple set-based measures.</p>

<p>But again, information retrieval is fundamentally an empirical problem.
What a human assessor marks as “relevant” might not line up with what real users actually care about in the real-world context of search.</p>

<p>The takeaway is that to make reliable decisions, we need to evaluate across many queries.
That’s how we avoid shaky averages and get confidence that one system is truly better than another.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://en.wikipedia.org/wiki/Evaluation_measures_(information_retrieval)#">Evaluation measures (information retrieval) - Wikipedia</a></li>
  <li><a href="https://en.wikipedia.org/wiki/Discounted_cumulative_gain">Discounted cumulative gain - Wikipedia</a></li>
  <li><a href="https://www.coursera.org/learn/text-retrieval">Text Retrieval and Search Engines by University of Illinois Urbana-Champaign</a></li>
</ul>]]></content><author><name>Mienxiu</name></author><category term="ir" /><category term="python" /><summary type="html"><![CDATA[The effectiveness (or accuracy) of an information retrieval system refers to how well it ranks relevant documents higher than non-relevant ones. Evaluating this effectiveness is important because it allows us to compare different algorithms, helping researchers and developers figure out which ideas and methods actually work better.]]></summary></entry><entry><title type="html">Understanding BM25 in Text Retrieval</title><link href="https://mienxiu.com/understanding-bm25/" rel="alternate" type="text/html" title="Understanding BM25 in Text Retrieval" /><published>2025-08-27T00:00:00+00:00</published><updated>2025-08-27T00:00:00+00:00</updated><id>https://mienxiu.com/understanding-bm25</id><content type="html" xml:base="https://mienxiu.com/understanding-bm25/"><![CDATA[<p>When you type a query into a search engine, it finds documents that contain your keywords.
The problem is: <strong>how do you order those documents from most to least relevant</strong>?</p>

<script>
  MathJax = {
    output: {
      displayOverflow: 'scroll'
    },
    tex: {
      inlineMath: {'[+]': [['$', '$']]}
    }
  };
</script>

<script id="MathJax-script" async="" src="https://cdn.jsdelivr.net/npm/mathjax@4/tex-mml-chtml.js"></script>

<p>Search engines use a ranking function to score document relevance.
We write it as $f(q,d)$, where $q$ is the query and $d$ is a document.
The higher the $f(q,d)$, the higher the document appears in the search results.</p>

<p>Although various methods exist to estimate document relevance, BM25 (Best Matching 25) is widely regarded as the most practical and effective ranking function for this task.
It’s been around since the 90s, and despite newer ranking models, BM25 is still a strong baseline and often tough to beat in real-world applications.</p>

<p>The BM25 formula looks like this:</p>

\[f(q,d) = \sum_{i=1}^{n}\frac{c(t_i,d)(k+1)}{c(t_i,d)+k(1-b+b\frac{|d|}{avdl})}(\log\frac{N+1}{df(t)+1}+1)\]

<p>At its heart, BM25 is just combining three heuristics:</p>
<ul>
  <li>Term Frequency (TF)</li>
  <li>Inverse Document Frequencey (IDF)</li>
  <li>Document Length Normalization</li>
</ul>

<p>In this post, I will explain each heuristic and show how they come together to form the BM25 formula.</p>

<h2 id="term-frequency-tf">Term Frequency (TF)</h2>

<p>The simplest idea: if a query term appears more often in a document, that document is probably more relevant.</p>

<p>A TF-only ranking function’s formula can be represented as this:</p>

\[f(q,d) = \sum_{i=1}^{n}TF(t_i,d)\]

<p>Where:</p>
<ul>
  <li>$f(q,d)$ is the relevance score of document $d$ for query $q$.</li>
  <li>$\sum_{i=1}^{n}$ is the summation over all terms ($t_1$, $t_2$, …, $t_n$) in the query $q$.</li>
  <li>$TF(t_i,d)$ is the frequency score of query term $t_i$ in $d$.</li>
</ul>

<p>Then how do we define $TF()$?</p>

<p>A naive TF is just to apply a linear count; $y=x$.
Therefore:</p>

\[TF(t,d) = c(t,d)\]

<p>Where $c(t,d)$ is the raw count of term <code class="language-plaintext highlighter-rouge">t</code> in document <code class="language-plaintext highlighter-rouge">d</code>.</p>

<p><img src="/assets/posts/37/linear_tf.png" alt="Linear TF" /></p>

<p>Let’s see an example.
Suppose we have two documents in our collection:</p>
<ul>
  <li>d1: The Bank of Korea is expected to lower its benchmark interest rate next month.</li>
  <li>d2: A lower interest rate will be welcomed by indebted households.</li>
</ul>

<p>Query: “korea interest rate”</p>

<p>Note that this query contains three terms: {$t_1$, $t_2$, $t_3$} = {“Korea”, “interest”, “rate”}</p>

<p>Under this linear application of the raw count, the scores are:</p>

<table>
  <thead>
    <tr>
      <th>documents</th>
      <th>$f(q,d)$</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>d1</td>
      <td>3</td>
    </tr>
    <tr>
      <td>d2</td>
      <td>2</td>
    </tr>
  </tbody>
</table>

<p>Here, <code class="language-plaintext highlighter-rouge">d2</code> contains two of the query terms—”interest” and “rate”—but misses “Korea”, and <code class="language-plaintext highlighter-rouge">d1</code> matches all three.
This ordering makes intuitive sense: <code class="language-plaintext highlighter-rouge">d1</code> fully matches the query, so it gets a higher score.</p>

<p>But there’s a problem: just repeating a term many times can inflate the score too much.
This opens the door to what’s called <em>term spamming</em>.
Term spamming is when a document unnaturally repeats certain keywords over and over to trick the ranking function into thinking it’s more relevant.</p>

<p>Suppose we add one more document to the collection:</p>
<ul>
  <li>d3: The interest rate charged on loans is often higher than the interest rate paid on deposits.</li>
</ul>

<p>With this new collection, the scores are:</p>

<table>
  <thead>
    <tr>
      <th>documents</th>
      <th>$f(q,d)$</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>d1</td>
      <td>3</td>
    </tr>
    <tr>
      <td>d2</td>
      <td>2</td>
    </tr>
    <tr>
      <td>d3</td>
      <td>4</td>
    </tr>
  </tbody>
</table>

<p>Obviously, this is not what we want.
Instead, we’d prefer a system where repetition still helps, but each extra occurrence of the same word contributes less and less to the score.
In other words, diminishing returns from repetition.</p>

<h3 id="tf-transformation">TF Transformation</h3>

<p>One simple fix is to apply a sublinear transform like logarithmic transformation; $y=\log(x+1)$.
Therefore:</p>

\[TF(t,d) = \log(c(t,d)+1)\]

<p>(1 is added to avoid $\log(0)$.)</p>

<p>This way, the impact of each extra occurrence shrinks.</p>

<p><img src="/assets/posts/37/logarithmic_tf.png" alt="Logarithmic TF" /></p>

<p>Still, it’s not perfect - TF is unbounded, meaning there’s no upper limit.
A spammy document can still rack up arbitrarily high scores if it repeats a term enough times.</p>

<p>To improve it more, here comes a bounded transformation; $y=\frac{x(k+1)}{x+k}$.
Therefore:</p>

\[TF(t,d) = \frac{c(t,d)(k+1)}{c(t,d)+k}\]

<p>This function saturates at $(k+1)$, no matter how large $x$ gets.
In other words, the function is upper-bounded by $k+1$.</p>

<p><img src="/assets/posts/37/bounded_tf.png" alt="Bounded TF" /></p>

<p>Not only is this transformation capped at $(k+1)$—which stops a single term’s frequency from completely dominating the score—it’s also flexible.
The parameter $k$ controls how quickly the curve saturates:</p>
<ul>
  <li>If $k=0$, it reduces to a simple binary scheme: any occurrence of the term contributes a score of 1, no matter how many times it appears.</li>
  <li>If $k$ is very large, the function behaves almost linearly, getting closer to the linear term frequency.</li>
</ul>

<p>With this, our TF-based ranking function becomes:</p>

\[f(q,d) = \sum_{i=1}^{n}\frac{c(t_i,d)(k+1)}{c(t_i,d)+k}\]

<p>This TF function can be implemented in Python like below:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">tf</span><span class="p">(</span><span class="n">term</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">document</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">k</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">1.2</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
    <span class="s">"""
    Calculate TF score using bounded transformation.
    """</span>
    <span class="c1"># Apply some basic text normalization: 1. punctuation handling 2. lowercasing
</span>    <span class="n">doc_terms</span> <span class="o">=</span> <span class="n">document</span><span class="p">.</span><span class="n">replace</span><span class="p">(</span><span class="s">"."</span><span class="p">,</span> <span class="s">" "</span><span class="p">).</span><span class="n">lower</span><span class="p">().</span><span class="n">split</span><span class="p">()</span>
    <span class="n">term_count</span> <span class="o">=</span> <span class="n">doc_terms</span><span class="p">.</span><span class="n">count</span><span class="p">(</span><span class="n">term</span><span class="p">.</span><span class="n">lower</span><span class="p">())</span>
    <span class="n">score</span> <span class="o">=</span> <span class="n">term_count</span> <span class="o">*</span> <span class="p">(</span><span class="n">k</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span> <span class="o">/</span> <span class="p">(</span><span class="n">term_count</span> <span class="o">+</span> <span class="n">k</span><span class="p">)</span>

    <span class="k">return</span> <span class="n">score</span>


<span class="k">def</span> <span class="nf">tf_ranking_function</span><span class="p">(</span><span class="n">query</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">document</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">k</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">1.2</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
    <span class="s">"""
    Calculate TF score of a document with respect to a query.
    """</span>
    <span class="n">score</span> <span class="o">=</span> <span class="nb">sum</span><span class="p">(</span><span class="n">tf</span><span class="p">(</span><span class="n">term</span><span class="p">,</span> <span class="n">document</span><span class="p">,</span> <span class="n">k</span><span class="p">)</span> <span class="k">for</span> <span class="n">term</span> <span class="ow">in</span> <span class="n">query</span><span class="p">.</span><span class="n">lower</span><span class="p">().</span><span class="n">split</span><span class="p">())</span>
    <span class="k">return</span> <span class="n">score</span>
</code></pre></div></div>

<p>To keep things simple, this implementation only applies a couple of basic text normalization steps—removing punctuation and lowercasing.
In a real search engine, though, you’d usually go further with more steps such as stemming, lemmatization, and stopword removal to make scoring more robust and accurate.</p>

<p>We can use this code to get the relevance score of a document for a given query.
For instance:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">score</span> <span class="o">=</span> <span class="n">tf_ranking_function</span><span class="p">(</span>
    <span class="n">query</span><span class="o">=</span><span class="s">"korea interest rate"</span><span class="p">,</span>
    <span class="n">document</span><span class="o">=</span><span class="s">"The Bank of Korea is expected to lower its benchmark interest rate next month."</span><span class="p">,</span>
<span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="n">score</span><span class="p">)</span>
</code></pre></div></div>

<p>Output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>3.0
</code></pre></div></div>

<p>The scores for different <code class="language-plaintext highlighter-rouge">k</code> values are shown below:</p>

<table>
  <thead>
    <tr>
      <th>documents</th>
      <th>$f(q,d)$ ($k$=1.2)</th>
      <th>$f(q,d)$ ($k$=1.6)</th>
      <th>$f(q,d)$ ($k$=2)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>d1</td>
      <td>3</td>
      <td>3</td>
      <td>3</td>
    </tr>
    <tr>
      <td>d2</td>
      <td>2</td>
      <td>2</td>
      <td>2</td>
    </tr>
    <tr>
      <td>d3</td>
      <td>2.75</td>
      <td>2.89</td>
      <td>3</td>
    </tr>
  </tbody>
</table>

<p>This illustrates the role of $k$: a smaller $k$ dampens the impact of raw term frequency, while a larger $k$ makes the function behave closer to linear counting.</p>

<p>The key takeaway here is that with this transformation, <code class="language-plaintext highlighter-rouge">d3</code> no longer unfairly outranks <code class="language-plaintext highlighter-rouge">d1</code> just because it happens to repeat the query terms.</p>

<h3 id="limitation">Limitation</h3>

<p>TF alone isn’t enough to reliably measure relevance because not all words are equally informative.</p>

<p>As an example, consider one more document that contains three “interest rate”s:</p>
<ul>
  <li>d4: The interest rate remains unchanged, but many fear this interest rate keeps loans costly while others welcome a stable interest rate.</li>
</ul>

<p>Using our TF-only ranking function for the same query “korea interest rate”, repetition wins out:</p>

<table>
  <thead>
    <tr>
      <th>documents</th>
      <th>$f(q,d)$ ($k$=1.2)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>d1</td>
      <td>3</td>
    </tr>
    <tr>
      <td>d2</td>
      <td>2</td>
    </tr>
    <tr>
      <td>d3</td>
      <td>2.75</td>
    </tr>
    <tr>
      <td>d4</td>
      <td><strong>3.14</strong></td>
    </tr>
  </tbody>
</table>

<p>As you can see, <code class="language-plaintext highlighter-rouge">d4</code> comes out on top even though it never mentions “Korea”, which is likely to be a crucial term.
That’s not what we want.</p>

<p>That’s where Inverse Document Frequency (IDF) comes in.</p>

<h2 id="inverse-document-frequencey-idf">Inverse Document Frequencey (IDF)</h2>

<p>Inverse Document Frequency (IDF) solves the problem of treating all words equally.
The intuition is that rare words are more informative than common ones.
For example, a rarer word like “Korea” can be a strong signal that a document is actually about the topic we care about.
On the other hand, common words like “the” or “of” show up in almost every document, so they don’t really help us figure out which document is more relevant.</p>

<p>At its core, IDF penalizes overly common terms that appear everywhere and rewards rarer, more descriptive terms that help distinguish one document from another.</p>

<p>When we combine TF with IDF, we get the TF–IDF ranking function:</p>

\[f(q,d) = \sum_{i=1}^{n}TF(t_i,d)IDF(t_i)\]

<p>Then how do we define $IDF()$?</p>

<p>The standard notation of IDF is this:</p>

\[IDF(t) = \log\frac{N}{df(t)}\]

<p>Where:</p>
<ul>
  <li>$N$ is the total number of documents in the collection. (cardinality of documents)</li>
  <li>$df(t)$ is the number of documents containing the term $t$. ($df$ stands for document frequency.)</li>
</ul>

<p>In words, a word with a low document frequency (a rare word) will have a high IDF score, while a very common word will have a low IDF score.</p>

<p>In practice, however, smoothed IDF variants are often used.</p>

<h3 id="smoothing">Smoothing</h3>

<p>In statistics and NLP, smoothing generally means adjusting counts slightly to avoid extreme cases like zeros or infinities.</p>

<p>One of the most common IDF variants with smoothing is this:</p>

\[IDF(t) = \log\frac{N+1}{df(t)+1} + 1\]

<p class="notice--info">This is also what <a href="https://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.TfidfVectorizer.html">scikit-learn</a> uses when calculating IDF.</p>

<p>Here’s why the formula has all those extra <code class="language-plaintext highlighter-rouge">+1</code>s:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">+1</code> in the denominator: Without it, if a term never appears in the collection ($df(t) = 0$), you’d end up dividing by zero. The <code class="language-plaintext highlighter-rouge">+1</code> smooths this out so the math always works. You can think of it as “Even if the term is unseen, let’s pretend it appeared at least once.”</li>
  <li><code class="language-plaintext highlighter-rouge">+1</code> in the numerator: If a term shows up in every document ($df(t) = N$), then $\log\frac{N}{N} = \log(1) = 0$. That would make the term’s weight vanish completely. By adding 1 to the numerator, those super-common terms still get a tiny but non-zero weight.</li>
  <li><code class="language-plaintext highlighter-rouge">+1</code> outside the log: Even with smoothing inside the fraction, the log value can hover around zero or dip negative when terms are extremely common. Negative IDF values don’t make sense—TF-IDF weights should never imply “anti-importance.” Adding 1 outside guarantees that IDF stays positive, avoiding zeros or negatives.</li>
</ul>

<p>Graphically:</p>

<p><img src="/assets/posts/37/idf.png" alt="idf" /></p>

<p>With this smoothed IDF formula, our TF-IDF ranking function becomes:</p>

\[f(q,d) = \sum_{i=1}^{n}\frac{c(t_i,d)(k+1)}{c(t_i,d)+k}(\log\frac{N+1}{df(t)+1}+1)\]

<p>In Python:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">math</span>


<span class="k">def</span> <span class="nf">tf</span><span class="p">(</span><span class="n">term</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">document</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">k</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">1.2</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
    <span class="s">"""
    Calculate TF score using bounded transformation.
    """</span>
    <span class="c1"># Apply some basic text normalization: 1. punctuation handling 2. lowercasing
</span>    <span class="n">doc_terms</span> <span class="o">=</span> <span class="n">document</span><span class="p">.</span><span class="n">replace</span><span class="p">(</span><span class="s">"."</span><span class="p">,</span> <span class="s">" "</span><span class="p">).</span><span class="n">lower</span><span class="p">().</span><span class="n">split</span><span class="p">()</span>
    <span class="n">term_count</span> <span class="o">=</span> <span class="n">doc_terms</span><span class="p">.</span><span class="n">count</span><span class="p">(</span><span class="n">term</span><span class="p">.</span><span class="n">lower</span><span class="p">())</span>
    <span class="n">score</span> <span class="o">=</span> <span class="n">term_count</span> <span class="o">*</span> <span class="p">(</span><span class="n">k</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span> <span class="o">/</span> <span class="p">(</span><span class="n">term_count</span> <span class="o">+</span> <span class="n">k</span><span class="p">)</span>

    <span class="k">return</span> <span class="n">score</span>


<span class="k">def</span> <span class="nf">idf</span><span class="p">(</span><span class="n">term</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">documents</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
    <span class="s">"""
    Calculate Inverse Document Frequency (IDF) for a term.
    """</span>
    <span class="n">N</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="n">documents</span><span class="p">)</span>  <span class="c1"># Total number of documents
</span>    <span class="n">df_t</span> <span class="o">=</span> <span class="mi">0</span>
    <span class="k">for</span> <span class="n">doc</span> <span class="ow">in</span> <span class="n">documents</span><span class="p">:</span>
        <span class="c1"># Apply some basic text normalization: 1. punctuation handling 2. lowercasing
</span>        <span class="k">if</span> <span class="n">term</span><span class="p">.</span><span class="n">lower</span><span class="p">()</span> <span class="ow">in</span> <span class="n">doc</span><span class="p">.</span><span class="n">replace</span><span class="p">(</span><span class="s">"."</span><span class="p">,</span> <span class="s">" "</span><span class="p">).</span><span class="n">lower</span><span class="p">().</span><span class="n">split</span><span class="p">():</span>
            <span class="n">df_t</span> <span class="o">+=</span> <span class="mi">1</span>
    <span class="n">score</span> <span class="o">=</span> <span class="n">math</span><span class="p">.</span><span class="n">log</span><span class="p">((</span><span class="n">N</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span> <span class="o">/</span> <span class="p">(</span><span class="n">df_t</span> <span class="o">+</span> <span class="mi">1</span><span class="p">))</span> <span class="o">+</span> <span class="mi">1</span>

    <span class="k">return</span> <span class="n">score</span>


<span class="k">def</span> <span class="nf">tf_idf_ranking_function</span><span class="p">(</span>
    <span class="n">query</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">document</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">documents</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">],</span> <span class="n">k</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">1.2</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
    <span class="s">"""
    Calculate TF-IDF score
    """</span>
    <span class="n">query_terms</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="n">lower</span><span class="p">().</span><span class="n">split</span><span class="p">()</span>
    <span class="n">score</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">0.0</span>
    <span class="k">for</span> <span class="n">term</span> <span class="ow">in</span> <span class="n">query_terms</span><span class="p">:</span>
        <span class="n">tf_score</span> <span class="o">=</span> <span class="n">tf</span><span class="p">(</span><span class="n">term</span><span class="p">,</span> <span class="n">document</span><span class="p">,</span> <span class="n">k</span><span class="p">)</span>
        <span class="n">idf_score</span> <span class="o">=</span> <span class="n">idf</span><span class="p">(</span><span class="n">term</span><span class="p">,</span> <span class="n">documents</span><span class="p">)</span>
        <span class="n">score</span> <span class="o">+=</span> <span class="n">tf_score</span> <span class="o">*</span> <span class="n">idf_score</span>

    <span class="k">return</span> <span class="n">score</span>
</code></pre></div></div>

<p>Let’s revisit the query “korea interest rate” and four documents:</p>
<ul>
  <li>d1: The Bank of Korea is expected to lower its benchmark interest rate next month.</li>
  <li>d2: A lower interest rate will be welcomed by indebted households.</li>
  <li>d3: The interest rate charged on loans is often higher than the interest rate paid on deposits.</li>
  <li>d4: The interest rate remains unchanged, but many fear this interest rate keeps loans costly while others welcome a stable interest rate.</li>
</ul>

<p>Using TF-IDF ranking function, the scores are:</p>

<table>
  <thead>
    <tr>
      <th>documents</th>
      <th>$f(q,d)$ ($k$=1.2)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>d1</td>
      <td><strong>3.92</strong></td>
    </tr>
    <tr>
      <td>d2</td>
      <td>2</td>
    </tr>
    <tr>
      <td>d3</td>
      <td>2.75</td>
    </tr>
    <tr>
      <td>d4</td>
      <td>3.14</td>
    </tr>
  </tbody>
</table>

<p>The ranking now matches user intent so that <code class="language-plaintext highlighter-rouge">d1</code> rises above <code class="language-plaintext highlighter-rouge">d4</code> because it contains the rarer term “Korea”.</p>

<h3 id="limitation-1">Limitation</h3>

<p>The ranking models discussed so far are inherently biased toward long documents.
A longer document has a statistically higher chance of matching query terms, even if the matches are coincidental and scattered throughout the text.</p>

<p>Let’s look at one more document:</p>
<ul>
  <li>d5: In South Korea, the central bank’s decision on the interest rate is closely watched by both businesses and households. Rising interest rate levels have slowed consumer spending, while exporters in Korea argue that a stable interest rate is necessary to remain competitive. Many in Korea believe that future growth depends on how carefully the government manages the interest rate policy.</li>
</ul>

<p>Notice how <code class="language-plaintext highlighter-rouge">d5</code> is stuffed with the words Korea and interest rate.
TF-IDF would likely push this document to the very top, not because it’s the most relevant, but simply because it’s long and keeps repeating the keywords.
The scores are:</p>

<table>
  <thead>
    <tr>
      <th>documents</th>
      <th>$f(q,d)$ ($k$=1.2)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>d1</td>
      <td>3.69</td>
    </tr>
    <tr>
      <td>d2</td>
      <td>2</td>
    </tr>
    <tr>
      <td>d3</td>
      <td>2.75</td>
    </tr>
    <tr>
      <td>d4</td>
      <td>3.14</td>
    </tr>
    <tr>
      <td>d5</td>
      <td><strong>5.71</strong></td>
    </tr>
  </tbody>
</table>

<p>That’s where Document Length Normalization comes in.</p>

<h2 id="document-length-normalization">Document Length Normalization</h2>

<p>The last heuristic is that longer documents naturally contain more terms, and therefore match queries more often.
To counter this, their scores are adjusted relative to the average document length.
The goal is simple: balance the bias toward long documents.
In short, don’t reward a document just for being long.</p>

<p>Penalizing long documents must be done carefully to avoid over-penalization since a document can be long either because:</p>
<ul>
  <li>It’s verbose: Some texts are just wordy. They take a simple idea and wrap it in way too many words. In this case, penalization makes sense.</li>
  <li>It’s comprehensive: Other texts are long because they actually cover more ground (like a news archive that bundles many different stories). These shouldn’t be punished too harshly, because their length comes from offering more useful, distinct content.</li>
</ul>

<h3 id="pivoted-length-normalization">Pivoted Length Normalization</h3>

<p>A robust solution to this problem is Pivoted Length Normalization.
This method uses the average document length (avdl) of the entire collection as a “pivot” or reference point.</p>

<p>Here’s how it works in practice:</p>
<ul>
  <li>Documents with a length equal to the <code class="language-plaintext highlighter-rouge">avdl</code> receive no penalty or reward (normalizer = 1).</li>
  <li>Documents longer than the <code class="language-plaintext highlighter-rouge">avdl</code> are penalized.</li>
  <li>Documents shorter than the <code class="language-plaintext highlighter-rouge">avdl</code> are rewarded.</li>
</ul>

<p>The formula for the pivoted normalizer is:</p>

\[normalizer = 1 - b + b \frac{|d|}{avdl}\]

<p>Where:</p>
<ul>
  <li>$|d|$ is the length of the document (number of terms).</li>
  <li>$avdl$ is the average document length across the whole collection.</li>
  <li>$b$ is a tuning parameter between 0 and 1.
    <ul>
      <li>If $b$ = 0, the normalizer is always 1, and no length normalization occurs.</li>
      <li>As $b$ increases towards 1, the penalty for long documents and the reward for short documents become more aggressive.</li>
    </ul>
  </li>
</ul>

<p><img src="/assets/posts/37/pivoted_length_normalization.png" alt="Pivoted Length Normalization" /></p>

<p>So where does this normalizer actually get applied?</p>

<p>In BM25, it slides into the denominator of the TF term.
Here’s the BM25 TF function:</p>

\[TF'(t,d) = \frac{c(t,d)(k+1)}{c(t,d)+k(1-b+b\frac{|d|}{avdl})}\]

<p>Finally, we arrive at the BM25 (Okapi) ranking function by integrating all three heuristics—TF Transformation, IDF Weighting, and Pivoted Length Normalization:</p>

\[f(q,d) = \sum_{i=1}^{n}\frac{c(t_i,d)(k+1)}{c(t_i,d)+k(1-b+b\frac{|d|}{avdl})}(\log\frac{N+1}{df(t)+1}+1)\]

<p>Where:</p>
<ul>
  <li>$k$ is the term frequency saturation parameter</li>
  <li>$b$ is the document-length normalization weight (0–1)</li>
</ul>

<p>In Python:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">math</span>


<span class="k">def</span> <span class="nf">tf_with_normalizer</span><span class="p">(</span><span class="n">term</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">document</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">k</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">1.2</span><span class="p">,</span> <span class="n">normalizer</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">1.0</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
    <span class="s">"""
    Calculate TF score for a term using BM25 with document length normalization.
    """</span>
    <span class="c1"># Apply some basic text normalization: 1. punctuation handling 2. lowercasing
</span>    <span class="n">doc_terms</span> <span class="o">=</span> <span class="n">document</span><span class="p">.</span><span class="n">replace</span><span class="p">(</span><span class="s">"."</span><span class="p">,</span> <span class="s">" "</span><span class="p">).</span><span class="n">lower</span><span class="p">().</span><span class="n">split</span><span class="p">()</span>
    <span class="n">term_count</span> <span class="o">=</span> <span class="n">doc_terms</span><span class="p">.</span><span class="n">count</span><span class="p">(</span><span class="n">term</span><span class="p">.</span><span class="n">lower</span><span class="p">())</span>
    <span class="n">score</span> <span class="o">=</span> <span class="n">term_count</span> <span class="o">*</span> <span class="p">(</span><span class="n">k</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span> <span class="o">/</span> <span class="p">(</span><span class="n">term_count</span> <span class="o">+</span> <span class="n">k</span> <span class="o">*</span> <span class="n">normalizer</span><span class="p">)</span>

    <span class="k">return</span> <span class="n">score</span>


<span class="k">def</span> <span class="nf">idf</span><span class="p">(</span><span class="n">term</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">documents</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
    <span class="s">"""
    Calculate Inverse Document Frequency (IDF) for a term.
    """</span>
    <span class="n">N</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="n">documents</span><span class="p">)</span>  <span class="c1"># Total number of documents
</span>    <span class="n">df_t</span> <span class="o">=</span> <span class="mi">0</span>
    <span class="k">for</span> <span class="n">doc</span> <span class="ow">in</span> <span class="n">documents</span><span class="p">:</span>
        <span class="c1"># Apply some basic text normalization: 1. punctuation handling 2. lowercasing
</span>        <span class="k">if</span> <span class="n">term</span><span class="p">.</span><span class="n">lower</span><span class="p">()</span> <span class="ow">in</span> <span class="n">doc</span><span class="p">.</span><span class="n">replace</span><span class="p">(</span><span class="s">"."</span><span class="p">,</span> <span class="s">" "</span><span class="p">).</span><span class="n">lower</span><span class="p">().</span><span class="n">split</span><span class="p">():</span>
            <span class="n">df_t</span> <span class="o">+=</span> <span class="mi">1</span>
    <span class="n">score</span> <span class="o">=</span> <span class="n">math</span><span class="p">.</span><span class="n">log</span><span class="p">((</span><span class="n">N</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span> <span class="o">/</span> <span class="p">(</span><span class="n">df_t</span> <span class="o">+</span> <span class="mi">1</span><span class="p">))</span> <span class="o">+</span> <span class="mi">1</span>

    <span class="k">return</span> <span class="n">score</span>


<span class="k">def</span> <span class="nf">document_length_normalization</span><span class="p">(</span><span class="n">document</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">documents</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">],</span> <span class="n">b</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">0.75</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
    <span class="s">"""
    Calculate document length normalization factor.
    """</span>
    <span class="c1"># Calculate average document length
</span>    <span class="n">total_length</span> <span class="o">=</span> <span class="nb">sum</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">doc</span><span class="p">.</span><span class="n">split</span><span class="p">())</span> <span class="k">for</span> <span class="n">doc</span> <span class="ow">in</span> <span class="n">documents</span><span class="p">)</span>
    <span class="n">avdl</span> <span class="o">=</span> <span class="n">total_length</span> <span class="o">/</span> <span class="nb">len</span><span class="p">(</span><span class="n">documents</span><span class="p">)</span>
    <span class="n">doc_length</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="n">document</span><span class="p">.</span><span class="n">split</span><span class="p">())</span>
    <span class="n">normalizer</span> <span class="o">=</span> <span class="mi">1</span> <span class="o">-</span> <span class="n">b</span> <span class="o">+</span> <span class="n">b</span> <span class="o">*</span> <span class="p">(</span><span class="n">doc_length</span> <span class="o">/</span> <span class="n">avdl</span><span class="p">)</span>

    <span class="k">return</span> <span class="n">normalizer</span>


<span class="k">def</span> <span class="nf">bm25_ranking_function</span><span class="p">(</span>
    <span class="n">query</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">document</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">documents</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">],</span> <span class="n">k</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">1.2</span><span class="p">,</span> <span class="n">b</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">0.75</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
    <span class="s">"""
    Calculate BM25 score for a document given a query.

    Args:
        query: The query string.
        document: The document string.
        documents: The list of all documents. (collection)
        k: The term frequency saturation parameter.
        b: The length normalization parameter.
    """</span>
    <span class="n">terms</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="n">lower</span><span class="p">().</span><span class="n">split</span><span class="p">()</span>
    <span class="n">normalizer</span> <span class="o">=</span> <span class="n">document_length_normalization</span><span class="p">(</span><span class="n">document</span><span class="p">,</span> <span class="n">documents</span><span class="p">,</span> <span class="n">b</span><span class="p">)</span>
    <span class="n">score</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">0.0</span>
    <span class="k">for</span> <span class="n">term</span> <span class="ow">in</span> <span class="n">terms</span><span class="p">:</span>
        <span class="n">tf_score</span> <span class="o">=</span> <span class="n">tf_with_normalizer</span><span class="p">(</span><span class="n">term</span><span class="p">,</span> <span class="n">document</span><span class="p">,</span> <span class="n">k</span><span class="p">,</span> <span class="n">normalizer</span><span class="p">)</span>
        <span class="n">idf_score</span> <span class="o">=</span> <span class="n">idf</span><span class="p">(</span><span class="n">term</span><span class="p">,</span> <span class="n">documents</span><span class="p">)</span>
        <span class="n">score</span> <span class="o">+=</span> <span class="n">tf_score</span> <span class="o">*</span> <span class="n">idf_score</span>

    <span class="k">return</span> <span class="n">score</span>
</code></pre></div></div>

<p>Using the BM25 ranking function, the scores for our example become more acceptable:</p>

<table>
  <thead>
    <tr>
      <th>documents</th>
      <th>$f(q,d)$ ($k$=1.2, $b$=0.75)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>d1</td>
      <td>4.46</td>
    </tr>
    <tr>
      <td>d2</td>
      <td>2.63</td>
    </tr>
    <tr>
      <td>d3</td>
      <td>3.04</td>
    </tr>
    <tr>
      <td>d4</td>
      <td>3.23</td>
    </tr>
    <tr>
      <td>d5</td>
      <td>4.34</td>
    </tr>
  </tbody>
</table>

<p>What happens here is that even though <code class="language-plaintext highlighter-rouge">d5</code> is long and loaded with repeated mentions of Korea and interest rate, it doesn’t unfairly dominate the ranking anymore.</p>

<h2 id="conclusion">Conclusion</h2>

<p>BM25 is the backbone for many retrieval systems because it’s simple, fast, and surprisingly effective.
But here’s one clear limitation: BM25 doesn’t actually understand context.
It sees words, counts them, and scores documents based on math, not semantics.</p>

<p>Take these two documents:</p>
<ul>
  <li>d1: The Bank of Korea is expected to lower its benchmark interest rate next month.</li>
  <li>d6: The interest in street dance across Korea has grown at such a fast rate.</li>
</ul>

<p>And the query: “korea interest rate”</p>

<p>From BM25’s perspective, both documents look almost identical.
The same words appear, with the same frequency, so the scores end up nearly the same.
But to a human, the difference is obvious: <code class="language-plaintext highlighter-rouge">d1</code> is clearly more relevant than <code class="language-plaintext highlighter-rouge">d6</code>.</p>

<p>That’s why modern search engines rarely rely on BM25 alone.
Here are some techniques often used to address BM25’s limitations:</p>
<ul>
  <li>Relevance feedback: learning from user clicks or pseudo-relevance feedback.</li>
  <li>Semantic embeddings: capturing meaning rather than just word overlap.</li>
  <li>Learning to Rank (LTR): training a machine learning model that combines multiple signals (BM25 scores, embeddings, click data, etc.) to optimize search result ordering.</li>
</ul>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://en.wikipedia.org/wiki/Okapi_BM25">Okapi BM25 - Wikipedia</a></li>
  <li><a href="https://en.wikipedia.org/wiki/Tf%E2%80%93idf">tf–idf - Wikipedia</a></li>
  <li><a href="https://www.elastic.co/blog/found-similarity-in-elasticsearch">Similarity in Elasticsearch by Konrad Beiske</a></li>
  <li><a href="https://www.coursera.org/learn/text-retrieval">Text Retrieval and Search Engines by University of Illinois Urbana-Champaign</a></li>
</ul>]]></content><author><name>Mienxiu</name></author><category term="ir" /><category term="python" /><summary type="html"><![CDATA[When you type a query into a search engine, it finds documents that contain your keywords. The problem is: how do you order those documents from most to least relevant?]]></summary></entry><entry><title type="html">Building Korean Query Auto-completion using Elasticsearch</title><link href="https://mienxiu.com/building-korean-query-autocompletion/" rel="alternate" type="text/html" title="Building Korean Query Auto-completion using Elasticsearch" /><published>2025-06-25T00:00:00+00:00</published><updated>2025-06-25T00:00:00+00:00</updated><id>https://mienxiu.com/building-korean-query-autocompletion</id><content type="html" xml:base="https://mienxiu.com/building-korean-query-autocompletion/"><![CDATA[<p>Query auto-completion (QAC) is a feature that suggests completions for a user’s input query.
It tries to guess what you’re about to type and throws out suggestions, commonly based on past searches.
Pretty much every site or app with a search box has it—Google, YouTube, Amazon, Naver, you name it.
It’s just one of those things we use every day.</p>

<p><img src="/assets/posts/36/youtube_query_autocompletion.png" alt="YouTube's query auto-completion" /></p>

<p>(YoutTube’s query auto-completion)</p>

<p>This post is a walk-through of building Korean query auto-completion feature based on past searches using Elasticsearch.</p>

<h2 id="what-problems-does-query-auto-completion-solve">What problems does query auto-completion solve?</h2>

<ul>
  <li>Typing can be slow and error-prone. This gets even trickier depending on the user’s environment like mobile. For car navigation, query auto-completion isn’t just nice to have, it’s a must.</li>
  <li>Users sometimes don’t know the exact keywords to search. Query auto-completion helps them find what they want faster, even if they’re not sure what to type.</li>
</ul>

<h2 id="how-is-korean-query-auto-completion-different-from-english">How is Korean query auto-completion different from English?</h2>

<p>The simplest way to build query auto-completion is using just simple prefix matching.</p>

<p>For English, that could be enough with some lowercasing thrown in.
For example, if your dataset includes <code class="language-plaintext highlighter-rouge">apple</code>, <code class="language-plaintext highlighter-rouge">autocomplete</code>, and <code class="language-plaintext highlighter-rouge">artificial intelligence</code>, typing just <code class="language-plaintext highlighter-rouge">a</code> or <code class="language-plaintext highlighter-rouge">A</code> will match all of them.</p>

<p>For Korean, that approach doesn’t quite cut it.
Say your dataset includes <code class="language-plaintext highlighter-rouge">가구</code> (furniture), <code class="language-plaintext highlighter-rouge">간식</code> (snack), and <code class="language-plaintext highlighter-rouge">겨울 코트</code> (winter coat).
They all start with the consonant <code class="language-plaintext highlighter-rouge">ㄱ</code>, but if a user types just <code class="language-plaintext highlighter-rouge">ㄱ</code>, none of those will match.
That’s because <code class="language-plaintext highlighter-rouge">ㄱ</code>, <code class="language-plaintext highlighter-rouge">가</code>, <code class="language-plaintext highlighter-rouge">간</code>, and <code class="language-plaintext highlighter-rouge">겨</code> are completely different characters.
If you want <code class="language-plaintext highlighter-rouge">가구</code> to show up in the suggestions, you have to type at least <code class="language-plaintext highlighter-rouge">가</code>.</p>

<p>To give users a better experience, you need to break Hangul syllables apart into their building blocks—a process called <em>syllable decomposition</em>.
That’s the key to making Korean auto-completion actually work better.</p>

<h2 id="hangul-syllables-and-jamo">Hangul syllables and Jamo</h2>

<p>Hangul (한글) is the Korean alphabet, made up of Hangul Jamos (자모).
These Jamos are the Korean consonants and vowels, which are basically the building blocks of Korean syllables.
There are 19 consonants and 21 vowels in Jamo.</p>

<p>19 consonants:</p>

<table>
  <thead>
    <tr>
      <th>Hangul</th>
      <th>ㄱ</th>
      <th>ㄲ</th>
      <th>ㄴ</th>
      <th>ㄷ</th>
      <th>ㄸ</th>
      <th>ㄹ</th>
      <th>ㅁ</th>
      <th>ㅂ</th>
      <th>ㅃ</th>
      <th>ㅅ</th>
      <th>ㅆ</th>
      <th>ㅇ</th>
      <th>ㅈ</th>
      <th>ㅉ</th>
      <th>ㅊ</th>
      <th>ㅋ</th>
      <th>ㅌ</th>
      <th>ㅍ</th>
      <th>ㅎ</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Initial RR</td>
      <td>g</td>
      <td>kk</td>
      <td>n</td>
      <td>d</td>
      <td>tt</td>
      <td>r</td>
      <td>m</td>
      <td>b</td>
      <td>pp</td>
      <td>s</td>
      <td>ss</td>
      <td>–</td>
      <td>j</td>
      <td>jj</td>
      <td>ch</td>
      <td>k</td>
      <td>t</td>
      <td>p</td>
      <td>h</td>
    </tr>
    <tr>
      <td>Final RR</td>
      <td>k</td>
      <td>k</td>
      <td>n</td>
      <td>t</td>
      <td>–</td>
      <td>l</td>
      <td>m</td>
      <td>p</td>
      <td>–</td>
      <td>t</td>
      <td>t</td>
      <td>ng</td>
      <td>t</td>
      <td>–</td>
      <td>t</td>
      <td>k</td>
      <td>t</td>
      <td>p</td>
      <td>t</td>
    </tr>
  </tbody>
</table>

<ul>
  <li>Initial RR: Romanization of consonants at the start of a syllable. These are the sounds you hear starting a Korean syllable.</li>
  <li>Final RR: Romanization of consonants at the end of a syllable. “-“ means these letters don’t appear at the end of syllables.</li>
</ul>

<p>21 vowels:</p>

<table>
  <thead>
    <tr>
      <th>Hangul</th>
      <th>ㅏ</th>
      <th>ㅐ</th>
      <th>ㅑ</th>
      <th>ㅒ</th>
      <th>ㅓ</th>
      <th>ㅔ</th>
      <th>ㅕ</th>
      <th>ㅖ</th>
      <th>ㅗ</th>
      <th>ㅘ</th>
      <th>ㅙ</th>
      <th>ㅚ</th>
      <th>ㅛ</th>
      <th>ㅜ</th>
      <th>ㅝ</th>
      <th>ㅞ</th>
      <th>ㅟ</th>
      <th>ㅠ</th>
      <th>ㅡ</th>
      <th>ㅢ</th>
      <th>ㅣ</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>RR</td>
      <td>a</td>
      <td>ae</td>
      <td>ya</td>
      <td>yae</td>
      <td>eo</td>
      <td>e</td>
      <td>yeo</td>
      <td>ye</td>
      <td>o</td>
      <td>wa</td>
      <td>wae</td>
      <td>oe</td>
      <td>yo</td>
      <td>u</td>
      <td>wo</td>
      <td>we</td>
      <td>wi</td>
      <td>yu</td>
      <td>eu</td>
      <td>ui/yi</td>
      <td>i</td>
    </tr>
  </tbody>
</table>

<p>Here are some examples of Korean syllables made by combining jamos:</p>

<table>
  <thead>
    <tr>
      <th>Syllable</th>
      <th>Romanization</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>가</td>
      <td>ga</td>
      <td>(depends on context)</td>
    </tr>
    <tr>
      <td>옷</td>
      <td>ot</td>
      <td>clothes</td>
    </tr>
    <tr>
      <td>사과</td>
      <td>sagwa</td>
      <td>apple</td>
    </tr>
    <tr>
      <td>크림</td>
      <td>keurim</td>
      <td>cream</td>
    </tr>
  </tbody>
</table>

<p>That’s pretty much it.
Once you’ve got this down, you can read and write almost any Korean.</p>

<p class="notice--info">Hangul system is remarkably unique in that its letters are made to look like the mouth and tongue shapes used to say the sounds.
It is designed so that anyone can easily learn to read and write.</p>

<hr />

<p>The example in this post uses the following software versions:</p>
<ul>
  <li>Python 3.13.4 (with <code class="language-plaintext highlighter-rouge">pip install elasticsearch==8.13.2 "fastapi[standard]" aiohttp</code>)</li>
  <li>Elasticsearch 8.13.4</li>
  <li>Kibana 8.13.4</li>
</ul>

<p>Elasticsearch and Kibana are running locally at http://localhost:9200 and http://localhost:5601, respectively.</p>

<h2 id="context">Context</h2>

<p>You’re building an e-commerce application with a search bar. Every time a user types a search query, you’re logging that into an Elasticsearch index called <code class="language-plaintext highlighter-rouge">search-logs</code> with the following mappings:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">PUT</span><span class="w"> </span><span class="err">/search-logs</span><span class="w">
</span><span class="p">{</span><span class="w">
  </span><span class="nl">"mappings"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"@timestamp"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"date"</span><span class="w">
      </span><span class="p">},</span><span class="w">
      </span><span class="nl">"query"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"keyword"</span><span class="w">
      </span><span class="p">},</span><span class="w">
      </span><span class="nl">"user_id"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"keyword"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>For demo purposes, let’s insert some sample data:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">POST</span><span class="w"> </span><span class="err">/search-logs/_bulk</span><span class="w">
</span><span class="p">{</span><span class="nl">"index"</span><span class="p">:{}}</span><span class="w">
</span><span class="p">{</span><span class="nl">"@timestamp"</span><span class="p">:</span><span class="s2">"2025-06-21T09:00:00Z"</span><span class="p">,</span><span class="nl">"query"</span><span class="p">:</span><span class="s2">"가구"</span><span class="p">,</span><span class="nl">"user_id"</span><span class="p">:</span><span class="s2">"user1"</span><span class="p">}</span><span class="w">
</span><span class="p">{</span><span class="nl">"index"</span><span class="p">:{}}</span><span class="w">
</span><span class="p">{</span><span class="nl">"@timestamp"</span><span class="p">:</span><span class="s2">"2025-06-21T09:00:01Z"</span><span class="p">,</span><span class="nl">"query"</span><span class="p">:</span><span class="s2">"가방"</span><span class="p">,</span><span class="nl">"user_id"</span><span class="p">:</span><span class="s2">"user2"</span><span class="p">}</span><span class="w">
</span><span class="p">{</span><span class="nl">"index"</span><span class="p">:{}}</span><span class="w">
</span><span class="p">{</span><span class="nl">"@timestamp"</span><span class="p">:</span><span class="s2">"2025-06-21T09:00:02Z"</span><span class="p">,</span><span class="nl">"query"</span><span class="p">:</span><span class="s2">"가방"</span><span class="p">,</span><span class="nl">"user_id"</span><span class="p">:</span><span class="s2">"user3"</span><span class="p">}</span><span class="w">
</span><span class="p">{</span><span class="nl">"index"</span><span class="p">:{}}</span><span class="w">
</span><span class="p">{</span><span class="nl">"@timestamp"</span><span class="p">:</span><span class="s2">"2025-06-21T09:00:03Z"</span><span class="p">,</span><span class="nl">"query"</span><span class="p">:</span><span class="s2">"간식"</span><span class="p">,</span><span class="nl">"user_id"</span><span class="p">:</span><span class="s2">"user4"</span><span class="p">}</span><span class="w">
</span><span class="p">{</span><span class="nl">"index"</span><span class="p">:{}}</span><span class="w">
</span><span class="p">{</span><span class="nl">"@timestamp"</span><span class="p">:</span><span class="s2">"2025-06-21T09:00:04Z"</span><span class="p">,</span><span class="nl">"query"</span><span class="p">:</span><span class="s2">"겨울 코트"</span><span class="p">,</span><span class="nl">"user_id"</span><span class="p">:</span><span class="s2">"user5"</span><span class="p">}</span><span class="w">
</span><span class="p">{</span><span class="nl">"index"</span><span class="p">:{}}</span><span class="w">
</span><span class="p">{</span><span class="nl">"@timestamp"</span><span class="p">:</span><span class="s2">"2025-06-21T09:00:05Z"</span><span class="p">,</span><span class="nl">"query"</span><span class="p">:</span><span class="s2">"사과"</span><span class="p">,</span><span class="nl">"user_id"</span><span class="p">:</span><span class="s2">"user6"</span><span class="p">}</span><span class="w">
</span><span class="p">{</span><span class="nl">"index"</span><span class="p">:{}}</span><span class="w">
</span><span class="p">{</span><span class="nl">"@timestamp"</span><span class="p">:</span><span class="s2">"2025-06-21T09:00:06Z"</span><span class="p">,</span><span class="nl">"query"</span><span class="p">:</span><span class="s2">"1234567890"</span><span class="p">,</span><span class="nl">"user_id"</span><span class="p">:</span><span class="s2">"user7"</span><span class="p">}</span><span class="w">
</span><span class="p">{</span><span class="nl">"index"</span><span class="p">:{}}</span><span class="w">
</span><span class="p">{</span><span class="nl">"@timestamp"</span><span class="p">:</span><span class="s2">"2025-06-21T09:00:07Z"</span><span class="p">,</span><span class="nl">"query"</span><span class="p">:</span><span class="s2">"airpods 4"</span><span class="p">,</span><span class="nl">"user_id"</span><span class="p">:</span><span class="s2">"user8"</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h2 id="solution">Solution</h2>

<p>There are plugins like nori for Korean, but I’m skipping those to build the logic ourselves for full control and to better understand how Korean QAC actually works under the hood.</p>

<p>Another possible solution is using <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.13/analysis-edgengram-tokenizer.html">Edge n-gram tokenizer</a>.
With this approach, you can set up a custom tokenizer in your index and tweak options like <code class="language-plaintext highlighter-rouge">min_gram</code>, <code class="language-plaintext highlighter-rouge">max_gram</code>, and <code class="language-plaintext highlighter-rouge">token_chars</code> to fine-tune how autocomplete behaves.
That said, I won’t be covering this method here.</p>

<p>Instead, I’ll use Elasticsearch’s <code class="language-plaintext highlighter-rouge">completion</code> suggester which is a more purpose-built option.
I’ll also take a look at its limitations and walk through ways to gradually improve the experience.</p>

<h3 id="version0-simple-prefix-matching">Version0: Simple prefix matching</h3>

<p>Start simple.
No syllable decomposition, just raw keyword prefix matching using Elasticsearch’s <code class="language-plaintext highlighter-rouge">completion</code> suggester.</p>

<p>First, create a keyword suggestion index named <code class="language-plaintext highlighter-rouge">search-keywords</code>:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">PUT</span><span class="w"> </span><span class="err">/search-keywords</span><span class="w">
</span><span class="p">{</span><span class="w">
  </span><span class="nl">"mappings"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"suggest"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="err">//</span><span class="w"> </span><span class="err">you</span><span class="w"> </span><span class="err">can</span><span class="w"> </span><span class="err">use</span><span class="w"> </span><span class="err">any</span><span class="w"> </span><span class="err">name</span><span class="w"> </span><span class="err">here</span><span class="w"> </span><span class="err">instead</span><span class="w"> </span><span class="err">of</span><span class="w"> </span><span class="s2">"suggest"</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"completion"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"analyzer"</span><span class="p">:</span><span class="w"> </span><span class="s2">"standard"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">completion</code> suggester is designed to be as fast as possible because auto-completion should keep pace with your typing, giving you instant, relevant suggestions as you go.
This is why auto-completion is also often called as “search-as-you-type” functionality.</p>

<p>We also use the <code class="language-plaintext highlighter-rouge">standard</code> analyzer instead of the default <code class="language-plaintext highlighter-rouge">simple</code> analyzer to make sure numbers are handled properly.
The <code class="language-plaintext highlighter-rouge">simple</code> analyzer strips out digits completely, so a query like “1” won’t match “1234567890” in our dataset.
The <code class="language-plaintext highlighter-rouge">standard</code> analyzer keeps numbers intact—so numeric queries work just like you’d expect.</p>

<p>Now that we have two indices, <code class="language-plaintext highlighter-rouge">search-logs</code> and <code class="language-plaintext highlighter-rouge">search-keywords</code>, we can read from <code class="language-plaintext highlighter-rouge">search-logs</code> and populate <code class="language-plaintext highlighter-rouge">search-keywords</code> using this Python script:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># update_keywords0.py
</span><span class="kn">from</span> <span class="nn">dataclasses</span> <span class="kn">import</span> <span class="n">dataclass</span>

<span class="kn">from</span> <span class="nn">elasticsearch</span> <span class="kn">import</span> <span class="n">Elasticsearch</span><span class="p">,</span> <span class="n">helpers</span>


<span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
    <span class="n">hosts</span> <span class="o">=</span> <span class="p">[</span><span class="s">"http://localhost:9200"</span><span class="p">]</span>
    <span class="n">username</span> <span class="o">=</span> <span class="s">"elastic"</span>
    <span class="n">password</span> <span class="o">=</span> <span class="s">"elasticpassword"</span>
    <span class="k">with</span> <span class="n">Elasticsearch</span><span class="p">(</span><span class="n">hosts</span><span class="o">=</span><span class="n">hosts</span><span class="p">,</span> <span class="n">basic_auth</span><span class="o">=</span><span class="p">(</span><span class="n">username</span><span class="p">,</span> <span class="n">password</span><span class="p">))</span> <span class="k">as</span> <span class="n">es_client</span><span class="p">:</span>
        <span class="n">top_queries</span> <span class="o">=</span> <span class="n">_get_top_queries_from_search_logs</span><span class="p">(</span><span class="n">es_client</span><span class="o">=</span><span class="n">es_client</span><span class="p">,</span> <span class="n">size</span><span class="o">=</span><span class="mi">10000</span><span class="p">)</span>
        <span class="n">success_count</span> <span class="o">=</span> <span class="n">_update_search_keywords</span><span class="p">(</span><span class="n">es_client</span><span class="p">,</span> <span class="n">keywords</span><span class="o">=</span><span class="n">top_queries</span><span class="p">)</span>
        <span class="k">print</span><span class="p">(</span><span class="s">"Success count:"</span><span class="p">,</span> <span class="n">success_count</span><span class="p">)</span>


<span class="o">@</span><span class="n">dataclass</span>
<span class="k">class</span> <span class="nc">Keyword</span><span class="p">:</span>
    <span class="n">key</span><span class="p">:</span> <span class="nb">str</span>
    <span class="n">count</span><span class="p">:</span> <span class="nb">int</span>


<span class="k">def</span> <span class="nf">_get_top_queries_from_search_logs</span><span class="p">(</span><span class="n">es_client</span><span class="p">:</span> <span class="n">Elasticsearch</span><span class="p">,</span> <span class="n">size</span><span class="p">:</span> <span class="nb">int</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="n">Keyword</span><span class="p">]:</span>
    <span class="s">"""
    Get the top search queries from the search log index.
    We use composite aggregation to handle large datasets efficiently.
    """</span>
    <span class="n">index</span> <span class="o">=</span> <span class="s">"search-logs"</span>

    <span class="n">all_buckets</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="n">after_key</span> <span class="o">=</span> <span class="bp">None</span>  <span class="c1"># for pagination
</span>    <span class="k">while</span> <span class="bp">True</span><span class="p">:</span>
        <span class="n">comp_query</span> <span class="o">=</span> <span class="p">{</span>
            <span class="s">"size"</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
            <span class="s">"aggs"</span><span class="p">:</span> <span class="p">{</span>
                <span class="s">"top_queries"</span><span class="p">:</span> <span class="p">{</span>
                    <span class="s">"composite"</span><span class="p">:</span> <span class="p">{</span>
                        <span class="s">"size"</span><span class="p">:</span> <span class="n">size</span><span class="p">,</span>
                        <span class="s">"sources"</span><span class="p">:</span> <span class="p">[{</span><span class="s">"term"</span><span class="p">:</span> <span class="p">{</span><span class="s">"terms"</span><span class="p">:</span> <span class="p">{</span><span class="s">"field"</span><span class="p">:</span> <span class="s">"query"</span><span class="p">}}}],</span>
                        <span class="o">**</span><span class="p">({</span><span class="s">"after"</span><span class="p">:</span> <span class="n">after_key</span><span class="p">}</span> <span class="k">if</span> <span class="n">after_key</span> <span class="k">else</span> <span class="p">{}),</span>
                    <span class="p">}</span>
                <span class="p">}</span>
            <span class="p">},</span>
        <span class="p">}</span>
        <span class="n">res</span> <span class="o">=</span> <span class="n">es_client</span><span class="p">.</span><span class="n">search</span><span class="p">(</span><span class="n">index</span><span class="o">=</span><span class="n">index</span><span class="p">,</span> <span class="n">body</span><span class="o">=</span><span class="n">comp_query</span><span class="p">)</span>
        <span class="n">buckets</span> <span class="o">=</span> <span class="n">res</span><span class="p">[</span><span class="s">"aggregations"</span><span class="p">][</span><span class="s">"top_queries"</span><span class="p">][</span><span class="s">"buckets"</span><span class="p">]</span>
        <span class="n">all_buckets</span><span class="p">.</span><span class="n">extend</span><span class="p">(</span><span class="n">buckets</span><span class="p">)</span>
        <span class="n">after_key</span> <span class="o">=</span> <span class="n">res</span><span class="p">[</span><span class="s">"aggregations"</span><span class="p">][</span><span class="s">"top_queries"</span><span class="p">].</span><span class="n">get</span><span class="p">(</span><span class="s">"after_key"</span><span class="p">)</span>
        <span class="k">if</span> <span class="ow">not</span> <span class="n">after_key</span><span class="p">:</span>
            <span class="k">break</span>

    <span class="c1"># Convert the buckets to a list of Keyword dataclass instances
</span>    <span class="n">keywords</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="n">Keyword</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">item</span> <span class="ow">in</span> <span class="n">all_buckets</span><span class="p">:</span>
        <span class="n">keywords</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">Keyword</span><span class="p">(</span><span class="n">key</span><span class="o">=</span><span class="n">item</span><span class="p">[</span><span class="s">"key"</span><span class="p">][</span><span class="s">"term"</span><span class="p">].</span><span class="n">strip</span><span class="p">(),</span> <span class="n">count</span><span class="o">=</span><span class="n">item</span><span class="p">[</span><span class="s">"doc_count"</span><span class="p">]))</span>

    <span class="k">return</span> <span class="n">keywords</span>


<span class="k">def</span> <span class="nf">_update_search_keywords</span><span class="p">(</span><span class="n">es_client</span><span class="p">:</span> <span class="n">Elasticsearch</span><span class="p">,</span> <span class="n">keywords</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="n">Keyword</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">int</span><span class="p">:</span>
    <span class="s">"""
    Update the autocomplete index with new data.
    Return the number of successfully executed actions (including overrides).
    """</span>
    <span class="n">index</span> <span class="o">=</span> <span class="s">"search-keywords"</span>

    <span class="n">actions</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">keyword</span> <span class="ow">in</span> <span class="n">keywords</span><span class="p">:</span>
        <span class="n">action</span> <span class="o">=</span> <span class="p">{</span>
            <span class="s">"_op_type"</span><span class="p">:</span> <span class="s">"index"</span><span class="p">,</span>
            <span class="s">"_index"</span><span class="p">:</span> <span class="n">index</span><span class="p">,</span>
            <span class="s">"_id"</span><span class="p">:</span> <span class="n">keyword</span><span class="p">.</span><span class="n">key</span><span class="p">,</span>
            <span class="s">"_source"</span><span class="p">:</span> <span class="p">{</span>
                <span class="s">"suggest"</span><span class="p">:</span> <span class="p">{</span>
                    <span class="s">"input"</span><span class="p">:</span> <span class="p">[</span>
                        <span class="n">keyword</span><span class="p">.</span><span class="n">key</span><span class="p">,</span>  <span class="c1"># Use the keyword itself as input
</span>                    <span class="p">],</span>
                    <span class="s">"weight"</span><span class="p">:</span> <span class="n">keyword</span><span class="p">.</span><span class="n">count</span><span class="p">,</span>
                <span class="p">}</span>
            <span class="p">},</span>
        <span class="p">}</span>
        <span class="n">actions</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">action</span><span class="p">)</span>

    <span class="n">res</span> <span class="o">=</span> <span class="n">helpers</span><span class="p">.</span><span class="n">bulk</span><span class="p">(</span><span class="n">es_client</span><span class="p">,</span> <span class="n">actions</span><span class="p">)</span>

    <span class="k">return</span> <span class="n">res</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>


<span class="k">if</span> <span class="n">__name__</span> <span class="o">==</span> <span class="s">"__main__"</span><span class="p">:</span>
    <span class="n">main</span><span class="p">()</span>
</code></pre></div></div>

<p>What it does:</p>
<ol>
  <li>Aggregates the top queries from <code class="language-plaintext highlighter-rouge">search-logs</code>, using a composite aggregation which efficiently handles large datasets by paginating results. (Without composite aggregation, there is a size limit to the number of buckets.)</li>
  <li>Bulk-indexes them into <code class="language-plaintext highlighter-rouge">search-keywords</code> with:
    <ul>
      <li><code class="language-plaintext highlighter-rouge">input</code>: the actual query</li>
      <li><code class="language-plaintext highlighter-rouge">weight</code>: how often it was searched. This way, more frequently searched keywords show up higher in suggestions.</li>
    </ul>
  </li>
</ol>

<p>In practice, if you’re dealing with a large volume of logs, you’ll want to add a range filter to speed things up and avoid processing stale data.
For example, here’s how to retrieve search logs from the past week:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"size"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
    </span><span class="nl">"query"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"bool"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"filter"</span><span class="p">:</span><span class="w"> </span><span class="p">[{</span><span class="nl">"range"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"@timestamp"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"gte"</span><span class="p">:</span><span class="w"> </span><span class="s2">"now-7d"</span><span class="p">}}}]}},</span><span class="w">
    </span><span class="err">...</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Running the code will output:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Success</span> <span class="n">count</span><span class="p">:</span> <span class="mi">7</span>
</code></pre></div></div>

<p>In practice, again, you can run this code periodically to keep your dataset fresh and up to date.</p>

<p>Verify the documents by using <code class="language-plaintext highlighter-rouge">_search</code> API in Kibana Dev Tools:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">GET</span><span class="w"> </span><span class="err">/search-keywords/_search</span><span class="w">
</span></code></pre></div></div>

<p>Next up, use FastAPI to expose an endpoint <code class="language-plaintext highlighter-rouge">/keyword-suggestions</code>:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app.py
</span><span class="kn">from</span> <span class="nn">contextlib</span> <span class="kn">import</span> <span class="n">asynccontextmanager</span>
<span class="kn">from</span> <span class="nn">typing</span> <span class="kn">import</span> <span class="n">Annotated</span>

<span class="kn">from</span> <span class="nn">elasticsearch</span> <span class="kn">import</span> <span class="n">AsyncElasticsearch</span>
<span class="kn">from</span> <span class="nn">fastapi</span> <span class="kn">import</span> <span class="n">Depends</span><span class="p">,</span> <span class="n">FastAPI</span><span class="p">,</span> <span class="n">Query</span>
<span class="kn">from</span> <span class="nn">fastapi.middleware.cors</span> <span class="kn">import</span> <span class="n">CORSMiddleware</span>

<span class="c1"># Create an Elasticsearch client
</span><span class="n">hosts</span> <span class="o">=</span> <span class="p">[</span><span class="s">"http://localhost:9200"</span><span class="p">]</span>
<span class="n">username</span> <span class="o">=</span> <span class="s">"elastic"</span>
<span class="n">password</span> <span class="o">=</span> <span class="s">"elasticpassword"</span>
<span class="n">es_client</span> <span class="o">=</span> <span class="n">AsyncElasticsearch</span><span class="p">(</span><span class="n">hosts</span><span class="o">=</span><span class="n">hosts</span><span class="p">,</span> <span class="n">basic_auth</span><span class="o">=</span><span class="p">(</span><span class="n">username</span><span class="p">,</span> <span class="n">password</span><span class="p">))</span>


<span class="o">@</span><span class="n">asynccontextmanager</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">lifespan</span><span class="p">(</span><span class="n">app</span><span class="p">:</span> <span class="n">FastAPI</span><span class="p">):</span>
    <span class="k">yield</span>
    <span class="k">print</span><span class="p">(</span><span class="s">"Closing Elasticsearch client..."</span><span class="p">)</span>
    <span class="k">await</span> <span class="n">es_client</span><span class="p">.</span><span class="n">close</span><span class="p">()</span>


<span class="c1"># Create FastAPI app with CORS middleware
</span><span class="n">app</span> <span class="o">=</span> <span class="n">FastAPI</span><span class="p">(</span><span class="n">title</span><span class="o">=</span><span class="s">"Keyword Suggestion API"</span><span class="p">,</span> <span class="n">lifespan</span><span class="o">=</span><span class="n">lifespan</span><span class="p">)</span>
<span class="n">app</span><span class="p">.</span><span class="n">add_middleware</span><span class="p">(</span><span class="n">CORSMiddleware</span><span class="p">,</span> <span class="n">allow_origins</span><span class="o">=</span><span class="p">[</span><span class="s">"*"</span><span class="p">])</span>


<span class="k">def</span> <span class="nf">get_es_client</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="n">AsyncElasticsearch</span><span class="p">:</span>
    <span class="k">return</span> <span class="n">es_client</span>


<span class="o">@</span><span class="n">app</span><span class="p">.</span><span class="n">get</span><span class="p">(</span>
    <span class="s">"/keyword-suggestions"</span><span class="p">,</span>
    <span class="n">response_model</span><span class="o">=</span><span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">],</span>
    <span class="n">summary</span><span class="o">=</span><span class="s">"Suggest keywords for a given query"</span><span class="p">,</span>
    <span class="n">description</span><span class="o">=</span><span class="s">"Returns a list of keyword suggestions based on the input query."</span><span class="p">,</span>
<span class="p">)</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">suggest_keywords</span><span class="p">(</span>
    <span class="n">es_client</span><span class="p">:</span> <span class="n">Annotated</span><span class="p">[</span><span class="n">AsyncElasticsearch</span><span class="p">,</span> <span class="n">Depends</span><span class="p">(</span><span class="n">get_es_client</span><span class="p">)],</span>
    <span class="n">query</span><span class="p">:</span> <span class="n">Annotated</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Query</span><span class="p">(</span><span class="n">description</span><span class="o">=</span><span class="s">"User input query for keyword suggestions"</span><span class="p">)],</span>
    <span class="n">limit</span><span class="p">:</span> <span class="n">Annotated</span><span class="p">[</span><span class="nb">int</span><span class="p">,</span> <span class="n">Query</span><span class="p">(</span><span class="n">description</span><span class="o">=</span><span class="s">"Maximum number of keywords to return"</span><span class="p">,</span> <span class="n">gt</span><span class="o">=</span><span class="mi">0</span><span class="p">)]</span> <span class="o">=</span> <span class="mi">10</span><span class="p">,</span>
<span class="p">):</span>
    <span class="n">body</span> <span class="o">=</span> <span class="p">{</span>
        <span class="s">"suggest"</span><span class="p">:</span> <span class="p">{</span>
            <span class="s">"keyword-suggest"</span><span class="p">:</span> <span class="p">{</span>
                <span class="s">"prefix"</span><span class="p">:</span> <span class="n">query</span><span class="p">,</span>
                <span class="s">"completion"</span><span class="p">:</span> <span class="p">{</span>
                    <span class="s">"field"</span><span class="p">:</span> <span class="s">"suggest"</span><span class="p">,</span>
                    <span class="s">"size"</span><span class="p">:</span> <span class="n">limit</span><span class="p">,</span>
                <span class="p">},</span>
            <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="n">res</span> <span class="o">=</span> <span class="k">await</span> <span class="n">es_client</span><span class="p">.</span><span class="n">search</span><span class="p">(</span><span class="n">index</span><span class="o">=</span><span class="s">"search-keywords"</span><span class="p">,</span> <span class="n">body</span><span class="o">=</span><span class="n">body</span><span class="p">)</span>
    <span class="n">results</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">option</span> <span class="ow">in</span> <span class="n">res</span><span class="p">[</span><span class="s">"suggest"</span><span class="p">][</span><span class="s">"keyword-suggest"</span><span class="p">][</span><span class="mi">0</span><span class="p">][</span><span class="s">"options"</span><span class="p">]:</span>
        <span class="n">results</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">option</span><span class="p">[</span><span class="s">"_source"</span><span class="p">][</span><span class="s">"suggest"</span><span class="p">][</span><span class="s">"input"</span><span class="p">][</span><span class="mi">0</span><span class="p">])</span>

    <span class="k">return</span> <span class="n">results</span>
</code></pre></div></div>

<p>This FastAPI app handles Korean keyword autocomplete using Elasticsearch. Here’s how it works:</p>
<ol>
  <li>Set up and manages an Elasticsearch client.</li>
  <li>Expose a <code class="language-plaintext highlighter-rouge">/keyword-suggestions</code> endpoint that takes a query and an optional limit.</li>
  <li>Query the <code class="language-plaintext highlighter-rouge">search-keywords</code> index using Elasticsearch’s <code class="language-plaintext highlighter-rouge">completion</code> suggester.</li>
  <li>Return a list of matching keyword suggestions.</li>
</ol>

<p>The core of the logic lives in this Elasticsearch query:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">body</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s">"suggest"</span><span class="p">:</span> <span class="p">{</span>
        <span class="s">"keyword-suggest"</span><span class="p">:</span> <span class="p">{</span>
            <span class="s">"prefix"</span><span class="p">:</span> <span class="n">query</span><span class="p">,</span>
            <span class="s">"completion"</span><span class="p">:</span> <span class="p">{</span>
                <span class="s">"field"</span><span class="p">:</span> <span class="s">"suggest"</span><span class="p">,</span>
                <span class="s">"size"</span><span class="p">:</span> <span class="n">limit</span><span class="p">,</span>
            <span class="p">},</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Here’s a quick breakdown:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">prefix</code>: the user’s input—we’re trying to autocomplete this.</li>
  <li><code class="language-plaintext highlighter-rouge">completion</code>: tells Elasticsearch to use the completion suggester.</li>
  <li><code class="language-plaintext highlighter-rouge">field</code>: the field in the index we defined to support autocomplete (in this case, <code class="language-plaintext highlighter-rouge">suggest</code>).</li>
</ul>

<p>You can run the app with:</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>fastapi run
</code></pre></div></div>

<p>Then head to http://localhost:8000/docs to try it out.</p>

<p>If you hit the endpoint with <code class="language-plaintext highlighter-rouge">query=가</code>, you’ll get a response like:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="w">
  </span><span class="s2">"가방"</span><span class="p">,</span><span class="w">
  </span><span class="s2">"가구"</span><span class="w">
</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<p>Note that <code class="language-plaintext highlighter-rouge">가방</code> comes first because it has a higher weight (i.e., it’s been searched more often).
It’s a simple heuristic: the more people search for something, the higher it shows up.</p>

<p>Here are a couple of limitations:</p>
<ul>
  <li>Typing a single jamo like <code class="language-plaintext highlighter-rouge">ㄱ</code> won’t return anything even if some keywords start with that consonant.</li>
  <li>Typing <code class="language-plaintext highlighter-rouge">갑</code> or <code class="language-plaintext highlighter-rouge">가바</code> (a typo or maybe just middle of typing <code class="language-plaintext highlighter-rouge">가방</code>) won’t return anything either.</li>
</ul>

<h3 id="version1-syllable-decomposition">Version1: Syllable decomposition</h3>

<p>To support keyword suggestions even when the user types only jamo (like <code class="language-plaintext highlighter-rouge">ㄱ</code>, <code class="language-plaintext highlighter-rouge">ㄱㄱ</code>, etc.), we need to make a few changes to how we index and search.</p>

<p>We’ll re-create a <code class="language-plaintext highlighter-rouge">search-keywords</code> index:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">PUT</span><span class="w"> </span><span class="err">/search-keywords</span><span class="w">
</span><span class="p">{</span><span class="w">
  </span><span class="nl">"mappings"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"suggest"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="err">//</span><span class="w"> </span><span class="err">you</span><span class="w"> </span><span class="err">can</span><span class="w"> </span><span class="err">use</span><span class="w"> </span><span class="err">any</span><span class="w"> </span><span class="err">name</span><span class="w"> </span><span class="err">here</span><span class="w"> </span><span class="err">instead</span><span class="w"> </span><span class="err">of</span><span class="w"> </span><span class="s2">"suggest"</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"completion"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"analyzer"</span><span class="p">:</span><span class="w"> </span><span class="s2">"standard"</span><span class="w">
      </span><span class="p">},</span><span class="w">
      </span><span class="nl">"keyword"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"keyword"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Now, we’ve separated responsibilities by defining two fields:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">suggest</code>: used for querying suggestions.</li>
  <li><code class="language-plaintext highlighter-rouge">keyword</code>: stores the original keyword to display to users.</li>
</ul>

<p>Next, we need to break each Korean syllable into its jamos (choseong, jungseong, and jongseong) so the <code class="language-plaintext highlighter-rouge">completion</code> suggester can match partial inputs.</p>

<p>Here’s a utility function to do that:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Hangul.py
</span><span class="s">"""
Hangul (Korean alphabet) processing utilities.

This module provides functions for working with Korean text, including:
- Extracting initial consonants (choseong)
- Splitting Korean syllables into individual jamo (consonants and vowels)
- Converting Korean text to romanized form based on keyboard layout

The module uses Unicode code points to manipulate Korean characters.
"""</span>

<span class="c1"># For long lists, use `extend` in favor of readability
</span>
<span class="c1"># 19 initial consonants
</span><span class="n">CHOSEONGS</span> <span class="o">=</span> <span class="p">[]</span>
<span class="n">CHOSEONGS</span><span class="p">.</span><span class="n">extend</span><span class="p">([</span><span class="s">"ㄱ"</span><span class="p">,</span> <span class="s">"ㄲ"</span><span class="p">,</span> <span class="s">"ㄴ"</span><span class="p">,</span> <span class="s">"ㄷ"</span><span class="p">])</span>
<span class="n">CHOSEONGS</span><span class="p">.</span><span class="n">extend</span><span class="p">([</span><span class="s">"ㄸ"</span><span class="p">,</span> <span class="s">"ㄹ"</span><span class="p">,</span> <span class="s">"ㅁ"</span><span class="p">,</span> <span class="s">"ㅂ"</span><span class="p">])</span>
<span class="n">CHOSEONGS</span><span class="p">.</span><span class="n">extend</span><span class="p">([</span><span class="s">"ㅃ"</span><span class="p">,</span> <span class="s">"ㅅ"</span><span class="p">,</span> <span class="s">"ㅆ"</span><span class="p">,</span> <span class="s">"ㅇ"</span><span class="p">])</span>
<span class="n">CHOSEONGS</span><span class="p">.</span><span class="n">extend</span><span class="p">([</span><span class="s">"ㅈ"</span><span class="p">,</span> <span class="s">"ㅉ"</span><span class="p">,</span> <span class="s">"ㅊ"</span><span class="p">,</span> <span class="s">"ㅋ"</span><span class="p">])</span>
<span class="n">CHOSEONGS</span><span class="p">.</span><span class="n">extend</span><span class="p">([</span><span class="s">"ㅌ"</span><span class="p">,</span> <span class="s">"ㅍ"</span><span class="p">,</span> <span class="s">"ㅎ"</span><span class="p">])</span>

<span class="c1"># 21 vowels
</span><span class="n">JUNGSEONGS</span> <span class="o">=</span> <span class="p">[]</span>
<span class="n">JUNGSEONGS</span><span class="p">.</span><span class="n">extend</span><span class="p">([</span><span class="s">"ㅏ"</span><span class="p">,</span> <span class="s">"ㅐ"</span><span class="p">,</span> <span class="s">"ㅑ"</span><span class="p">,</span> <span class="s">"ㅒ"</span><span class="p">])</span>
<span class="n">JUNGSEONGS</span><span class="p">.</span><span class="n">extend</span><span class="p">([</span><span class="s">"ㅓ"</span><span class="p">,</span> <span class="s">"ㅔ"</span><span class="p">,</span> <span class="s">"ㅕ"</span><span class="p">,</span> <span class="s">"ㅖ"</span><span class="p">])</span>
<span class="n">JUNGSEONGS</span><span class="p">.</span><span class="n">extend</span><span class="p">([</span><span class="s">"ㅗ"</span><span class="p">,</span> <span class="s">"ㅘ"</span><span class="p">,</span> <span class="s">"ㅙ"</span><span class="p">,</span> <span class="s">"ㅚ"</span><span class="p">])</span>
<span class="n">JUNGSEONGS</span><span class="p">.</span><span class="n">extend</span><span class="p">([</span><span class="s">"ㅛ"</span><span class="p">,</span> <span class="s">"ㅜ"</span><span class="p">,</span> <span class="s">"ㅝ"</span><span class="p">,</span> <span class="s">"ㅞ"</span><span class="p">])</span>
<span class="n">JUNGSEONGS</span><span class="p">.</span><span class="n">extend</span><span class="p">([</span><span class="s">"ㅟ"</span><span class="p">,</span> <span class="s">"ㅠ"</span><span class="p">,</span> <span class="s">"ㅡ"</span><span class="p">,</span> <span class="s">"ㅢ"</span><span class="p">])</span>
<span class="n">JUNGSEONGS</span><span class="p">.</span><span class="n">extend</span><span class="p">([</span><span class="s">"ㅣ"</span><span class="p">])</span>

<span class="c1"># 28 final consonants (including no-final)
</span><span class="n">JONGSEONGS</span> <span class="o">=</span> <span class="p">[]</span>
<span class="n">JONGSEONGS</span><span class="p">.</span><span class="n">extend</span><span class="p">([</span><span class="s">" "</span><span class="p">,</span> <span class="s">"ㄱ"</span><span class="p">,</span> <span class="s">"ㄲ"</span><span class="p">,</span> <span class="s">"ㄳ"</span><span class="p">])</span>
<span class="n">JONGSEONGS</span><span class="p">.</span><span class="n">extend</span><span class="p">([</span><span class="s">"ㄴ"</span><span class="p">,</span> <span class="s">"ㄵ"</span><span class="p">,</span> <span class="s">"ㄶ"</span><span class="p">,</span> <span class="s">"ㄷ"</span><span class="p">])</span>
<span class="n">JONGSEONGS</span><span class="p">.</span><span class="n">extend</span><span class="p">([</span><span class="s">"ㄹ"</span><span class="p">,</span> <span class="s">"ㄺ"</span><span class="p">,</span> <span class="s">"ㄻ"</span><span class="p">,</span> <span class="s">"ㄼ"</span><span class="p">])</span>
<span class="n">JONGSEONGS</span><span class="p">.</span><span class="n">extend</span><span class="p">([</span><span class="s">"ㄽ"</span><span class="p">,</span> <span class="s">"ㄾ"</span><span class="p">,</span> <span class="s">"ㄿ"</span><span class="p">,</span> <span class="s">"ㅀ"</span><span class="p">])</span>
<span class="n">JONGSEONGS</span><span class="p">.</span><span class="n">extend</span><span class="p">([</span><span class="s">"ㅁ"</span><span class="p">,</span> <span class="s">"ㅂ"</span><span class="p">,</span> <span class="s">"ㅄ"</span><span class="p">,</span> <span class="s">"ㅅ"</span><span class="p">])</span>
<span class="n">JONGSEONGS</span><span class="p">.</span><span class="n">extend</span><span class="p">([</span><span class="s">"ㅆ"</span><span class="p">,</span> <span class="s">"ㅇ"</span><span class="p">,</span> <span class="s">"ㅈ"</span><span class="p">,</span> <span class="s">"ㅊ"</span><span class="p">])</span>
<span class="n">JONGSEONGS</span><span class="p">.</span><span class="n">extend</span><span class="p">([</span><span class="s">"ㅋ"</span><span class="p">,</span> <span class="s">"ㅌ"</span><span class="p">,</span> <span class="s">"ㅍ"</span><span class="p">,</span> <span class="s">"ㅎ"</span><span class="p">])</span>


<span class="k">def</span> <span class="nf">decompose_syllables</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]:</span>
    <span class="s">"""
    Decompose Hangul syllables into individual jamo (consonants and vowels).

    Example:
        "사과" -&gt; ["ㅅ", "ㅏ", "ㄱ", "ㅘ"]
    """</span>
    <span class="n">array</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">character</span> <span class="ow">in</span> <span class="nb">list</span><span class="p">(</span><span class="n">text</span><span class="p">.</span><span class="n">replace</span><span class="p">(</span><span class="s">" "</span><span class="p">,</span> <span class="s">""</span><span class="p">).</span><span class="n">strip</span><span class="p">()):</span>
        <span class="k">if</span> <span class="s">"가"</span> <span class="o">&lt;=</span> <span class="n">character</span> <span class="o">&lt;=</span> <span class="s">"힣"</span><span class="p">:</span>  <span class="c1"># For Hangul syllables
</span>            <span class="n">offset</span> <span class="o">=</span> <span class="nb">ord</span><span class="p">(</span><span class="n">character</span><span class="p">)</span> <span class="o">-</span> <span class="nb">ord</span><span class="p">(</span><span class="s">"가"</span><span class="p">)</span>
            <span class="n">choseong</span> <span class="o">=</span> <span class="n">offset</span> <span class="o">//</span> <span class="mi">588</span>
            <span class="n">jungseong</span> <span class="o">=</span> <span class="p">(</span><span class="n">offset</span> <span class="o">-</span> <span class="p">(</span><span class="mi">588</span> <span class="o">*</span> <span class="n">choseong</span><span class="p">))</span> <span class="o">//</span> <span class="mi">28</span>
            <span class="n">jongseong</span> <span class="o">=</span> <span class="n">offset</span> <span class="o">-</span> <span class="p">(</span><span class="mi">588</span> <span class="o">*</span> <span class="n">choseong</span><span class="p">)</span> <span class="o">-</span> <span class="p">(</span><span class="mi">28</span> <span class="o">*</span> <span class="n">jungseong</span><span class="p">)</span>
            <span class="n">array</span><span class="p">.</span><span class="n">append</span><span class="p">([</span><span class="n">CHOSEONGS</span><span class="p">[</span><span class="n">choseong</span><span class="p">],</span> <span class="n">JUNGSEONGS</span><span class="p">[</span><span class="n">jungseong</span><span class="p">],</span> <span class="n">JONGSEONGS</span><span class="p">[</span><span class="n">jongseong</span><span class="p">]])</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="n">array</span><span class="p">.</span><span class="n">append</span><span class="p">([</span><span class="n">character</span><span class="p">])</span>

    <span class="k">return</span> <span class="p">[</span><span class="n">char</span> <span class="k">for</span> <span class="n">sublist</span> <span class="ow">in</span> <span class="n">array</span> <span class="k">for</span> <span class="n">char</span> <span class="ow">in</span> <span class="n">sublist</span> <span class="k">if</span> <span class="ow">not</span> <span class="n">char</span> <span class="o">==</span> <span class="s">" "</span><span class="p">]</span>
</code></pre></div></div>

<p>To explain in detail, there are 19 initial consonants * 21 vowels * 28 final consonants (including no-final) = 11,172 possible combinations for modern Hangul syllables.
And they occupy consecutive Unicode code points staring at <code class="language-plaintext highlighter-rouge">U+AC00</code> (“가”, 44032) and ending at <code class="language-plaintext highlighter-rouge">U+D7A3</code> (“힣”, 55203).
So, the check <code class="language-plaintext highlighter-rouge">if "가" &lt;= character &lt;= "힣":</code> ensures we only apply the decomposition math to Hangul syllables.</p>

<p>The Unicode code points for Hangul syllables advance first by final consonant, then by medial vowel, and last by initial consonant.
For example:</p>
<ul>
  <li>by 28 final consonant:
    <ul>
      <li>가: 44032</li>
      <li>각: 44033 (advanced by 1)</li>
      <li>갂: 44034</li>
      <li>…</li>
    </ul>
  </li>
  <li>by 21 medial vowel:
    <ul>
      <li>가: 44032</li>
      <li>개: 44060 (advanced by 28)</li>
      <li>갸: 44088</li>
      <li>…</li>
    </ul>
  </li>
  <li>by 19 initial consonant:
    <ul>
      <li>가: 44032</li>
      <li>까: 44620 (advanced by 21 * 28)</li>
      <li>나: 45208</li>
      <li>…</li>
      <li>힣: 55203</li>
    </ul>
  </li>
</ul>

<p>Thus, every Hangul syllable code point can be thought of as:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>code_point = 0xAC00 + (initial_index * 21 * 28) + (medial_index * 28) + final_index
</code></pre></div></div>
<p>where <code class="language-plaintext highlighter-rouge">initial_index</code>, <code class="language-plaintext highlighter-rouge">medial_index</code>, and <code class="language-plaintext highlighter-rouge">final_index</code> are zero-based positions in the respective jamo lists.</p>

<p>Therefore:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">offset</span> <span class="o">=</span> <span class="nb">ord</span><span class="p">(</span><span class="n">character</span><span class="p">)</span> <span class="o">-</span> <span class="nb">ord</span><span class="p">(</span><span class="s">"가"</span><span class="p">)</span>  <span class="c1"># ord("가") == 0xAC00 == 44032
</span><span class="n">choseong</span> <span class="o">=</span> <span class="n">offset</span> <span class="o">//</span> <span class="mi">588</span>  <span class="c1"># initial_index (0–18)
</span><span class="n">jungseong</span> <span class="o">=</span> <span class="p">(</span><span class="n">offset</span> <span class="o">-</span> <span class="p">(</span><span class="mi">588</span> <span class="o">*</span> <span class="n">choseong</span><span class="p">))</span> <span class="o">//</span> <span class="mi">28</span>  <span class="c1"># medial_index (0–20)
</span><span class="n">jongseong</span> <span class="o">=</span> <span class="n">offset</span> <span class="o">-</span> <span class="p">(</span><span class="mi">588</span> <span class="o">*</span> <span class="n">choseong</span><span class="p">)</span> <span class="o">-</span> <span class="p">(</span><span class="mi">28</span> <span class="o">*</span> <span class="n">jungseong</span><span class="p">)</span>  <span class="c1"># final_index (0–27)
</span></code></pre></div></div>
<p>(588 here is just the precomputed value for 21 * 28.)</p>

<p>For example:</p>

<table>
  <thead>
    <tr>
      <th>character</th>
      <th>code point</th>
      <th>offset</th>
      <th>choseong</th>
      <th>jungseong</th>
      <th>jongseong</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>가</td>
      <td>44032</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
    </tr>
    <tr>
      <td>각</td>
      <td>44033</td>
      <td>1</td>
      <td>0</td>
      <td>0</td>
      <td>1</td>
    </tr>
    <tr>
      <td>힣</td>
      <td>55203</td>
      <td>11171</td>
      <td>18</td>
      <td>20</td>
      <td>27</td>
    </tr>
  </tbody>
</table>

<p>Once you have those three indices, you simply look up the corresponding jamo in the three lists:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">CHOSEONGS</span><span class="p">[</span><span class="n">choseong</span><span class="p">],</span> <span class="n">JUNGSEONGS</span><span class="p">[</span><span class="n">jungseong</span><span class="p">],</span> <span class="n">JONGSEONGS</span><span class="p">[</span><span class="n">jongseong</span><span class="p">]</span>
</code></pre></div></div>

<p>As a result, the <code class="language-plaintext highlighter-rouge">decompose_syllables</code> takes a string like <code class="language-plaintext highlighter-rouge">사과</code> and turns it into <code class="language-plaintext highlighter-rouge">["ㅅ", "ㅏ", "ㄱ", "ㅘ"]</code>.</p>

<p>This utility function is used to decompose the syllables and feed that into the <code class="language-plaintext highlighter-rouge">suggest</code> field while the original keyword goes into <code class="language-plaintext highlighter-rouge">keyword</code> field:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># update_keywords1.py
</span><span class="kn">from</span> <span class="nn">Hangul</span> <span class="kn">import</span> <span class="n">decompose_syllables</span>

<span class="p">...</span>

<span class="k">def</span> <span class="nf">_update_search_keywords</span><span class="p">(</span><span class="n">es_client</span><span class="p">:</span> <span class="n">Elasticsearch</span><span class="p">,</span> <span class="n">keywords</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="n">Keyword</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">int</span><span class="p">:</span>
    <span class="s">"""
    Update the autocomplete index with new data.
    Return the number of successfully executed actions (including overrides).
    """</span>
    <span class="n">index</span> <span class="o">=</span> <span class="s">"search-keywords"</span>

    <span class="n">actions</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">keyword</span> <span class="ow">in</span> <span class="n">keywords</span><span class="p">:</span>
        <span class="n">action</span> <span class="o">=</span> <span class="p">{</span>
            <span class="s">"_op_type"</span><span class="p">:</span> <span class="s">"index"</span><span class="p">,</span>
            <span class="s">"_index"</span><span class="p">:</span> <span class="n">index</span><span class="p">,</span>
            <span class="s">"_id"</span><span class="p">:</span> <span class="n">keyword</span><span class="p">.</span><span class="n">key</span><span class="p">,</span>
            <span class="s">"_source"</span><span class="p">:</span> <span class="p">{</span>
                <span class="s">"suggest"</span><span class="p">:</span> <span class="p">{</span>
                    <span class="s">"input"</span><span class="p">:</span> <span class="p">[</span>
                        <span class="s">""</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">decompose_syllables</span><span class="p">(</span><span class="n">keyword</span><span class="p">.</span><span class="n">key</span><span class="p">)),</span>  <span class="c1"># changed
</span>                    <span class="p">],</span>
                    <span class="s">"weight"</span><span class="p">:</span> <span class="n">keyword</span><span class="p">.</span><span class="n">count</span><span class="p">,</span>
                <span class="p">},</span>
                <span class="s">"keyword"</span><span class="p">:</span> <span class="n">keyword</span><span class="p">.</span><span class="n">key</span><span class="p">,</span>
            <span class="p">},</span>
        <span class="p">}</span>
        <span class="n">actions</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">action</span><span class="p">)</span>

    <span class="n">res</span> <span class="o">=</span> <span class="n">helpers</span><span class="p">.</span><span class="n">bulk</span><span class="p">(</span><span class="n">es_client</span><span class="p">,</span> <span class="n">actions</span><span class="p">)</span>

    <span class="k">return</span> <span class="n">res</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
</code></pre></div></div>

<p>Running this code will output the same as the previous one:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Success</span> <span class="n">count</span><span class="p">:</span> <span class="mi">7</span>
</code></pre></div></div>

<p>Next, modify the FastAPI endpoint to:</p>
<ul>
  <li>Decompose the user query.</li>
  <li>Use the decomposed form for prefix search.</li>
  <li>Return the original keywords (from <code class="language-plaintext highlighter-rouge">keyword</code> field) as the suggestions.</li>
</ul>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app.py
</span><span class="kn">from</span> <span class="nn">Hangul</span> <span class="kn">import</span> <span class="n">decompose_syllables</span>

<span class="p">...</span>

<span class="o">@</span><span class="n">app</span><span class="p">.</span><span class="n">get</span><span class="p">(</span>
    <span class="s">"/keyword-suggestions"</span><span class="p">,</span>
    <span class="n">response_model</span><span class="o">=</span><span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">],</span>
    <span class="n">summary</span><span class="o">=</span><span class="s">"Suggest keywords for a given query"</span><span class="p">,</span>
    <span class="n">description</span><span class="o">=</span><span class="s">"Returns a list of keyword suggestions based on the input query."</span><span class="p">,</span>
<span class="p">)</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">suggest_keywords</span><span class="p">(</span>
    <span class="n">es_client</span><span class="p">:</span> <span class="n">Annotated</span><span class="p">[</span><span class="n">AsyncElasticsearch</span><span class="p">,</span> <span class="n">Depends</span><span class="p">(</span><span class="n">get_es_client</span><span class="p">)],</span>
    <span class="n">query</span><span class="p">:</span> <span class="n">Annotated</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Query</span><span class="p">(</span><span class="n">description</span><span class="o">=</span><span class="s">"User input query for keyword suggestions"</span><span class="p">)],</span>
    <span class="n">limit</span><span class="p">:</span> <span class="n">Annotated</span><span class="p">[</span><span class="nb">int</span><span class="p">,</span> <span class="n">Query</span><span class="p">(</span><span class="n">description</span><span class="o">=</span><span class="s">"Maximum number of keywords to return"</span><span class="p">,</span> <span class="n">gt</span><span class="o">=</span><span class="mi">0</span><span class="p">)]</span> <span class="o">=</span> <span class="mi">10</span><span class="p">,</span>
<span class="p">):</span>
    <span class="n">body</span> <span class="o">=</span> <span class="p">{</span>
        <span class="s">"suggest"</span><span class="p">:</span> <span class="p">{</span>
            <span class="s">"keyword-suggest"</span><span class="p">:</span> <span class="p">{</span>
                <span class="s">"prefix"</span><span class="p">:</span> <span class="s">""</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">decompose_syllables</span><span class="p">(</span><span class="n">query</span><span class="p">)),</span>  <span class="c1"># changed
</span>                <span class="s">"completion"</span><span class="p">:</span> <span class="p">{</span>
                    <span class="s">"field"</span><span class="p">:</span> <span class="s">"suggest"</span><span class="p">,</span>
                    <span class="s">"size"</span><span class="p">:</span> <span class="n">limit</span><span class="p">,</span>
                <span class="p">},</span>
            <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="n">res</span> <span class="o">=</span> <span class="k">await</span> <span class="n">es_client</span><span class="p">.</span><span class="n">search</span><span class="p">(</span><span class="n">index</span><span class="o">=</span><span class="s">"search-keywords"</span><span class="p">,</span> <span class="n">body</span><span class="o">=</span><span class="n">body</span><span class="p">)</span>
    <span class="n">results</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">option</span> <span class="ow">in</span> <span class="n">res</span><span class="p">[</span><span class="s">"suggest"</span><span class="p">][</span><span class="s">"keyword-suggest"</span><span class="p">][</span><span class="mi">0</span><span class="p">][</span><span class="s">"options"</span><span class="p">]:</span>
        <span class="n">results</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">option</span><span class="p">[</span><span class="s">"_source"</span><span class="p">][</span><span class="s">"keyword"</span><span class="p">])</span>  <span class="c1"># changed
</span>
    <span class="k">return</span> <span class="n">results</span>
</code></pre></div></div>

<p>With these changes, autocomplet becomes more responsive, like this:</p>

<table>
  <thead>
    <tr>
      <th>query</th>
      <th>results</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ㄱ</td>
      <td>가방, 가구, 간식, 겨울 코트</td>
    </tr>
    <tr>
      <td>가</td>
      <td>가방, 가구, 간식</td>
    </tr>
    <tr>
      <td>갑</td>
      <td>가방</td>
    </tr>
  </tbody>
</table>

<p>One limitation of this improvement is that people sometimes search with first consonants, like <code class="language-plaintext highlighter-rouge">ㄱㄱ</code> for <code class="language-plaintext highlighter-rouge">가구</code> and <code class="language-plaintext highlighter-rouge">ㅅㄱ</code> for <code class="language-plaintext highlighter-rouge">사과</code>.</p>

<h3 id="version2-initial-consonant-search">Version2: Initial consonant search</h3>

<p>Initial consonant (초성) search is one of the unique traits of the Korean language.
Instead of typing full syllables, people often just type the initial sounds.
For example, to search for “맥도날드” (McDonald’s), you might just type <code class="language-plaintext highlighter-rouge">ㅁㄷㄴㄷ</code>.</p>

<p>To make this work, add this function to <code class="language-plaintext highlighter-rouge">Hangul.py</code>:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Hangul.py
</span>
<span class="p">...</span>

<span class="k">def</span> <span class="nf">extract_choseongs</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]:</span>
    <span class="s">"""
    Extracts the initial consonants (choseong) from Korean text.

    Example:
        "사과" -&gt; ["ㅅ", "ㄱ"]
    """</span>
    <span class="n">result</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">character</span> <span class="ow">in</span> <span class="n">text</span><span class="p">.</span><span class="n">replace</span><span class="p">(</span><span class="s">" "</span><span class="p">,</span> <span class="s">""</span><span class="p">).</span><span class="n">strip</span><span class="p">():</span>
        <span class="k">if</span> <span class="s">"가"</span> <span class="o">&lt;=</span> <span class="n">character</span> <span class="o">&lt;=</span> <span class="s">"힣"</span><span class="p">:</span>  <span class="c1"># For Hangul syllables
</span>            <span class="n">code</span> <span class="o">=</span> <span class="nb">ord</span><span class="p">(</span><span class="n">character</span><span class="p">)</span> <span class="o">-</span> <span class="nb">ord</span><span class="p">(</span><span class="s">"가"</span><span class="p">)</span>
            <span class="n">choseong</span> <span class="o">=</span> <span class="n">code</span> <span class="o">//</span> <span class="p">(</span><span class="mi">21</span> <span class="o">*</span> <span class="mi">28</span><span class="p">)</span>
            <span class="n">result</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">CHOSEONGS</span><span class="p">[</span><span class="n">choseong</span><span class="p">])</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="n">result</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">character</span><span class="p">)</span>

    <span class="k">return</span> <span class="n">result</span>
</code></pre></div></div>

<p>This function simply extracts first consonant (choseong) from each Hangul syllable and returns as a list of them.
For example:</p>

<table>
  <thead>
    <tr>
      <th>Input</th>
      <th>Output</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>“가구”</td>
      <td>[“ㄱ”, “ㄱ”]</td>
    </tr>
    <tr>
      <td>“가방”</td>
      <td>[“ㄱ”, “ㅂ”]</td>
    </tr>
    <tr>
      <td>“사과”</td>
      <td>[“ㅅ”, “ㄱ”]</td>
    </tr>
  </tbody>
</table>

<p>Now we update our keyword indexing logic to include initial consonants in the <code class="language-plaintext highlighter-rouge">suggest.input</code> array:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># update_keywords2.py
</span><span class="kn">from</span> <span class="nn">Hangul</span> <span class="kn">import</span> <span class="n">decompose_syllables</span><span class="p">,</span> <span class="n">extract_choseongs</span>

<span class="p">...</span>

<span class="k">def</span> <span class="nf">_update_search_keywords</span><span class="p">(</span><span class="n">es_client</span><span class="p">:</span> <span class="n">Elasticsearch</span><span class="p">,</span> <span class="n">keywords</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="n">Keyword</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">int</span><span class="p">:</span>
    <span class="s">"""
    Update the autocomplete index with new data.
    Return the number of successfully executed actions (including overrides).
    """</span>
    <span class="n">index</span> <span class="o">=</span> <span class="s">"search-keywords"</span>

    <span class="n">actions</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">keyword</span> <span class="ow">in</span> <span class="n">keywords</span><span class="p">:</span>
        <span class="n">action</span> <span class="o">=</span> <span class="p">{</span>
            <span class="s">"_op_type"</span><span class="p">:</span> <span class="s">"index"</span><span class="p">,</span>
            <span class="s">"_index"</span><span class="p">:</span> <span class="n">index</span><span class="p">,</span>
            <span class="s">"_id"</span><span class="p">:</span> <span class="n">keyword</span><span class="p">.</span><span class="n">key</span><span class="p">,</span>
            <span class="s">"_source"</span><span class="p">:</span> <span class="p">{</span>
                <span class="s">"suggest"</span><span class="p">:</span> <span class="p">{</span>
                    <span class="s">"input"</span><span class="p">:</span> <span class="nb">list</span><span class="p">(</span>
                        <span class="nb">set</span><span class="p">(</span>  <span class="c1"># Remove duplicate items if keyword is not Korean
</span>                            <span class="p">[</span>
                                <span class="s">""</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">decompose_syllables</span><span class="p">(</span><span class="n">keyword</span><span class="p">.</span><span class="n">key</span><span class="p">)),</span>
                                <span class="s">""</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">extract_choseongs</span><span class="p">(</span><span class="n">keyword</span><span class="p">.</span><span class="n">key</span><span class="p">)),</span>  <span class="c1"># new line
</span>                            <span class="p">]</span>
                        <span class="p">)</span>
                    <span class="p">),</span>
                    <span class="s">"weight"</span><span class="p">:</span> <span class="n">keyword</span><span class="p">.</span><span class="n">count</span><span class="p">,</span>
                <span class="p">},</span>
                <span class="s">"keyword"</span><span class="p">:</span> <span class="n">keyword</span><span class="p">.</span><span class="n">key</span><span class="p">,</span>
            <span class="p">},</span>
        <span class="p">}</span>
        <span class="n">actions</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">action</span><span class="p">)</span>

    <span class="n">res</span> <span class="o">=</span> <span class="n">helpers</span><span class="p">.</span><span class="n">bulk</span><span class="p">(</span><span class="n">es_client</span><span class="p">,</span> <span class="n">actions</span><span class="p">)</span>

    <span class="k">return</span> <span class="n">res</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
</code></pre></div></div>

<p>This way, our autocomplete can handle initial consonant input.
For example:</p>

<table>
  <thead>
    <tr>
      <th>query</th>
      <th>results</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ㄱㄱ</td>
      <td>가구</td>
    </tr>
    <tr>
      <td>ㄱㅇㅋㅌ</td>
      <td>겨울 코트</td>
    </tr>
  </tbody>
</table>

<p>Our query auto-completion feature now feels a lot better now, but there’s still room to improve.
What’s the common problem we haven’t catched yet?
It’s that users sometimes type Korean words on an English keyboard — like <code class="language-plaintext highlighter-rouge">tkrhk</code> when they meant <code class="language-plaintext highlighter-rouge">사과</code>.</p>

<h3 id="version3-latin-to-jamo-mapping">Version3: Latin-to-Jamo mapping</h3>

<p>To type in Korean, users need to switch their keyboard to the Korean layout.
But it’s super common for people to forget and just start typing in English mode whether they’re on a phone or a PC.</p>

<p>For example, someone trying to search for <code class="language-plaintext highlighter-rouge">가구</code> might accidentally type <code class="language-plaintext highlighter-rouge">rkrn</code>—because that’s what shows up when you type <code class="language-plaintext highlighter-rouge">가구</code> on a standard Korean keyboard while in English mode.</p>

<p>To automatically correct such typos, we map Korean syllables to their corresponding Latin keyboard inputs so the autocomplete system can recognize and suggest the intended Korean keywords even when users accidentally type using the English keyboard layout.</p>

<p>Here’s a utility function that maps Hangul jamo to their Latin keyboard equivalents.
Add this to <code class="language-plaintext highlighter-rouge">Hangul.py</code>:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Hangul.py
</span>
<span class="p">...</span>

<span class="n">ENG_TO_KOR</span> <span class="o">=</span> <span class="p">{}</span>
<span class="c1"># Consonants
</span><span class="n">ENG_TO_KOR</span><span class="p">.</span><span class="n">update</span><span class="p">({</span><span class="s">"r"</span><span class="p">:</span> <span class="s">"ㄱ"</span><span class="p">,</span> <span class="s">"R"</span><span class="p">:</span> <span class="s">"ㄲ"</span><span class="p">,</span> <span class="s">"rt"</span><span class="p">:</span> <span class="s">"ㄳ"</span><span class="p">,</span> <span class="s">"s"</span><span class="p">:</span> <span class="s">"ㄴ"</span><span class="p">})</span>
<span class="n">ENG_TO_KOR</span><span class="p">.</span><span class="n">update</span><span class="p">({</span><span class="s">"sw"</span><span class="p">:</span> <span class="s">"ㄵ"</span><span class="p">,</span> <span class="s">"sg"</span><span class="p">:</span> <span class="s">"ㄶ"</span><span class="p">,</span> <span class="s">"e"</span><span class="p">:</span> <span class="s">"ㄷ"</span><span class="p">,</span> <span class="s">"E"</span><span class="p">:</span> <span class="s">"ㄸ"</span><span class="p">})</span>
<span class="n">ENG_TO_KOR</span><span class="p">.</span><span class="n">update</span><span class="p">({</span><span class="s">"f"</span><span class="p">:</span> <span class="s">"ㄹ"</span><span class="p">,</span> <span class="s">"fr"</span><span class="p">:</span> <span class="s">"ㄺ"</span><span class="p">,</span> <span class="s">"fa"</span><span class="p">:</span> <span class="s">"ㄻ"</span><span class="p">,</span> <span class="s">"fq"</span><span class="p">:</span> <span class="s">"ㄼ"</span><span class="p">})</span>
<span class="n">ENG_TO_KOR</span><span class="p">.</span><span class="n">update</span><span class="p">({</span><span class="s">"ft"</span><span class="p">:</span> <span class="s">"ㄽ"</span><span class="p">,</span> <span class="s">"fx"</span><span class="p">:</span> <span class="s">"ㄾ"</span><span class="p">,</span> <span class="s">"fv"</span><span class="p">:</span> <span class="s">"ㄿ"</span><span class="p">,</span> <span class="s">"fg"</span><span class="p">:</span> <span class="s">"ㅀ"</span><span class="p">})</span>
<span class="n">ENG_TO_KOR</span><span class="p">.</span><span class="n">update</span><span class="p">({</span><span class="s">"a"</span><span class="p">:</span> <span class="s">"ㅁ"</span><span class="p">,</span> <span class="s">"q"</span><span class="p">:</span> <span class="s">"ㅂ"</span><span class="p">,</span> <span class="s">"Q"</span><span class="p">:</span> <span class="s">"ㅃ"</span><span class="p">,</span> <span class="s">"qt"</span><span class="p">:</span> <span class="s">"ㅄ"</span><span class="p">})</span>
<span class="n">ENG_TO_KOR</span><span class="p">.</span><span class="n">update</span><span class="p">({</span><span class="s">"t"</span><span class="p">:</span> <span class="s">"ㅅ"</span><span class="p">,</span> <span class="s">"T"</span><span class="p">:</span> <span class="s">"ㅆ"</span><span class="p">,</span> <span class="s">"d"</span><span class="p">:</span> <span class="s">"ㅇ"</span><span class="p">,</span> <span class="s">"w"</span><span class="p">:</span> <span class="s">"ㅈ"</span><span class="p">})</span>
<span class="n">ENG_TO_KOR</span><span class="p">.</span><span class="n">update</span><span class="p">({</span><span class="s">"W"</span><span class="p">:</span> <span class="s">"ㅉ"</span><span class="p">,</span> <span class="s">"c"</span><span class="p">:</span> <span class="s">"ㅊ"</span><span class="p">,</span> <span class="s">"z"</span><span class="p">:</span> <span class="s">"ㅋ"</span><span class="p">,</span> <span class="s">"x"</span><span class="p">:</span> <span class="s">"ㅌ"</span><span class="p">})</span>
<span class="n">ENG_TO_KOR</span><span class="p">.</span><span class="n">update</span><span class="p">({</span><span class="s">"v"</span><span class="p">:</span> <span class="s">"ㅍ"</span><span class="p">,</span> <span class="s">"g"</span><span class="p">:</span> <span class="s">"ㅎ"</span><span class="p">})</span>
<span class="c1"># Vowels
</span><span class="n">ENG_TO_KOR</span><span class="p">.</span><span class="n">update</span><span class="p">({</span><span class="s">"k"</span><span class="p">:</span> <span class="s">"ㅏ"</span><span class="p">,</span> <span class="s">"o"</span><span class="p">:</span> <span class="s">"ㅐ"</span><span class="p">,</span> <span class="s">"i"</span><span class="p">:</span> <span class="s">"ㅑ"</span><span class="p">,</span> <span class="s">"O"</span><span class="p">:</span> <span class="s">"ㅒ"</span><span class="p">})</span>
<span class="n">ENG_TO_KOR</span><span class="p">.</span><span class="n">update</span><span class="p">({</span><span class="s">"j"</span><span class="p">:</span> <span class="s">"ㅓ"</span><span class="p">,</span> <span class="s">"p"</span><span class="p">:</span> <span class="s">"ㅔ"</span><span class="p">,</span> <span class="s">"u"</span><span class="p">:</span> <span class="s">"ㅕ"</span><span class="p">,</span> <span class="s">"P"</span><span class="p">:</span> <span class="s">"ㅖ"</span><span class="p">})</span>
<span class="n">ENG_TO_KOR</span><span class="p">.</span><span class="n">update</span><span class="p">({</span><span class="s">"h"</span><span class="p">:</span> <span class="s">"ㅗ"</span><span class="p">,</span> <span class="s">"hk"</span><span class="p">:</span> <span class="s">"ㅘ"</span><span class="p">,</span> <span class="s">"ho"</span><span class="p">:</span> <span class="s">"ㅙ"</span><span class="p">,</span> <span class="s">"hl"</span><span class="p">:</span> <span class="s">"ㅚ"</span><span class="p">})</span>
<span class="n">ENG_TO_KOR</span><span class="p">.</span><span class="n">update</span><span class="p">({</span><span class="s">"y"</span><span class="p">:</span> <span class="s">"ㅛ"</span><span class="p">,</span> <span class="s">"n"</span><span class="p">:</span> <span class="s">"ㅜ"</span><span class="p">,</span> <span class="s">"nj"</span><span class="p">:</span> <span class="s">"ㅝ"</span><span class="p">,</span> <span class="s">"np"</span><span class="p">:</span> <span class="s">"ㅞ"</span><span class="p">})</span>
<span class="n">ENG_TO_KOR</span><span class="p">.</span><span class="n">update</span><span class="p">({</span><span class="s">"nl"</span><span class="p">:</span> <span class="s">"ㅟ"</span><span class="p">,</span> <span class="s">"b"</span><span class="p">:</span> <span class="s">"ㅠ"</span><span class="p">,</span> <span class="s">"m"</span><span class="p">:</span> <span class="s">"ㅡ"</span><span class="p">,</span> <span class="s">"ml"</span><span class="p">:</span> <span class="s">"ㅢ"</span><span class="p">})</span>
<span class="n">ENG_TO_KOR</span><span class="p">.</span><span class="n">update</span><span class="p">({</span><span class="s">"l"</span><span class="p">:</span> <span class="s">"ㅣ"</span><span class="p">})</span>

<span class="c1"># Create a reverse mapping from Korean to English
</span><span class="n">KOR_TO_ENG</span> <span class="o">=</span> <span class="p">{</span><span class="n">v</span><span class="p">:</span> <span class="n">k</span> <span class="k">for</span> <span class="n">k</span><span class="p">,</span> <span class="n">v</span> <span class="ow">in</span> <span class="n">ENG_TO_KOR</span><span class="p">.</span><span class="n">items</span><span class="p">()}</span>

<span class="k">def</span> <span class="nf">convert_jamo_to_latin</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
    <span class="s">"""
    Convert Hangul jamo (consonants and vowels) to a romanized form based on the standard Korean keyboard layout.

    Example:
        "사과" -&gt; "tkrhk"
    """</span>
    <span class="n">jamos</span> <span class="o">=</span> <span class="n">decompose_syllables</span><span class="p">(</span><span class="n">text</span><span class="o">=</span><span class="n">text</span><span class="p">)</span>
    <span class="n">result</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">jamo</span> <span class="ow">in</span> <span class="n">jamos</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">jamo</span> <span class="ow">in</span> <span class="n">KOR_TO_ENG</span><span class="p">:</span>
            <span class="n">result</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">KOR_TO_ENG</span><span class="p">[</span><span class="n">jamo</span><span class="p">])</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="n">result</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">jamo</span><span class="p">)</span>
    <span class="k">return</span> <span class="s">""</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">result</span><span class="p">)</span>
</code></pre></div></div>

<p>What this does:</p>
<ul>
  <li>Decompose Korean syllables into jamos.</li>
  <li>Convert each jamo to its QWERTY key equivalent.</li>
  <li>Join the result back into a string.</li>
</ul>

<p>For example:</p>

<table>
  <thead>
    <tr>
      <th>Input</th>
      <th>Output</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>가구</td>
      <td>rkrn</td>
    </tr>
    <tr>
      <td>가방</td>
      <td>rkqkd</td>
    </tr>
    <tr>
      <td>사과</td>
      <td>tkrhk</td>
    </tr>
  </tbody>
</table>

<p>After that, update <code class="language-plaintext highlighter-rouge">_update_search_keywords</code> function to include these typo-prone Latin inputs in the <code class="language-plaintext highlighter-rouge">suggest.input</code> array:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># update_keywords3.py
</span><span class="kn">from</span> <span class="nn">Hangul</span> <span class="kn">import</span> <span class="n">convert_jamo_to_latin</span><span class="p">,</span> <span class="n">decompose_syllables</span><span class="p">,</span> <span class="n">extract_choseongs</span>

<span class="p">...</span>

<span class="k">def</span> <span class="nf">_update_search_keywords</span><span class="p">(</span><span class="n">es_client</span><span class="p">:</span> <span class="n">Elasticsearch</span><span class="p">,</span> <span class="n">keywords</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="n">Keyword</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">int</span><span class="p">:</span>
    <span class="s">"""
    Update the autocomplete index with new data.
    Return the number of successfully executed actions (including overrides).
    """</span>
    <span class="n">index</span> <span class="o">=</span> <span class="s">"search-keywords"</span>

    <span class="n">actions</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">keyword</span> <span class="ow">in</span> <span class="n">keywords</span><span class="p">:</span>
        <span class="n">action</span> <span class="o">=</span> <span class="p">{</span>
            <span class="s">"_op_type"</span><span class="p">:</span> <span class="s">"index"</span><span class="p">,</span>
            <span class="s">"_index"</span><span class="p">:</span> <span class="n">index</span><span class="p">,</span>
            <span class="s">"_id"</span><span class="p">:</span> <span class="n">keyword</span><span class="p">.</span><span class="n">key</span><span class="p">,</span>
            <span class="s">"_source"</span><span class="p">:</span> <span class="p">{</span>
                <span class="s">"suggest"</span><span class="p">:</span> <span class="p">{</span>
                    <span class="s">"input"</span><span class="p">:</span> <span class="nb">list</span><span class="p">(</span>
                        <span class="nb">set</span><span class="p">(</span>  <span class="c1"># Remove duplicate items if keyword is not Korean
</span>                            <span class="p">[</span>
                                <span class="s">""</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">decompose_syllables</span><span class="p">(</span><span class="n">keyword</span><span class="p">.</span><span class="n">key</span><span class="p">)),</span>
                                <span class="s">""</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">extract_choseongs</span><span class="p">(</span><span class="n">keyword</span><span class="p">.</span><span class="n">key</span><span class="p">)),</span>
                                <span class="s">""</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">convert_jamo_to_latin</span><span class="p">(</span><span class="n">keyword</span><span class="p">.</span><span class="n">key</span><span class="p">)),</span>  <span class="c1"># new line
</span>                            <span class="p">]</span>
                        <span class="p">)</span>
                    <span class="p">),</span>
                    <span class="s">"weight"</span><span class="p">:</span> <span class="n">keyword</span><span class="p">.</span><span class="n">count</span><span class="p">,</span>
                <span class="p">},</span>
                <span class="s">"keyword"</span><span class="p">:</span> <span class="n">keyword</span><span class="p">.</span><span class="n">key</span><span class="p">,</span>
            <span class="p">},</span>
        <span class="p">}</span>
        <span class="n">actions</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">action</span><span class="p">)</span>

    <span class="n">res</span> <span class="o">=</span> <span class="n">helpers</span><span class="p">.</span><span class="n">bulk</span><span class="p">(</span><span class="n">es_client</span><span class="p">,</span> <span class="n">actions</span><span class="p">)</span>

    <span class="k">return</span> <span class="n">res</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
</code></pre></div></div>

<p>After running the keyword update script, here’s what the document for “사과” might look like:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"_index"</span><span class="p">:</span><span class="w"> </span><span class="s2">"search-keywords"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"사과"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"_score"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
  </span><span class="nl">"_source"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"suggest"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"input"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="s2">"ㅅㅏㄱㅘ"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"ㅅㄱ"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"tkrhk"</span><span class="w">
      </span><span class="p">],</span><span class="w">
      </span><span class="nl">"weight"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"keyword"</span><span class="p">:</span><span class="w"> </span><span class="s2">"사과"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Note that each input value is the result of the following utility functions:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">"".join(decompose_syllables("사과"))</code> -&gt; <code class="language-plaintext highlighter-rouge">ㅅㅏㄱㅘ</code></li>
  <li><code class="language-plaintext highlighter-rouge">"".join(extract_choseongs("사과"))</code> -&gt; <code class="language-plaintext highlighter-rouge">ㅅㄱ</code></li>
  <li><code class="language-plaintext highlighter-rouge">"".join(convert_jamo_to_latin("사과"))</code> -&gt; <code class="language-plaintext highlighter-rouge">tkrhk</code></li>
</ul>

<p>Even if the user mistakenly types in English mode, your autocomplete will still just work.</p>

<p>Meanwhile, the English keyword <code class="language-plaintext highlighter-rouge">airpods 4</code> stays mostly intact because we removed duplicate items when indexing <code class="language-plaintext highlighter-rouge">input</code> values:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"_index"</span><span class="p">:</span><span class="w"> </span><span class="s2">"search-keywords"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"airpods 4"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"_score"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
  </span><span class="nl">"_source"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"suggest"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"input"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="s2">"airpods4"</span><span class="w">
      </span><span class="p">],</span><span class="w">
      </span><span class="nl">"weight"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"keyword"</span><span class="p">:</span><span class="w"> </span><span class="s2">"airpods 4"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h2 id="demonstration-with-search-box">Demonstration with search box</h2>

<p>Up until now, we’ve been testing things without a proper UI with actual search box.</p>

<p>Here’s a simple HTML + JavaScript setup you can use to test the example in action.
It talks to a backend endpoint that returns suggestions based on user input.</p>

<p>Just save the code below as an <code class="language-plaintext highlighter-rouge">.html</code> file and open it in your browser:</p>
<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;!DOCTYPE html&gt;</span>

<span class="nt">&lt;head&gt;</span>
    <span class="nt">&lt;meta</span> <span class="na">charset=</span><span class="s">"UTF-8"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;title&gt;</span>Search Suggestions<span class="nt">&lt;/title&gt;</span>
    <span class="nt">&lt;link</span> <span class="na">href=</span><span class="s">"https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css"</span> <span class="na">rel=</span><span class="s">"stylesheet"</span><span class="nt">&gt;</span>
<span class="nt">&lt;/head&gt;</span>

<span class="nt">&lt;body&gt;</span>
    <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"container mt-5"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"row"</span><span class="nt">&gt;</span>
            <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"col-md-6 mx-auto"</span><span class="nt">&gt;</span>
                <span class="nt">&lt;input</span> <span class="na">id=</span><span class="s">"search"</span> <span class="na">type=</span><span class="s">"text"</span> <span class="na">class=</span><span class="s">"form-control"</span> <span class="na">placeholder=</span><span class="s">"Search"</span><span class="nt">&gt;</span>
                <span class="nt">&lt;ul</span> <span class="na">id=</span><span class="s">"results"</span> <span class="na">class=</span><span class="s">"list-group mt-2"</span><span class="nt">&gt;&lt;/ul&gt;</span>
            <span class="nt">&lt;/div&gt;</span>
        <span class="nt">&lt;/div&gt;</span>
    <span class="nt">&lt;/div&gt;</span>

    <span class="nt">&lt;script&gt;</span>
        <span class="kd">const</span> <span class="nx">API_URL</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">http://localhost:8000/keyword-suggestions</span><span class="dl">'</span><span class="p">;</span>

        <span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="dl">'</span><span class="s1">search</span><span class="dl">'</span><span class="p">).</span><span class="nx">addEventListener</span><span class="p">(</span><span class="dl">'</span><span class="s1">input</span><span class="dl">'</span><span class="p">,</span> <span class="nx">e</span> <span class="o">=&gt;</span> <span class="p">{</span>
            <span class="nx">fetch</span><span class="p">(</span><span class="s2">`</span><span class="p">${</span><span class="nx">API_URL</span><span class="p">}</span><span class="s2">?limit=10&amp;query=</span><span class="p">${</span><span class="nx">e</span><span class="p">.</span><span class="nx">target</span><span class="p">.</span><span class="nx">value</span><span class="p">}</span><span class="s2">`</span><span class="p">)</span>
                <span class="p">.</span><span class="nx">then</span><span class="p">(</span><span class="nx">res</span> <span class="o">=&gt;</span> <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">())</span>
                <span class="p">.</span><span class="nx">then</span><span class="p">(</span><span class="nx">data</span> <span class="o">=&gt;</span> <span class="p">{</span>
                    <span class="kd">const</span> <span class="nx">results</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="dl">'</span><span class="s1">results</span><span class="dl">'</span><span class="p">);</span>
                    <span class="nx">results</span><span class="p">.</span><span class="nx">innerHTML</span> <span class="o">=</span> <span class="dl">''</span><span class="p">;</span>
                    <span class="nx">data</span><span class="p">.</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">text</span> <span class="o">=&gt;</span> <span class="p">{</span>
                        <span class="kd">const</span> <span class="nx">li</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">createElement</span><span class="p">(</span><span class="dl">'</span><span class="s1">li</span><span class="dl">'</span><span class="p">);</span>
                        <span class="nx">li</span><span class="p">.</span><span class="nx">className</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">list-group-item</span><span class="dl">'</span><span class="p">;</span>
                        <span class="nx">li</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">cursor</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">pointer</span><span class="dl">'</span><span class="p">;</span>
                        <span class="nx">li</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="nx">text</span><span class="p">;</span>
                        <span class="nx">li</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="dl">'</span><span class="s1">click</span><span class="dl">'</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
                            <span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="dl">'</span><span class="s1">search</span><span class="dl">'</span><span class="p">).</span><span class="nx">value</span> <span class="o">=</span> <span class="nx">text</span><span class="p">;</span>
                            <span class="nx">results</span><span class="p">.</span><span class="nx">innerHTML</span> <span class="o">=</span> <span class="dl">''</span><span class="p">;</span>
                        <span class="p">});</span>
                        <span class="nx">results</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">li</span><span class="p">);</span>
                    <span class="p">});</span>
                <span class="p">})</span>
                <span class="p">.</span><span class="k">catch</span><span class="p">(</span><span class="nx">err</span> <span class="o">=&gt;</span> <span class="nx">console</span><span class="p">.</span><span class="nx">error</span><span class="p">(</span><span class="nx">err</span><span class="p">));</span>
        <span class="p">});</span>
    <span class="nt">&lt;/script&gt;</span>
<span class="nt">&lt;/body&gt;</span>

<span class="nt">&lt;/html&gt;</span>
</code></pre></div></div>

<p>This is just a simple example page, but it gives you a working UI to see your suggestions in real time.
Just make sure your backend is running and serving suggestions at the URL specified (http://localhost:8000/keyword-suggestions).</p>

<p><img src="/assets/posts/36/search_box_demo.png" alt="Search box" /></p>

<h2 id="context-suggester">Context suggester</h2>

<p>Autocomplete suggestions can be fine-tuned with filters and boostings.
One useful example is to personalize suggestions—so instead of everyone seeing the same results, each user gets suggestions based on their own search history or behavior.</p>

<p>To make that happen, you can add context mappings to the <code class="language-plaintext highlighter-rouge">search-keywords</code> index.
Here’s a simple example where we define a context using <code class="language-plaintext highlighter-rouge">user_id</code>:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">PUT</span><span class="w"> </span><span class="err">/search-keywords</span><span class="w">
</span><span class="p">{</span><span class="w">
  </span><span class="nl">"mappings"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"suggest"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="err">//</span><span class="w"> </span><span class="err">you</span><span class="w"> </span><span class="err">can</span><span class="w"> </span><span class="err">use</span><span class="w"> </span><span class="err">any</span><span class="w"> </span><span class="err">name</span><span class="w"> </span><span class="err">here</span><span class="w"> </span><span class="err">instead</span><span class="w"> </span><span class="err">of</span><span class="w"> </span><span class="s2">"suggest"</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"completion"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"analyzer"</span><span class="p">:</span><span class="w"> </span><span class="s2">"standard"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"contexts"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
          </span><span class="p">{</span><span class="w">
            </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"user_id"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"category"</span><span class="w">
          </span><span class="p">}</span><span class="w">
        </span><span class="p">]</span><span class="w">
      </span><span class="p">},</span><span class="w">
      </span><span class="nl">"keyword"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"keyword"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Here, we’re using the <code class="language-plaintext highlighter-rouge">category</code> context type, which lets us tag suggestions with one or more user IDs when we index them.
When a user types something, we can filter or boost suggestions based on their context.</p>

<p>Let’s look at the indexing part.
Here’s how you’d update the <code class="language-plaintext highlighter-rouge">search-keywords</code> index with user-personalized data:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">_update_search_keywords</span><span class="p">(</span>
    <span class="n">es_client</span><span class="p">:</span> <span class="n">Elasticsearch</span><span class="p">,</span> <span class="n">keywords</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="n">Keyword</span><span class="p">],</span> <span class="n">user_ids</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">int</span><span class="p">:</span>
    <span class="s">"""
    Update the autocomplete index with new data.
    Return the number of successfully executed actions (including overrides).
    """</span>
    <span class="n">index</span> <span class="o">=</span> <span class="s">"search-keywords"</span>

    <span class="n">actions</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">keyword</span> <span class="ow">in</span> <span class="n">keywords</span><span class="p">:</span>
        <span class="n">action</span> <span class="o">=</span> <span class="p">{</span>
            <span class="s">"_op_type"</span><span class="p">:</span> <span class="s">"index"</span><span class="p">,</span>
            <span class="s">"_index"</span><span class="p">:</span> <span class="n">index</span><span class="p">,</span>
            <span class="s">"_id"</span><span class="p">:</span> <span class="n">keyword</span><span class="p">.</span><span class="n">key</span><span class="p">,</span>
            <span class="s">"_source"</span><span class="p">:</span> <span class="p">{</span>
                <span class="s">"suggest"</span><span class="p">:</span> <span class="p">{</span>
                    <span class="s">"input"</span><span class="p">:</span> <span class="p">[</span>
                        <span class="s">""</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">decompose_syllables</span><span class="p">(</span><span class="n">keyword</span><span class="p">.</span><span class="n">key</span><span class="p">)),</span>
                        <span class="s">""</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">extract_choseongs</span><span class="p">(</span><span class="n">keyword</span><span class="p">.</span><span class="n">key</span><span class="p">)),</span>
                        <span class="s">""</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">convert_jamo_to_latin</span><span class="p">(</span><span class="n">keyword</span><span class="p">.</span><span class="n">key</span><span class="p">)),</span>
                    <span class="p">],</span>
                    <span class="s">"weight"</span><span class="p">:</span> <span class="n">keyword</span><span class="p">.</span><span class="n">count</span><span class="p">,</span>
                    <span class="s">"contexts"</span><span class="p">:</span> <span class="p">{</span>  <span class="c1"># new line
</span>                        <span class="s">"user_id"</span><span class="p">:</span> <span class="n">user_ids</span><span class="p">,</span>
                    <span class="p">},</span>
                <span class="p">},</span>
                <span class="s">"keyword"</span><span class="p">:</span> <span class="n">keyword</span><span class="p">.</span><span class="n">key</span><span class="p">,</span>
            <span class="p">},</span>
        <span class="p">}</span>
        <span class="n">actions</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">action</span><span class="p">)</span>

    <span class="n">res</span> <span class="o">=</span> <span class="n">helpers</span><span class="p">.</span><span class="n">bulk</span><span class="p">(</span><span class="n">es_client</span><span class="p">,</span> <span class="n">actions</span><span class="p">)</span>

    <span class="k">return</span> <span class="n">res</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
</code></pre></div></div>

<p>In the querying part, just plug the <code class="language-plaintext highlighter-rouge">user_id</code> context into your search body like so:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">...</span>

<span class="n">body</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s">"suggest"</span><span class="p">:</span> <span class="p">{</span>
        <span class="s">"keyword-suggest"</span><span class="p">:</span> <span class="p">{</span>
            <span class="s">"prefix"</span><span class="p">:</span> <span class="s">""</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">decompose_syllables</span><span class="p">(</span><span class="n">query</span><span class="p">)),</span>
            <span class="s">"completion"</span><span class="p">:</span> <span class="p">{</span>
                <span class="s">"field"</span><span class="p">:</span> <span class="s">"suggest"</span><span class="p">,</span>
                <span class="s">"size"</span><span class="p">:</span> <span class="n">limit</span><span class="p">,</span>
                <span class="s">"contexts"</span><span class="p">:</span> <span class="p">{</span><span class="s">"user_id"</span><span class="p">:</span> <span class="p">[</span><span class="s">"user1"</span><span class="p">]},</span>  <span class="c1"># new line
</span>            <span class="p">},</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This way, you’ve added personalized query suggestions to your search experience.</p>

<p class="notice--info">As of Elasticsearch 8.13.4, you can define up to 10 context mappings per completion field.</p>

<p>To tear down the indices after the demonstration:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">DELETE</span><span class="w"> </span><span class="err">/search-logs</span><span class="w">
</span><span class="err">DELETE</span><span class="w"> </span><span class="err">/search-keywords</span><span class="w">
</span></code></pre></div></div>

<h2 id="conclusion">Conclusion</h2>

<p>While you can adapt the example in this post for your own application, there’s more you can (and probably should) do—like handling special characters, supporting partial matches, and filtering out weird or meaningless suggestions.
However, once you’ve got the basics down, adding those extra layers gets a lot easier.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://en.wikipedia.org/wiki/Hangul">Hangul - Wikipedia</a></li>
  <li><a href="https://en.wikipedia.org/wiki/Hangul_Syllables">Hangul Syllables - Wikipedia</a></li>
  <li><a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.13/search-suggesters.html">Suggesters - Elasticsearch</a></li>
</ul>]]></content><author><name>Mienxiu</name></author><category term="elasticsearch" /><category term="ir" /><category term="python" /><summary type="html"><![CDATA[Query auto-completion (QAC) is a feature that suggests completions for a user’s input query. It tries to guess what you’re about to type and throws out suggestions, commonly based on past searches. Pretty much every site or app with a search box has it—Google, YouTube, Amazon, Naver, you name it. It’s just one of those things we use every day.]]></summary></entry><entry><title type="html">Solving Latin Squares as Constraint Satisfaction Problems using Backtracking Search with Heuristics</title><link href="https://mienxiu.com/solving-latin-squares-as-csps/" rel="alternate" type="text/html" title="Solving Latin Squares as Constraint Satisfaction Problems using Backtracking Search with Heuristics" /><published>2025-04-30T00:00:00+00:00</published><updated>2025-04-30T00:00:00+00:00</updated><id>https://mienxiu.com/solving-latin-squares-as-csps</id><content type="html" xml:base="https://mienxiu.com/solving-latin-squares-as-csps/"><![CDATA[<p>A <strong>Latin square</strong> is an <code class="language-plaintext highlighter-rouge">n x n</code> grid filled with <code class="language-plaintext highlighter-rouge">n</code> different symbols (commonly numbers 1 to <code class="language-plaintext highlighter-rouge">n</code>), each appearing exactly once in every row and every column.</p>

<p>An example of a 3x3 Latin square would look like the following table:</p>

<table>
  <thead>
    <tr>
      <th>1</th>
      <th>2</th>
      <th>3</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>3</td>
      <td>1</td>
      <td>2</td>
    </tr>
    <tr>
      <td>2</td>
      <td>3</td>
      <td>1</td>
    </tr>
  </tbody>
</table>

<p>In this post, I will illustrate the key concepts of constraint satisfaction problems (CSPs) and backtracking search, two fundamental techniques in AI, by applying them to solve Latin squares.</p>

<h2 id="constraint-satisfaction">Constraint Satisfaction</h2>

<p>Constraint satisfaction is a subfield of optimization that focuses on selecting the best option from a set of possible options.
It ensures that a solution meets all given constraints, such as resource limitations or time restrictions.
Once these constraints are met, the next goal is to optimize a given function, either by maximizing or minimizing it.</p>

<p>In a constraint satisfaction problem (often abbreviated as CSP), variables must be assigned values while meeting specific conditions.</p>

<p>For a computer to solve this problem, the problem must be clearly structured.
The key properties of a CSP are:</p>
<ul>
  <li>Variables: The set of possible values that each variable can take.</li>
  <li>Domains: The possible values (options) for a variable.</li>
  <li>Constraints: The specific conditions that must be satisfied when assigning values to the variables.</li>
</ul>

<p>The constraints in particular can be categorized based on how many variables they involve:</p>
<ul>
  <li>Unary Constraint: A constraint that involves only one variable, restricting the values it can take from its domain.</li>
  <li>Binary Constraint: A constraint that involves two variables, specifying the allowable pairs of values between them.</li>
</ul>

<p>One of the most well-known examples of a CSP is Sudoku.
In the context of Sudoku, the properties can be described as follows:</p>
<ul>
  <li>Variables: The empty squares on the Sudoku grid.</li>
  <li>Domains: The set <code class="language-plaintext highlighter-rouge">{1, 2, 3, 4, 5, 6, 7, 8, 9}</code>, representing the possible digits for each empty square.</li>
  <li>Constraints: No digit can repeat in any row, column, or 3x3 subgrid.</li>
</ul>

<p>In the context of a 3x3 Latin square, each property of CSP can be described as follows:</p>
<ul>
  <li>
    <p>Variables: <code class="language-plaintext highlighter-rouge">{(0, 0), (0, 1), (1, 1), (1, 0), (1, 1), (1, 2), (2, 0), (2, 1), (2, 2)}</code> (the positions of the grid)</p>

    <table>
      <thead>
        <tr>
          <th>position</th>
          <th>0</th>
          <th>1</th>
          <th>2</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>0</td>
          <td>(0, 0)</td>
          <td>(0, 1)</td>
          <td>(0, 2)</td>
        </tr>
        <tr>
          <td>1</td>
          <td>(1, 0)</td>
          <td>(1, 1)</td>
          <td>(1, 2)</td>
        </tr>
        <tr>
          <td>2</td>
          <td>(2, 0)</td>
          <td>(2, 1)</td>
          <td>(2, 2)</td>
        </tr>
      </tbody>
    </table>
  </li>
  <li>Domains: <code class="language-plaintext highlighter-rouge">{1, 2, 3}</code> (the possible values for each cell)</li>
  <li>Constraints:
    <ul>
      <li>Row ronstraints: No two cells in the same row can have the same value (e.g., <code class="language-plaintext highlighter-rouge">{(0, 0) != (0, 1), (0, 0) != (0, 2), ...}</code>).</li>
      <li>Column constraints: No two cells in the same column can have the same value (e.g., <code class="language-plaintext highlighter-rouge">{(0, 0) != (1, 0), (0, 0) != (2, 0), ...}</code>).</li>
    </ul>
  </li>
</ul>

<p>Note that the constraints defining a Latin square are all binary constraints unless there are any pre-filled cells, in which case they would be unary constraints.</p>

<p>To model the Latin square as a CSP in Python, we first define a general-purpose <code class="language-plaintext highlighter-rouge">CSP</code> class:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># csp.py
</span>

<span class="k">class</span> <span class="nc">CSP</span><span class="p">:</span>
    <span class="s">"""
    Represents a Constraint Satisfaction Problem (CSP).
    """</span>

    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">variables</span><span class="p">:</span> <span class="nb">list</span><span class="p">,</span> <span class="n">domains</span><span class="p">:</span> <span class="nb">dict</span><span class="p">,</span> <span class="n">constraints</span><span class="p">:</span> <span class="nb">list</span><span class="p">):</span>
        <span class="s">"""
        Initialize the CSP with variables, domains, and constraints.
        Args:
            variables (list): A list of variables in the CSP.
            domains (dict): A dictionary mapping each variable to a set of possible values.
            constraints (list): A list of constraints that must be satisfied.
        """</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">variables</span> <span class="o">=</span> <span class="n">variables</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">domains</span> <span class="o">=</span> <span class="n">domains</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">constraints</span> <span class="o">=</span> <span class="n">constraints</span>
</code></pre></div></div>

<p>Next, we create a <code class="language-plaintext highlighter-rouge">CSP</code> specifically for a Latin square:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># latin_square.py
</span><span class="kn">from</span> <span class="nn">csp</span> <span class="kn">import</span> <span class="n">CSP</span>


<span class="k">def</span> <span class="nf">create_latin_square</span><span class="p">(</span><span class="n">size</span><span class="p">:</span> <span class="nb">int</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">CSP</span><span class="p">:</span>
    <span class="s">"""
    Create a CSP for Latin square of given size.
    """</span>
    <span class="n">variables</span> <span class="o">=</span> <span class="p">[(</span><span class="n">i</span><span class="p">,</span> <span class="n">j</span><span class="p">)</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">size</span><span class="p">)</span> <span class="k">for</span> <span class="n">j</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">size</span><span class="p">)]</span>  <span class="c1"># variable == cell
</span>    <span class="n">_domains</span> <span class="o">=</span> <span class="p">{</span><span class="n">i</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="n">size</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)}</span>  <span class="c1"># Possible values for each cell
</span>    <span class="n">domains</span> <span class="o">=</span> <span class="p">{</span><span class="n">variable</span><span class="p">:</span> <span class="nb">set</span><span class="p">(</span><span class="n">_domains</span><span class="p">)</span> <span class="k">for</span> <span class="n">variable</span> <span class="ow">in</span> <span class="n">variables</span><span class="p">}</span>

    <span class="c1"># Generate binary constraints for rows and columns
</span>    <span class="n">constraints</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">size</span><span class="p">):</span>
        <span class="k">for</span> <span class="n">j</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">size</span><span class="p">):</span>
            <span class="k">for</span> <span class="n">k</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">j</span> <span class="o">+</span> <span class="mi">1</span><span class="p">,</span> <span class="n">size</span><span class="p">):</span>
                <span class="n">constraints</span><span class="p">.</span><span class="n">append</span><span class="p">(((</span><span class="n">i</span><span class="p">,</span> <span class="n">j</span><span class="p">),</span> <span class="p">(</span><span class="n">i</span><span class="p">,</span> <span class="n">k</span><span class="p">)))</span>  <span class="c1"># Row constraints
</span>                <span class="n">constraints</span><span class="p">.</span><span class="n">append</span><span class="p">(((</span><span class="n">j</span><span class="p">,</span> <span class="n">i</span><span class="p">),</span> <span class="p">(</span><span class="n">k</span><span class="p">,</span> <span class="n">i</span><span class="p">)))</span>  <span class="c1"># Column constraints
</span>
    <span class="k">return</span> <span class="n">CSP</span><span class="p">(</span><span class="n">variables</span><span class="p">,</span> <span class="n">domains</span><span class="p">,</span> <span class="n">constraints</span><span class="p">)</span>
</code></pre></div></div>

<p>Once a solution is found, we can neatly print the Latin square with the function below:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># latin_square.py
</span><span class="p">...</span>


<span class="k">def</span> <span class="nf">print_latin_square</span><span class="p">(</span><span class="n">solution</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">tuple</span><span class="p">[</span><span class="nb">int</span><span class="p">,</span> <span class="nb">int</span><span class="p">],</span> <span class="nb">int</span><span class="p">]):</span>
    <span class="s">"""Print the Latin square solution as a formatted table."""</span>
    <span class="c1"># Determine the size automatically from the solution
</span>    <span class="n">coordinates</span> <span class="o">=</span> <span class="n">solution</span><span class="p">.</span><span class="n">keys</span><span class="p">()</span>
    <span class="n">size</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="nb">max</span><span class="p">(</span><span class="nb">max</span><span class="p">(</span><span class="n">i</span><span class="p">,</span> <span class="n">j</span><span class="p">)</span> <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">j</span> <span class="ow">in</span> <span class="n">coordinates</span><span class="p">)</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span>

    <span class="c1"># Print the Latin square
</span>    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Latin square Solution for </span><span class="si">{</span><span class="n">size</span><span class="si">}</span><span class="s">x</span><span class="si">{</span><span class="n">size</span><span class="si">}</span><span class="s">:"</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">size</span><span class="p">):</span>
        <span class="n">row</span> <span class="o">=</span> <span class="p">[]</span>
        <span class="k">for</span> <span class="n">j</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">size</span><span class="p">):</span>
            <span class="n">row</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="nb">str</span><span class="p">(</span><span class="n">solution</span><span class="p">[(</span><span class="n">i</span><span class="p">,</span> <span class="n">j</span><span class="p">)]))</span>
        <span class="k">print</span><span class="p">(</span><span class="s">" "</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">row</span><span class="p">))</span>
</code></pre></div></div>

<h3 id="arc-consistency">Arc Consistency</h3>

<p>While unary constraints reduce the domain of one variable, binary constraints define relationships between pairs of variables.
This relationship is called an <em>arc</em>.</p>

<p>An arc is typically denoted as <code class="language-plaintext highlighter-rouge">(X -&gt; Y)</code>, meaning:</p>
<ul>
  <li>Variable <code class="language-plaintext highlighter-rouge">X</code> is constrained in relation to variable <code class="language-plaintext highlighter-rouge">Y</code>.</li>
  <li>The constraint defines the allowable values of <code class="language-plaintext highlighter-rouge">X</code>, depending on the values of <code class="language-plaintext highlighter-rouge">Y</code>.</li>
</ul>

<p>In the context of a 3x3 Latin square, there are arcs between any two variables that are constrained by being in the same row or column.
These arcs can be represented as follows:</p>
<ul>
  <li>Same row: ((0, 0) -&gt; (0, 1)), ((0, 0) -&gt; (0, 2)), …</li>
  <li>Same column: ((0, 0) -&gt; (1, 0)), ((0, 0) -&gt; (2, 0)), …</li>
</ul>

<p>The following figure is a node graph of a 3x3 Latin square.
Each node represents a variable, and each line represents a binary constraint (an arc).</p>

<p><img src="/assets/posts/35/latin_square_node_graph.png" alt="Node graph of a 3 x 3 Latin square" /></p>

<p>Arc consistency is a property of binary constraints between two variables in a CSP.</p>

<p>In technical terms:</p>
<blockquote>
  <p>An arc <code class="language-plaintext highlighter-rouge">(X -&gt; Y)</code> is consistent if for every value in the domain of <code class="language-plaintext highlighter-rouge">X</code>, there is at least one compatible value in the domain of <code class="language-plaintext highlighter-rouge">Y</code> that satisfies the constraint between <code class="language-plaintext highlighter-rouge">X</code> and <code class="language-plaintext highlighter-rouge">Y</code>.</p>
</blockquote>

<p>Arc consistency is an important concept because it helps prune the search space by eliminating values from the variable domains that can’t possibly lead to a solution.
This significantly speeds up the solving process.
The most common algorithm used to enforce arc consistency is <strong>AC-3</strong>, which we’ll discuss later.</p>

<h2 id="backtracking">Backtracking</h2>

<p>The idea of backtracking is to build a solution incrementally, abandoning a path as soon as it becomes clear that there is no solution found.
It’s not just used in constraint satisfaction problems.
You’ll also see it in problems like puzzles and combinatorial search, where efficiently exploring all possible options really matters.</p>

<p>Here’s how it works, step by step:</p>
<ol>
  <li>Start from an empty or initial state.</li>
  <li>Make a choice to move forward (pick a possible next step).</li>
  <li>If the current path is valid, continue exploring deeper.</li>
  <li>If you hit a dead-end (no valid moves or constraints are violated), undo the last choice (backtrack) and try another option.</li>
  <li>Repeat this process until you find a complete solution or exhaust all possibilities.</li>
</ol>

<p>Basically, it’s a recursive algorithm.</p>

<p>Let’s encode this idea in Python.</p>

<p>Here’s a naive implementaion to solve a 5x5 Latin square:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># backtrack.py
</span><span class="s">"""
Naive backtracking search for Latin square CSP.
"""</span>

<span class="kn">from</span> <span class="nn">csp</span> <span class="kn">import</span> <span class="n">CSP</span>
<span class="kn">from</span> <span class="nn">latin_square</span> <span class="kn">import</span> <span class="n">create_latin_square</span><span class="p">,</span> <span class="n">print_latin_square</span>


<span class="k">def</span> <span class="nf">backtrack</span><span class="p">(</span><span class="n">assignment</span><span class="p">:</span> <span class="nb">dict</span><span class="p">,</span> <span class="n">csp</span><span class="p">:</span> <span class="n">CSP</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">dict</span> <span class="o">|</span> <span class="bp">None</span><span class="p">:</span>
    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">assignment</span><span class="p">)</span> <span class="o">==</span> <span class="nb">len</span><span class="p">(</span><span class="n">csp</span><span class="p">.</span><span class="n">variables</span><span class="p">):</span>  <span class="c1"># Return assignment if complete
</span>        <span class="k">return</span> <span class="n">assignment</span>

    <span class="n">variable</span> <span class="o">=</span> <span class="n">select_unassigned_variable</span><span class="p">(</span><span class="n">assignment</span><span class="p">,</span> <span class="n">csp</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">value</span> <span class="ow">in</span> <span class="n">get_domain_values</span><span class="p">(</span><span class="n">variable</span><span class="p">,</span> <span class="n">assignment</span><span class="p">,</span> <span class="n">csp</span><span class="p">):</span>
        <span class="n">new_assignment</span> <span class="o">=</span> <span class="n">assignment</span><span class="p">.</span><span class="n">copy</span><span class="p">()</span>
        <span class="n">new_assignment</span><span class="p">[</span><span class="n">variable</span><span class="p">]</span> <span class="o">=</span> <span class="n">value</span>
        <span class="k">if</span> <span class="n">is_consistent</span><span class="p">(</span><span class="n">new_assignment</span><span class="p">,</span> <span class="n">csp</span><span class="p">.</span><span class="n">constraints</span><span class="p">):</span>
            <span class="n">result</span> <span class="o">=</span> <span class="n">backtrack</span><span class="p">(</span><span class="n">new_assignment</span><span class="p">,</span> <span class="n">csp</span><span class="p">)</span>  <span class="c1"># Recur with the new assignment
</span>            <span class="k">if</span> <span class="n">result</span><span class="p">:</span>
                <span class="k">return</span> <span class="n">result</span>  <span class="c1"># Solution found
</span>    <span class="k">return</span> <span class="bp">None</span>  <span class="c1"># No solution found
</span>

<span class="k">def</span> <span class="nf">select_unassigned_variable</span><span class="p">(</span><span class="n">assignment</span><span class="p">:</span> <span class="nb">dict</span><span class="p">,</span> <span class="n">csp</span><span class="p">:</span> <span class="n">CSP</span><span class="p">):</span>
    <span class="k">for</span> <span class="n">variable</span> <span class="ow">in</span> <span class="n">csp</span><span class="p">.</span><span class="n">variables</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">variable</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">assignment</span><span class="p">:</span>
            <span class="k">return</span> <span class="n">variable</span>
    <span class="k">return</span> <span class="bp">None</span>


<span class="k">def</span> <span class="nf">get_domain_values</span><span class="p">(</span><span class="n">variable</span><span class="p">,</span> <span class="n">assignment</span><span class="p">:</span> <span class="nb">dict</span><span class="p">,</span> <span class="n">csp</span><span class="p">:</span> <span class="n">CSP</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">:</span>
    <span class="s">"""
    Get the domain values for a variable without specific ordering.
    """</span>
    <span class="k">return</span> <span class="n">csp</span><span class="p">.</span><span class="n">domains</span><span class="p">[</span><span class="n">variable</span><span class="p">]</span>


<span class="k">def</span> <span class="nf">is_consistent</span><span class="p">(</span><span class="n">assignment</span><span class="p">:</span> <span class="nb">dict</span><span class="p">,</span> <span class="n">constraints</span><span class="p">:</span> <span class="nb">list</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
    <span class="s">"""
    Check if the assignment is consistent with the constraints for Latin square.
    """</span>
    <span class="c1"># Check if any constraints are violated
</span>    <span class="k">for</span> <span class="n">var1</span><span class="p">,</span> <span class="n">var2</span> <span class="ow">in</span> <span class="n">constraints</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">var1</span> <span class="ow">in</span> <span class="n">assignment</span> <span class="ow">and</span> <span class="n">var2</span> <span class="ow">in</span> <span class="n">assignment</span><span class="p">:</span>
            <span class="k">if</span> <span class="n">assignment</span><span class="p">[</span><span class="n">var1</span><span class="p">]</span> <span class="o">==</span> <span class="n">assignment</span><span class="p">[</span><span class="n">var2</span><span class="p">]:</span>
                <span class="k">return</span> <span class="bp">False</span>  <span class="c1"># assignment is not consistent
</span>    <span class="k">return</span> <span class="bp">True</span>  <span class="c1"># assignment is consistent
</span>

<span class="k">if</span> <span class="n">__name__</span> <span class="o">==</span> <span class="s">"__main__"</span><span class="p">:</span>
    <span class="n">SIZE</span> <span class="o">=</span> <span class="mi">5</span>
    <span class="n">latin_square_csp</span> <span class="o">=</span> <span class="n">create_latin_square</span><span class="p">(</span><span class="n">size</span><span class="o">=</span><span class="n">SIZE</span><span class="p">)</span>

    <span class="c1"># assignment is a dictionary that maps variable to value
</span>    <span class="n">assignment</span> <span class="o">=</span> <span class="n">backtrack</span><span class="p">({},</span> <span class="n">latin_square_csp</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">assignment</span><span class="p">:</span>
        <span class="n">print_latin_square</span><span class="p">(</span><span class="n">assignment</span><span class="p">)</span>
    <span class="k">else</span><span class="p">:</span>
        <span class="k">print</span><span class="p">(</span><span class="s">"No solution found."</span><span class="p">)</span>
</code></pre></div></div>

<p>Here’s how it flows.
The <code class="language-plaintext highlighter-rouge">backtrack</code> function tries to assign values to variables one by one:</p>
<ul>
  <li>Base case: If every variable has a value (assignment complete), return the solution.</li>
  <li><code class="language-plaintext highlighter-rouge">select_unassigned_variable</code>: Pick the next unassigned variable.</li>
  <li><code class="language-plaintext highlighter-rouge">get_domain_values</code>: Try every possible value from the variable’s domain.</li>
  <li><code class="language-plaintext highlighter-rouge">is_consistent</code>: After each assignment, check if constraints (no conflicts) are satisfied.</li>
  <li>Recursion: Continue with this new assignment; if it leads to a dead end, backtrack.</li>
</ul>

<p>Running this code will output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Latin square Solution for 5x5:
1 2 3 4 5
2 1 4 5 3
3 4 5 1 2
4 5 2 3 1
5 3 1 2 4
</code></pre></div></div>

<p>Below is a visual breakdown of the step-by-step process for solving a 5x5 Latin square:</p>

<p><img src="/assets/posts/35/solving_latin_square.webp" alt="Step-by-step process for solving 5x5 Latin square" /></p>

<h3 id="testing">Testing</h3>

<p>To verify the solution:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># test_latin_square.py
</span><span class="kn">from</span> <span class="nn">backtrack</span> <span class="kn">import</span> <span class="n">backtrack</span>
<span class="kn">from</span> <span class="nn">latin_square</span> <span class="kn">import</span> <span class="n">create_latin_square</span>


<span class="k">def</span> <span class="nf">check_rows</span><span class="p">(</span><span class="n">solution</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">tuple</span><span class="p">[</span><span class="nb">int</span><span class="p">,</span> <span class="nb">int</span><span class="p">],</span> <span class="nb">int</span><span class="p">],</span> <span class="n">size</span><span class="p">:</span> <span class="nb">int</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
    <span class="s">"""Check if each row contains unique numbers from 1 to size."""</span>
    <span class="k">for</span> <span class="n">r</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">size</span><span class="p">):</span>
        <span class="n">row_values</span> <span class="o">=</span> <span class="p">{</span><span class="n">solution</span><span class="p">[(</span><span class="n">r</span><span class="p">,</span> <span class="n">c</span><span class="p">)]</span> <span class="k">for</span> <span class="n">c</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">size</span><span class="p">)}</span>
        <span class="k">if</span> <span class="n">row_values</span> <span class="o">!=</span> <span class="nb">set</span><span class="p">(</span><span class="nb">range</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="n">size</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)):</span>
            <span class="k">return</span> <span class="bp">False</span>
    <span class="k">return</span> <span class="bp">True</span>


<span class="k">def</span> <span class="nf">check_cols</span><span class="p">(</span><span class="n">solution</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">tuple</span><span class="p">[</span><span class="nb">int</span><span class="p">,</span> <span class="nb">int</span><span class="p">],</span> <span class="nb">int</span><span class="p">],</span> <span class="n">size</span><span class="p">:</span> <span class="nb">int</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
    <span class="s">"""Check if each column contains unique numbers from 1 to size."""</span>
    <span class="k">for</span> <span class="n">c</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">size</span><span class="p">):</span>
        <span class="n">col_values</span> <span class="o">=</span> <span class="p">{</span><span class="n">solution</span><span class="p">[(</span><span class="n">r</span><span class="p">,</span> <span class="n">c</span><span class="p">)]</span> <span class="k">for</span> <span class="n">r</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">size</span><span class="p">)}</span>
        <span class="k">if</span> <span class="n">col_values</span> <span class="o">!=</span> <span class="nb">set</span><span class="p">(</span><span class="nb">range</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="n">size</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)):</span>
            <span class="k">return</span> <span class="bp">False</span>
    <span class="k">return</span> <span class="bp">True</span>


<span class="k">def</span> <span class="nf">test_latin_square</span><span class="p">():</span>
    <span class="s">"""Test the Latin square CSP with different sizes."""</span>
    <span class="k">for</span> <span class="n">size</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">5</span><span class="p">,</span> <span class="mi">10</span><span class="p">):</span>
        <span class="n">latin_square</span> <span class="o">=</span> <span class="n">create_latin_square</span><span class="p">(</span><span class="n">size</span><span class="p">)</span>
        <span class="n">solution</span> <span class="o">=</span> <span class="n">backtrack</span><span class="p">({},</span> <span class="n">latin_square</span><span class="p">)</span>

        <span class="k">assert</span> <span class="n">solution</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">,</span> <span class="s">"Backtrack function should find a solution"</span>
        <span class="k">assert</span> <span class="nb">len</span><span class="p">(</span><span class="n">solution</span><span class="p">)</span> <span class="o">==</span> <span class="n">size</span> <span class="o">*</span> <span class="n">size</span><span class="p">,</span> <span class="s">"Solution should have size * size assignments"</span>

        <span class="c1"># Verify constraints
</span>        <span class="k">assert</span> <span class="n">check_rows</span><span class="p">(</span><span class="n">solution</span><span class="p">,</span> <span class="n">size</span><span class="p">),</span> <span class="s">"Row constraint violated"</span>
        <span class="k">assert</span> <span class="n">check_cols</span><span class="p">(</span><span class="n">solution</span><span class="p">,</span> <span class="n">size</span><span class="p">),</span> <span class="s">"Column constraint violated"</span>
</code></pre></div></div>

<p>You can run it using <a href="https://docs.pytest.org/en/stable/">pytest</a>:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pytest test_latin_square.py
</code></pre></div></div>

<h3 id="limitation">Limitation</h3>

<p>The problem with this naive implementation is that as the size of the square increases, the number of <code class="language-plaintext highlighter-rouge">backtrack</code> calls grows dramatically, leading to a huge increase in computation.</p>

<p>To illustrate this, we can get a sense of the computational effort by counting how many times <code class="language-plaintext highlighter-rouge">backtrack</code> is called.</p>

<p>Here’s the result for Latin squares ranging from size 1 to 10:</p>

<table>
  <thead>
    <tr>
      <th>Size</th>
      <th>Backtrack Calls</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>2</td>
    </tr>
    <tr>
      <td>2</td>
      <td>5</td>
    </tr>
    <tr>
      <td>3</td>
      <td>11</td>
    </tr>
    <tr>
      <td>4</td>
      <td>17</td>
    </tr>
    <tr>
      <td>5</td>
      <td>38</td>
    </tr>
    <tr>
      <td>6</td>
      <td>63</td>
    </tr>
    <tr>
      <td>7</td>
      <td>132</td>
    </tr>
    <tr>
      <td>8</td>
      <td>65</td>
    </tr>
    <tr>
      <td>9</td>
      <td>303</td>
    </tr>
    <tr>
      <td>10</td>
      <td>1465</td>
    </tr>
  </tbody>
</table>

<p>Line graph:</p>

<p><img src="/assets/posts/35/backtrack_calls_for_latin_squares.png" alt="Backtrack calls for latin squares of different sizes" /></p>

<p>Meanwhile, one interesting observation is that a larger problem doesn’t always mean more backtracking.
For example, solving the 8x8 grid actually required fewer backtrack calls than the 7x7.
This suggests that problem difficulty isn’t just about size.
Some instances are just naturally friendlier to the search process.</p>

<p>Back to the main point, as you can see, the computational cost increases rapidly, even for moderately larger squares.</p>

<p>Moreover, the problem becomes even harder when additional constraints are added.
For example, consider a Diagonal Latin square, where elements along both the main diagonal and the anti-diagonal must also be unique.</p>

<p>We can model this diagonal Latin square by adding more constraints as follows:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># latin_square.py
</span><span class="p">...</span>


<span class="k">def</span> <span class="nf">create_diagonal_latin_square</span><span class="p">(</span><span class="n">size</span><span class="p">:</span> <span class="nb">int</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">CSP</span><span class="p">:</span>
    <span class="s">"""
    Create a CSP for diagonal Latin square (DLS) of given size.
    """</span>
    <span class="n">csp</span> <span class="o">=</span> <span class="n">create_latin_square</span><span class="p">(</span><span class="n">size</span><span class="p">)</span>

    <span class="c1"># Add constraints for diagonal elements
</span>    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">size</span><span class="p">):</span>
        <span class="k">for</span> <span class="n">k</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">i</span> <span class="o">+</span> <span class="mi">1</span><span class="p">,</span> <span class="n">size</span><span class="p">):</span>
            <span class="c1"># Main diagonal constraints
</span>            <span class="n">csp</span><span class="p">.</span><span class="n">constraints</span><span class="p">.</span><span class="n">append</span><span class="p">(((</span><span class="n">i</span><span class="p">,</span> <span class="n">i</span><span class="p">),</span> <span class="p">(</span><span class="n">k</span><span class="p">,</span> <span class="n">k</span><span class="p">)))</span>
            <span class="c1"># Anti-diagonal constraints
</span>            <span class="n">csp</span><span class="p">.</span><span class="n">constraints</span><span class="p">.</span><span class="n">append</span><span class="p">(((</span><span class="n">i</span><span class="p">,</span> <span class="n">size</span> <span class="o">-</span> <span class="mi">1</span> <span class="o">-</span> <span class="n">i</span><span class="p">),</span> <span class="p">(</span><span class="n">k</span><span class="p">,</span> <span class="n">size</span> <span class="o">-</span> <span class="mi">1</span> <span class="o">-</span> <span class="n">k</span><span class="p">)))</span>
    <span class="k">return</span> <span class="n">csp</span>
</code></pre></div></div>

<p>The following figure is a node graph of a 3x3 diagonal Latin square.</p>

<p><img src="/assets/posts/35/diagonal_latin_square_node_graph.png" alt="Node graph of a 3x3 Diagonal Latin square" /></p>

<p class="notice--info">In fact, diagonal Latin squares are impossible for certain sizes, such as 2 and 3.</p>

<p>As as result, adding these diagonal constraints causes the number of backtracking calls to skyrocket:</p>

<table>
  <thead>
    <tr>
      <th>Size</th>
      <th>Backtrack Calls</th>
      <th>Solution Found</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>2</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>2</td>
      <td>5</td>
      <td>No</td>
    </tr>
    <tr>
      <td>3</td>
      <td>28</td>
      <td>No</td>
    </tr>
    <tr>
      <td>4</td>
      <td>30</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>5</td>
      <td>191</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>6</td>
      <td>1658</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>7</td>
      <td>87944</td>
      <td>Yes</td>
    </tr>
  </tbody>
</table>

<p>Below is a visual explanation of how many steps are added to solve a 5x5 Latin square compared to the previous example:</p>

<p><img src="/assets/posts/35/solving_diagonal_latin_square.webp" alt="Step-by-step process of solving diagonal Latin square" /></p>

<p>This happens because the search space becomes much more restricted, making it harder for the algorithm to find valid placements without conflict.
With more constraints, the algorithm has to backtrack more frequently.</p>

<p>Adding unary constraints can also make the problem significantly harder as well.</p>

<p>For instance, let’s force the main diagonal elements to follow a specific sequence:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># latin_square.py
</span><span class="p">...</span>


<span class="k">def</span> <span class="nf">create_diagonal_latin_square_with_unary_constraints</span><span class="p">(</span><span class="n">size</span><span class="p">:</span> <span class="nb">int</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">CSP</span><span class="p">:</span>
    <span class="s">"""
    Create a CSP for diagonal Latin square (DLS) of given size with additional unary constraints.
    """</span>
    <span class="n">csp</span> <span class="o">=</span> <span class="n">create_diagonal_latin_square</span><span class="p">(</span><span class="n">size</span><span class="p">)</span>
    <span class="c1"># Add unary constraints to make problem harder
</span>    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">size</span><span class="p">):</span>
        <span class="n">csp</span><span class="p">.</span><span class="n">domains</span><span class="p">[(</span><span class="n">i</span><span class="p">,</span> <span class="n">i</span><span class="p">)]</span> <span class="o">=</span> <span class="p">{</span><span class="n">i</span> <span class="o">+</span> <span class="mi">1</span><span class="p">}</span>
    <span class="k">return</span> <span class="n">csp</span>
</code></pre></div></div>

<p>These unary constraints further reduce the available choices for key variables right from the beginning, which leads to more failures during search and thus more backtracking.</p>

<table>
  <thead>
    <tr>
      <th>Size</th>
      <th>Backtrack Calls</th>
      <th>Solution Found</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>2</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>2</td>
      <td>3</td>
      <td>No</td>
    </tr>
    <tr>
      <td>3</td>
      <td>10</td>
      <td>No</td>
    </tr>
    <tr>
      <td>4</td>
      <td>56</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>5</td>
      <td>400</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>6</td>
      <td>44534</td>
      <td>Yes</td>
    </tr>
  </tbody>
</table>

<p>Once you throw in those diagonal and unary constraints, even a 7x7 Latin square becomes brutally slow for naive backtracking.
On most modern PCs, you’ll be stuck waiting who-knows-how-long for a solution.</p>

<p>To address these challenges and improve efficiency, we will enforce arc consistency by using AC-3 algorithm.</p>

<p>Update the test to verify the diagonal Latin square with unary constraints:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># test_latin_square.py
</span><span class="p">...</span>


<span class="k">def</span> <span class="nf">check_main_diagonal</span><span class="p">(</span><span class="n">solution</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">tuple</span><span class="p">[</span><span class="nb">int</span><span class="p">,</span> <span class="nb">int</span><span class="p">],</span> <span class="nb">int</span><span class="p">],</span> <span class="n">size</span><span class="p">:</span> <span class="nb">int</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
    <span class="s">"""Check if the main diagonal contains unique numbers from 1 to size."""</span>
    <span class="n">diag_values</span> <span class="o">=</span> <span class="p">{</span><span class="n">solution</span><span class="p">[(</span><span class="n">i</span><span class="p">,</span> <span class="n">i</span><span class="p">)]</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">size</span><span class="p">)}</span>
    <span class="c1"># For the specific DLS generated by create_dls, the diagonal is fixed
</span>    <span class="n">expected_diag_values</span> <span class="o">=</span> <span class="p">{</span><span class="n">i</span> <span class="o">+</span> <span class="mi">1</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">size</span><span class="p">)}</span>
    <span class="k">return</span> <span class="n">diag_values</span> <span class="o">==</span> <span class="n">expected_diag_values</span>


<span class="k">def</span> <span class="nf">check_anti_diagonal</span><span class="p">(</span><span class="n">solution</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">tuple</span><span class="p">[</span><span class="nb">int</span><span class="p">,</span> <span class="nb">int</span><span class="p">],</span> <span class="nb">int</span><span class="p">],</span> <span class="n">size</span><span class="p">:</span> <span class="nb">int</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
    <span class="s">"""Check if the anti-diagonal contains unique numbers from 1 to size."""</span>
    <span class="n">anti_diag_values</span> <span class="o">=</span> <span class="p">{</span><span class="n">solution</span><span class="p">[(</span><span class="n">i</span><span class="p">,</span> <span class="n">size</span> <span class="o">-</span> <span class="mi">1</span> <span class="o">-</span> <span class="n">i</span><span class="p">)]</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">size</span><span class="p">)}</span>
    <span class="k">return</span> <span class="nb">len</span><span class="p">(</span><span class="n">anti_diag_values</span><span class="p">)</span> <span class="o">==</span> <span class="n">size</span> <span class="ow">and</span> <span class="nb">all</span><span class="p">(</span><span class="mi">1</span> <span class="o">&lt;=</span> <span class="n">v</span> <span class="o">&lt;=</span> <span class="n">size</span> <span class="k">for</span> <span class="n">v</span> <span class="ow">in</span> <span class="n">anti_diag_values</span><span class="p">)</span>


<span class="k">def</span> <span class="nf">check_unary_constraints</span><span class="p">(</span><span class="n">solution</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">tuple</span><span class="p">[</span><span class="nb">int</span><span class="p">,</span> <span class="nb">int</span><span class="p">],</span> <span class="nb">int</span><span class="p">],</span> <span class="n">size</span><span class="p">:</span> <span class="nb">int</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
    <span class="s">"""Check if the unary constraints are satisfied."""</span>
    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">size</span><span class="p">):</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">i</span><span class="p">,</span> <span class="n">i</span><span class="p">)</span> <span class="ow">in</span> <span class="n">solution</span><span class="p">:</span>
            <span class="k">if</span> <span class="n">solution</span><span class="p">[(</span><span class="n">i</span><span class="p">,</span> <span class="n">i</span><span class="p">)]</span> <span class="o">!=</span> <span class="n">i</span> <span class="o">+</span> <span class="mi">1</span><span class="p">:</span>
                <span class="k">return</span> <span class="bp">False</span>
    <span class="k">return</span> <span class="bp">True</span>


<span class="k">def</span> <span class="nf">test_latin_square_diagonal_with_unary_constraints</span><span class="p">():</span>
    <span class="s">"""Test the Latin square CSP with diagonal constraints and unary constraints."""</span>
    <span class="k">for</span> <span class="n">size</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">4</span><span class="p">,</span> <span class="mi">7</span><span class="p">):</span>
        <span class="n">latin_square</span> <span class="o">=</span> <span class="n">create_diagonal_latin_square_with_unary_constraints</span><span class="p">(</span><span class="n">size</span><span class="p">)</span>
        <span class="n">solution</span> <span class="o">=</span> <span class="n">backtrack</span><span class="p">({},</span> <span class="n">latin_square</span><span class="p">)</span>

        <span class="k">assert</span> <span class="n">solution</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">,</span> <span class="s">"Backtrack function should find a solution"</span>
        <span class="k">assert</span> <span class="nb">len</span><span class="p">(</span><span class="n">solution</span><span class="p">)</span> <span class="o">==</span> <span class="n">size</span> <span class="o">*</span> <span class="n">size</span><span class="p">,</span> <span class="s">"Solution should have size * size assignments"</span>

        <span class="c1"># Verify constraints
</span>        <span class="k">assert</span> <span class="n">check_rows</span><span class="p">(</span><span class="n">solution</span><span class="p">,</span> <span class="n">size</span><span class="p">),</span> <span class="s">"Row constraint violated"</span>
        <span class="k">assert</span> <span class="n">check_cols</span><span class="p">(</span><span class="n">solution</span><span class="p">,</span> <span class="n">size</span><span class="p">),</span> <span class="s">"Column constraint violated"</span>
        <span class="k">assert</span> <span class="n">check_main_diagonal</span><span class="p">(</span><span class="n">solution</span><span class="p">,</span> <span class="n">size</span><span class="p">),</span> <span class="s">"Main diagonal constraint violated"</span>
        <span class="k">assert</span> <span class="n">check_anti_diagonal</span><span class="p">(</span><span class="n">solution</span><span class="p">,</span> <span class="n">size</span><span class="p">),</span> <span class="s">"Anti-diagonal constraint violated"</span>
        <span class="k">assert</span> <span class="n">check_unary_constraints</span><span class="p">(</span><span class="n">solution</span><span class="p">,</span> <span class="n">size</span><span class="p">),</span> <span class="s">"Unary constraint violated"</span>
</code></pre></div></div>

<h2 id="ac-3-algorithm">AC-3 Algorithm</h2>

<p>The AC-3 is an algorithm used in constraint satisfaction problems to enforce arc consistency.
It processes all arcs in the problem, removing inconsistent values from variable domains until all arcs are consistent — or until it detects that no solution is possible.
By reducing the domains early, AC-3 helps prune the search space, leading to fewer backtracks and faster solutions during search.</p>

<p>In Python, it can be implemented as follows:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># ac3.py
</span><span class="kn">from</span> <span class="nn">csp</span> <span class="kn">import</span> <span class="n">CSP</span>


<span class="k">def</span> <span class="nf">ac3</span><span class="p">(</span><span class="n">csp</span><span class="p">:</span> <span class="n">CSP</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
    <span class="s">"""
    Enforce arc consistency using AC-3 algorithm.
    """</span>
    <span class="n">queue</span> <span class="o">=</span> <span class="n">csp</span><span class="p">.</span><span class="n">constraints</span><span class="p">.</span><span class="n">copy</span><span class="p">()</span>  <span class="c1"># Initialize the queue with all constraints
</span>    <span class="k">while</span> <span class="n">queue</span><span class="p">:</span>
        <span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">y</span><span class="p">)</span> <span class="o">=</span> <span class="n">queue</span><span class="p">.</span><span class="n">pop</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">arc_reduce</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">y</span><span class="p">,</span> <span class="n">csp</span><span class="p">):</span>
            <span class="k">if</span> <span class="ow">not</span> <span class="n">csp</span><span class="p">.</span><span class="n">domains</span><span class="p">[</span><span class="n">x</span><span class="p">]:</span>
                <span class="k">return</span> <span class="bp">False</span>  <span class="c1"># No solution found
</span>            <span class="k">for</span> <span class="n">z</span> <span class="ow">in</span> <span class="n">get_neighbors</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">csp</span><span class="p">.</span><span class="n">constraints</span><span class="p">):</span>
                <span class="k">if</span> <span class="n">z</span> <span class="o">!=</span> <span class="n">y</span><span class="p">:</span>
                    <span class="n">queue</span><span class="p">.</span><span class="n">append</span><span class="p">((</span><span class="n">z</span><span class="p">,</span> <span class="n">x</span><span class="p">))</span>
    <span class="k">return</span> <span class="bp">True</span>


<span class="k">def</span> <span class="nf">arc_reduce</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">y</span><span class="p">,</span> <span class="n">csp</span><span class="p">:</span> <span class="n">CSP</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
    <span class="s">"""
    Make variable x arc-consistent with variable y.
    Return True if domain of x is reduced, False otherwise.
    """</span>
    <span class="n">changed</span> <span class="o">=</span> <span class="bp">False</span>
    <span class="k">for</span> <span class="n">vx</span> <span class="ow">in</span> <span class="n">csp</span><span class="p">.</span><span class="n">domains</span><span class="p">[</span><span class="n">x</span><span class="p">].</span><span class="n">copy</span><span class="p">():</span>
        <span class="n">satisfied</span> <span class="o">=</span> <span class="bp">False</span>
        <span class="c1"># Find a value in the domain of y that satisfies the constraint
</span>        <span class="k">for</span> <span class="n">vy</span> <span class="ow">in</span> <span class="n">csp</span><span class="p">.</span><span class="n">domains</span><span class="p">[</span><span class="n">y</span><span class="p">]:</span>
            <span class="k">if</span> <span class="n">vx</span> <span class="o">!=</span> <span class="n">vy</span><span class="p">:</span>
                <span class="n">satisfied</span> <span class="o">=</span> <span class="bp">True</span>
                <span class="k">break</span>
        <span class="k">if</span> <span class="ow">not</span> <span class="n">satisfied</span><span class="p">:</span>
            <span class="n">csp</span><span class="p">.</span><span class="n">domains</span><span class="p">[</span><span class="n">x</span><span class="p">].</span><span class="n">remove</span><span class="p">(</span><span class="n">vx</span><span class="p">)</span>
            <span class="n">changed</span> <span class="o">=</span> <span class="bp">True</span>
    <span class="k">return</span> <span class="n">changed</span>


<span class="k">def</span> <span class="nf">get_neighbors</span><span class="p">(</span><span class="n">variable</span><span class="p">,</span> <span class="n">constraints</span><span class="p">:</span> <span class="nb">list</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">:</span>
    <span class="s">"""
    Get all neighbors of a variable based on the constraints.
    """</span>
    <span class="n">neighbors</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">var1</span><span class="p">,</span> <span class="n">var2</span> <span class="ow">in</span> <span class="n">constraints</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">var1</span> <span class="o">==</span> <span class="n">variable</span><span class="p">:</span>
            <span class="n">neighbors</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">var2</span><span class="p">)</span>
        <span class="k">elif</span> <span class="n">var2</span> <span class="o">==</span> <span class="n">variable</span><span class="p">:</span>
            <span class="n">neighbors</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">var1</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">neighbors</span>

</code></pre></div></div>

<p>In the code above:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">ac3(csp)</code> initializes a queue with all binary constraints and processes them one by one.
For each arc <code class="language-plaintext highlighter-rouge">(X -&gt; Y)</code>, it checks whether the domain of <code class="language-plaintext highlighter-rouge">X</code> can be reduced based on <code class="language-plaintext highlighter-rouge">Y</code> using <code class="language-plaintext highlighter-rouge">arc_reduce(x, y, csp)</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">arc_reduce(x, y, csp)</code> attempts to prune inconsistent values from <code class="language-plaintext highlighter-rouge">X</code>’s domain. If a value of <code class="language-plaintext highlighter-rouge">X</code> cannot be matched with any value of <code class="language-plaintext highlighter-rouge">Y</code>, that value is removed.</li>
  <li><code class="language-plaintext highlighter-rouge">get_neighbors(variable, constraints)</code> returns all variables that share a constraint with the given variable, ensuring that after reducing <code class="language-plaintext highlighter-rouge">X</code>’s domain, related arcs are re-added to the queue for rechecking.</li>
</ul>

<p>If any domain becomes empty during this process, the algorithm returns <code class="language-plaintext highlighter-rouge">False</code>, indicating that no solution is possible under the current assignments.
Otherwise, it returns <code class="language-plaintext highlighter-rouge">True</code> once all arcs are consistent.</p>

<p>Here are two main ways to apply the AC-3 algorithm:</p>
<ul>
  <li>Initial pruning: at the start of the problem before starting the search.</li>
  <li>Maintaining Arc Consistency (MAC): during the search itself.</li>
</ul>

<h3 id="initial-pruning">Initial Pruning</h3>

<p>We can apply AC-3 to the CSP to prune the search space before staring the search.
This preprocessing step can help reduce the number of backtracks needed during the actual search in advance:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">latin_square_csp</span> <span class="o">=</span> <span class="n">create_diagonal_latin_square_with_unary_constraints</span><span class="p">(</span><span class="n">size</span><span class="o">=</span><span class="mi">5</span><span class="p">)</span>

<span class="c1"># assignment is a dictionary that maps variable to value
</span><span class="n">ac3</span><span class="p">(</span><span class="n">latin_square_csp</span><span class="p">)</span>  <span class="c1"># Enforce arc consistency before backtracking (initial pruning)
</span><span class="n">assignment</span> <span class="o">=</span> <span class="n">backtrack</span><span class="p">({},</span> <span class="n">latin_square_csp</span><span class="p">)</span>
<span class="k">if</span> <span class="n">assignment</span><span class="p">:</span>
    <span class="n">print_latin_square</span><span class="p">(</span><span class="n">assignment</span><span class="p">)</span>
<span class="k">else</span><span class="p">:</span>
    <span class="k">print</span><span class="p">(</span><span class="s">"No solution found."</span><span class="p">)</span>
</code></pre></div></div>

<p>Pros:</p>
<ul>
  <li>Easy to implement.</li>
  <li>Can eliminate many early inconsistencies.</li>
  <li>Reduces search space before starting.</li>
</ul>

<p>Cons:</p>
<ul>
  <li>Only detects inconsistencies before search.</li>
  <li>Cannot prevent failures that arise during search.</li>
</ul>

<p>As an example, the table below shows how the domains of each variable in a 3×3 Latin square are refined before and after applying the AC-3 algorithm at the beginning.</p>

<table>
  <thead>
    <tr>
      <th>Variable</th>
      <th>Before AC-3</th>
      <th>After AC-3</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>(0, 0)</td>
      <td>{1}</td>
      <td>∅</td>
    </tr>
    <tr>
      <td>(0, 1)</td>
      <td>{1, 2, 3}</td>
      <td>{1, 3}</td>
    </tr>
    <tr>
      <td>(0, 2)</td>
      <td>{1, 2, 3}</td>
      <td>{1}</td>
    </tr>
    <tr>
      <td>(1, 0)</td>
      <td>{1, 2, 3}</td>
      <td>{1, 3}</td>
    </tr>
    <tr>
      <td>(1, 1)</td>
      <td>{2}</td>
      <td>{2}</td>
    </tr>
    <tr>
      <td>(1, 2)</td>
      <td>{1, 2, 3}</td>
      <td>{1, 2}</td>
    </tr>
    <tr>
      <td>(2, 0)</td>
      <td>{1, 2, 3}</td>
      <td>{1, 2}</td>
    </tr>
    <tr>
      <td>(2, 1)</td>
      <td>{1, 2, 3}</td>
      <td>{1, 2}</td>
    </tr>
    <tr>
      <td>(2, 2)</td>
      <td>{3}</td>
      <td>{3}</td>
    </tr>
  </tbody>
</table>

<p>As a result, we can immediately detect that the problem has no solution because one of the variables (0, 0) has an empty domain after <em>constraint propagation</em>, meaning no valid value can be assigned without violating the Latin square constraints.</p>

<p>The number of backtracking calls when solving a diagonal Latin square with unary constraints, with AC-3 applied before backtracking:</p>

<table>
  <thead>
    <tr>
      <th>Size</th>
      <th>Backtrack Calls</th>
      <th>Solution Found</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>2</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>2</td>
      <td>2</td>
      <td>No</td>
    </tr>
    <tr>
      <td>3</td>
      <td>1</td>
      <td>No</td>
    </tr>
    <tr>
      <td>4</td>
      <td>21</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>5</td>
      <td>48</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>6</td>
      <td>864</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>7</td>
      <td>155466</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>8</td>
      <td>111898</td>
      <td>Yes</td>
    </tr>
  </tbody>
</table>

<p>When compared to the result without AC-3, the difference in backtracking calls becomes increasingly noticeable as the grid size grows.</p>

<h3 id="maintaining-arc-consistency-mac">Maintaining Arc Consistency (MAC)</h3>

<p>We can also maintain arc consistency dynamically during the backtracking process.
After each variable assignment, we enforce arc consistency again, ensuring that every partial assignment remains as consistent as possible before proceeding further.
This approach can help reduce the need for deep backtracking by detecting dead-ends earlier:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">backtrack</span><span class="p">(</span><span class="n">assignment</span><span class="p">:</span> <span class="nb">dict</span><span class="p">,</span> <span class="n">csp</span><span class="p">:</span> <span class="n">CSP</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">dict</span> <span class="o">|</span> <span class="bp">None</span><span class="p">:</span>
    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">assignment</span><span class="p">)</span> <span class="o">==</span> <span class="nb">len</span><span class="p">(</span><span class="n">csp</span><span class="p">.</span><span class="n">variables</span><span class="p">):</span>  <span class="c1"># Return assignment if complete
</span>        <span class="k">return</span> <span class="n">assignment</span>

    <span class="n">variable</span> <span class="o">=</span> <span class="n">select_unassigned_variable</span><span class="p">(</span><span class="n">assignment</span><span class="p">,</span> <span class="n">csp</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">value</span> <span class="ow">in</span> <span class="nb">list</span><span class="p">(</span><span class="n">get_domain_values</span><span class="p">(</span><span class="n">variable</span><span class="p">,</span> <span class="n">assignment</span><span class="p">,</span> <span class="n">csp</span><span class="p">)):</span>
        <span class="n">new_assignment</span> <span class="o">=</span> <span class="n">assignment</span><span class="p">.</span><span class="n">copy</span><span class="p">()</span>
        <span class="n">new_assignment</span><span class="p">[</span><span class="n">variable</span><span class="p">]</span> <span class="o">=</span> <span class="n">value</span>
        <span class="k">if</span> <span class="n">is_consistent</span><span class="p">(</span><span class="n">new_assignment</span><span class="p">,</span> <span class="n">csp</span><span class="p">.</span><span class="n">constraints</span><span class="p">):</span>
            <span class="n">domains</span> <span class="o">=</span> <span class="n">csp</span><span class="p">.</span><span class="n">domains</span><span class="p">.</span><span class="n">copy</span><span class="p">()</span>  <span class="c1"># Save the original domains
</span>            <span class="k">if</span> <span class="n">ac3</span><span class="p">(</span><span class="n">csp</span><span class="p">):</span>  <span class="c1"># Enforce arc consistency
</span>                <span class="n">result</span> <span class="o">=</span> <span class="n">backtrack</span><span class="p">(</span><span class="n">new_assignment</span><span class="p">,</span> <span class="n">csp</span><span class="p">)</span>  <span class="c1"># Recur with the new assignment
</span>                <span class="k">if</span> <span class="n">result</span><span class="p">:</span>
                    <span class="k">return</span> <span class="n">result</span>  <span class="c1"># Solution found
</span>            <span class="n">csp</span><span class="p">.</span><span class="n">domains</span> <span class="o">=</span> <span class="n">domains</span>  <span class="c1"># Restore the original domains
</span>    <span class="k">return</span> <span class="bp">None</span>  <span class="c1"># No solution found
</span></code></pre></div></div>

<p>Pros:</p>
<ul>
  <li>Continuously prunes domains after each assignment.</li>
  <li>Detects dead-ends early.</li>
  <li>Leads to fewer backtracks and deeper pruning.</li>
</ul>

<p>Cons:</p>
<ul>
  <li>More computational overhead at each step.</li>
  <li>Slightly more complex to implement and manage.</li>
</ul>

<p>The number of backtrack calls when using MAC isn’t much different from that of initial pruning.
In fact, using MAC can actually be slower than simply doing that initial pruning.
You can see this for yourself by comparing the processing time of both versions of the code.
So, running AC-3 just once at the start is more than enough to significantly improve performance for our Latin square example.</p>

<p>The takeaway here is that while it’s possible to apply AC-3 at the beginning and also during the backtracking process, it’s not free.
It comes with a computational cost.
And sometimes, that extra overhead outweighs any benefit it might bring.</p>

<p>We will use initial pruning for the rest of the examples.</p>

<hr />

<p>To further improve search efficiency, we can use <em>heuristics</em>.</p>

<h2 id="heuristics">Heuristics</h2>

<p>Heuristics are strategies used to make better decisions during search — like which variable to pick next or which value to try first.
Instead of going through every possibility blindly, heuristics aim to cut down the search space and avoid dead ends early.</p>

<p>Three heuristics we’ll look at:</p>
<ul>
  <li>Least Constraining Values (LCV)</li>
  <li>Minimum Remaining Values (MRV)</li>
  <li>Degree</li>
</ul>

<h3 id="least-constraining-values-lcv">Least Constraining Values (LCV)</h3>

<p>The least constraining value (LCV) heuristic suggests trying the value that rules out the fewest choices for the neighboring (constrained) unassigned variables.
The goal is to keep options open for future assignments, increasing the chance of finding a solution without backtracking.</p>

<p>This requires calculating, for each potential value of the current variable, how many choices would be eliminated for its unassigned neighbors.
(Previosuly, the <code class="language-plaintext highlighter-rouge">get_domain_values</code> just returns all domain values for a variable.)</p>

<p>For this, we will update the <code class="language-plaintext highlighter-rouge">get_domain_values</code> function:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">...</span>

<span class="k">def</span> <span class="nf">get_domain_values</span><span class="p">(</span><span class="n">variable</span><span class="p">,</span> <span class="n">assignment</span><span class="p">:</span> <span class="nb">dict</span><span class="p">,</span> <span class="n">csp</span><span class="p">:</span> <span class="n">CSP</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">:</span>
    <span class="s">"""
    Order domain values using the Least Constraining Values (LCV) heuristic.
    Returns values sorted by the number of choices they rule out for neighboring variables.
    """</span>
    <span class="n">neighbors</span> <span class="o">=</span> <span class="n">get_neighbors</span><span class="p">(</span><span class="n">variable</span><span class="p">,</span> <span class="n">csp</span><span class="p">.</span><span class="n">constraints</span><span class="p">)</span>
    <span class="n">unassigned_neighbors</span> <span class="o">=</span> <span class="p">[</span><span class="n">n</span> <span class="k">for</span> <span class="n">n</span> <span class="ow">in</span> <span class="n">neighbors</span> <span class="k">if</span> <span class="n">n</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">assignment</span><span class="p">]</span>

    <span class="n">value_counts</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">value</span> <span class="ow">in</span> <span class="n">csp</span><span class="p">.</span><span class="n">domains</span><span class="p">[</span><span class="n">variable</span><span class="p">]:</span>
        <span class="n">constraint_count</span> <span class="o">=</span> <span class="mi">0</span>
        <span class="k">for</span> <span class="n">unassigned_neighbor</span> <span class="ow">in</span> <span class="n">unassigned_neighbors</span><span class="p">:</span>
            <span class="k">if</span> <span class="n">value</span> <span class="ow">in</span> <span class="n">csp</span><span class="p">.</span><span class="n">domains</span><span class="p">[</span><span class="n">unassigned_neighbor</span><span class="p">]:</span>
                <span class="n">constraint_count</span> <span class="o">+=</span> <span class="mi">1</span>
        <span class="n">value_counts</span><span class="p">.</span><span class="n">append</span><span class="p">((</span><span class="n">value</span><span class="p">,</span> <span class="n">constraint_count</span><span class="p">))</span>

    <span class="c1"># Sort values by their constraint count (ascending)
</span>    <span class="n">value_counts</span><span class="p">.</span><span class="n">sort</span><span class="p">(</span><span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">x</span><span class="p">:</span> <span class="n">x</span><span class="p">[</span><span class="mi">1</span><span class="p">])</span>

    <span class="c1"># NOTE The first item is the least constraining value (that rules out the least number of choices for neighboring variables)
</span>    <span class="k">return</span> <span class="p">[</span><span class="n">value</span> <span class="k">for</span> <span class="n">value</span><span class="p">,</span> <span class="n">_</span> <span class="ow">in</span> <span class="n">value_counts</span><span class="p">]</span>

<span class="k">def</span> <span class="nf">get_neighbors</span><span class="p">(</span><span class="n">variable</span><span class="p">,</span> <span class="n">constraints</span><span class="p">:</span> <span class="nb">list</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">:</span>
    <span class="s">"""
    Get all neighbors of a variable based on the constraints.
    """</span>
    <span class="n">neighbors</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">var1</span><span class="p">,</span> <span class="n">var2</span> <span class="ow">in</span> <span class="n">constraints</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">var1</span> <span class="o">==</span> <span class="n">variable</span><span class="p">:</span>
            <span class="n">neighbors</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">var2</span><span class="p">)</span>
        <span class="k">elif</span> <span class="n">var2</span> <span class="o">==</span> <span class="n">variable</span><span class="p">:</span>
            <span class="n">neighbors</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">var1</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">neighbors</span>
</code></pre></div></div>

<p>With LCV heuristic used (alongside AC-3), the number of backtrack calls significantly improves:</p>

<table>
  <thead>
    <tr>
      <th>Size</th>
      <th>Backtrack Calls</th>
      <th>Solution Found</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>2</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>2</td>
      <td>2</td>
      <td>No</td>
    </tr>
    <tr>
      <td>3</td>
      <td>1</td>
      <td>No</td>
    </tr>
    <tr>
      <td>4</td>
      <td>20</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>5</td>
      <td>36</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>6</td>
      <td>905</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>7</td>
      <td>854</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>8</td>
      <td>5588</td>
      <td>Yes</td>
    </tr>
  </tbody>
</table>

<p>Note the drastic drop in backtrack calls for size 7 compared to the previous 155466 without LCV.
This demonstrates how combining constraint propagation and value ordering (like LCV) can make a huge difference in search efficiency.</p>

<h3 id="minimum-remaining-values-mrv">Minimum Remaining Values (MRV)</h3>

<p>The minimum remaining values (MRV) heuristic (a.k.a. “most constrained variable” heuristic) suggests selecting the variable with the fewest legal values remaining in its domain.
The intuition is that if a variable has only one or very few valid choices left, it’s better off to assign it now.
This might lead to an immediate failure if no values are legal, allowing for quick backtracking.</p>

<p>For this, we will update the <code class="language-plaintext highlighter-rouge">select_unassigned_variable</code> function:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">...</span>

<span class="k">def</span> <span class="nf">select_unassigned_variable</span><span class="p">(</span><span class="n">assignment</span><span class="p">:</span> <span class="nb">dict</span><span class="p">,</span> <span class="n">csp</span><span class="p">:</span> <span class="n">CSP</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span> <span class="o">|</span> <span class="bp">None</span><span class="p">:</span>
    <span class="s">"""Select the unassigned variable with the fewest remaining legal values (MRV)."""</span>
    <span class="n">variable</span> <span class="o">=</span> <span class="bp">None</span>
    <span class="n">minimum_remaining_values</span> <span class="o">=</span> <span class="nb">float</span><span class="p">(</span><span class="s">"inf"</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">candidate_variable</span> <span class="ow">in</span> <span class="n">csp</span><span class="p">.</span><span class="n">variables</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">candidate_variable</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">assignment</span><span class="p">:</span>
            <span class="n">num_remaining_values</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="n">csp</span><span class="p">.</span><span class="n">domains</span><span class="p">[</span><span class="n">candidate_variable</span><span class="p">])</span>
            <span class="k">if</span> <span class="n">num_remaining_values</span> <span class="o">&lt;</span> <span class="n">minimum_remaining_values</span><span class="p">:</span>
                <span class="n">minimum_remaining_values</span> <span class="o">=</span> <span class="n">num_remaining_values</span>
                <span class="n">variable</span> <span class="o">=</span> <span class="n">candidate_variable</span>  <span class="c1"># Update the best variable found so far
</span>    <span class="k">return</span> <span class="n">variable</span>
</code></pre></div></div>

<p>With MRV heuristic used (alongside AC-3):</p>

<table>
  <thead>
    <tr>
      <th>Size</th>
      <th>Backtrack Calls</th>
      <th>Solution Found</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>2</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>2</td>
      <td>1</td>
      <td>No</td>
    </tr>
    <tr>
      <td>3</td>
      <td>1</td>
      <td>No</td>
    </tr>
    <tr>
      <td>4</td>
      <td>20</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>5</td>
      <td>40</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>6</td>
      <td>711</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>7</td>
      <td>22164</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>8</td>
      <td>91113</td>
      <td>Yes</td>
    </tr>
  </tbody>
</table>

<p>While MRV consistently improves performance, it results in more backtracking calls than LCV when solving larger Latin squares.</p>

<h3 id="degree">Degree</h3>

<p>The degree heuristic suggests selecting the variable involved in the largest number of constraints with other unassigned variables.</p>

<p>In a Latin square, each cell (r, c) is constrained by the other N-1 cells in its row and the other N-1 cells in its column.
The degree heuristic tries to pick the variable that constrains the most remaining choices.</p>

<p>Here’s the degree heuristic implemented version:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">...</span>

<span class="k">def</span> <span class="nf">select_unassigned_variable</span><span class="p">(</span><span class="n">assignment</span><span class="p">:</span> <span class="nb">dict</span><span class="p">,</span> <span class="n">csp</span><span class="p">:</span> <span class="n">CSP</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span> <span class="o">|</span> <span class="bp">None</span><span class="p">:</span>
    <span class="s">"""
    Select the unassigned variable using degree heuristic.
    This heuristic selects the variable that is involved in the largest number of constraints.
    """</span>
    <span class="n">max_degree</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="o">-</span><span class="mi">1</span>
    <span class="n">selected_variable</span> <span class="o">=</span> <span class="bp">None</span>
    <span class="k">for</span> <span class="n">var</span> <span class="ow">in</span> <span class="n">csp</span><span class="p">.</span><span class="n">variables</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">var</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">assignment</span><span class="p">:</span>
            <span class="n">num_neighbors</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="n">get_neighbors</span><span class="p">(</span><span class="n">var</span><span class="p">,</span> <span class="n">csp</span><span class="p">.</span><span class="n">constraints</span><span class="p">))</span>
            <span class="k">if</span> <span class="n">num_neighbors</span> <span class="o">&gt;</span> <span class="n">max_degree</span><span class="p">:</span>
                <span class="n">max_degree</span> <span class="o">=</span> <span class="n">num_neighbors</span>
                <span class="n">selected_variable</span> <span class="o">=</span> <span class="n">var</span>  <span class="c1"># Update the selected variable
</span>    <span class="k">return</span> <span class="n">selected_variable</span>

<span class="k">def</span> <span class="nf">get_neighbors</span><span class="p">(</span><span class="n">variable</span><span class="p">,</span> <span class="n">constraints</span><span class="p">:</span> <span class="nb">list</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">:</span>
    <span class="s">"""
    Get all neighbors of a variable based on the constraints.
    """</span>
    <span class="n">neighbors</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">var1</span><span class="p">,</span> <span class="n">var2</span> <span class="ow">in</span> <span class="n">constraints</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">var1</span> <span class="o">==</span> <span class="n">variable</span><span class="p">:</span>
            <span class="n">neighbors</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">var2</span><span class="p">)</span>
        <span class="k">elif</span> <span class="n">var2</span> <span class="o">==</span> <span class="n">variable</span><span class="p">:</span>
            <span class="n">neighbors</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">var1</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">neighbors</span>
</code></pre></div></div>

<p>With degree heuristic used (alongside AC-3):</p>

<table>
  <thead>
    <tr>
      <th>Size</th>
      <th>Backtrack Calls</th>
      <th>Solution Found</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>2</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>2</td>
      <td>2</td>
      <td>No</td>
    </tr>
    <tr>
      <td>3</td>
      <td>2</td>
      <td>No</td>
    </tr>
    <tr>
      <td>4</td>
      <td>17</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>5</td>
      <td>31</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>6</td>
      <td>39</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>7</td>
      <td>651</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>8</td>
      <td>568577</td>
      <td>Yes</td>
    </tr>
  </tbody>
</table>

<p>The degree heuristic performs really well for Latin squares up to size 7, but after that, the computation cost shoots up significantly.</p>

<p>The key insight here is that there’s no one-size-fits-all solution—even for a single problem type like Latin squares.
Different heuristics behave differently depending on the size and complexity of the instance.
That’s why it’s important to experiment with various strategies rather than sticking to just one.
What works well for a 7x7 grid might fall apart on an 8x8.</p>

<h3 id="lcv--mrv--degree">LCV + MRV + Degree</h3>

<p>Finally, let’s put all the heuristics together—LCV, MRV, and the Degree heuristic to see how well they perform when combined in a backtracking search.</p>

<p>Here’s the code that integrates all three:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">ac3</span> <span class="kn">import</span> <span class="n">ac3</span>
<span class="kn">from</span> <span class="nn">csp</span> <span class="kn">import</span> <span class="n">CSP</span>
<span class="kn">from</span> <span class="nn">latin_square</span> <span class="kn">import</span> <span class="n">create_diagonal_latin_square_with_unary_constraints</span><span class="p">,</span> <span class="n">print_latin_square</span>


<span class="k">def</span> <span class="nf">backtrack</span><span class="p">(</span><span class="n">assignment</span><span class="p">:</span> <span class="nb">dict</span><span class="p">,</span> <span class="n">csp</span><span class="p">:</span> <span class="n">CSP</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">dict</span> <span class="o">|</span> <span class="bp">None</span><span class="p">:</span>
    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">assignment</span><span class="p">)</span> <span class="o">==</span> <span class="nb">len</span><span class="p">(</span><span class="n">csp</span><span class="p">.</span><span class="n">variables</span><span class="p">):</span>  <span class="c1"># Return assignment if complete
</span>        <span class="k">return</span> <span class="n">assignment</span>

    <span class="n">variable</span> <span class="o">=</span> <span class="n">select_unassigned_variable</span><span class="p">(</span><span class="n">assignment</span><span class="p">,</span> <span class="n">csp</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">value</span> <span class="ow">in</span> <span class="n">get_domain_values</span><span class="p">(</span><span class="n">variable</span><span class="p">,</span> <span class="n">assignment</span><span class="p">,</span> <span class="n">csp</span><span class="p">):</span>
        <span class="n">new_assignment</span> <span class="o">=</span> <span class="n">assignment</span><span class="p">.</span><span class="n">copy</span><span class="p">()</span>
        <span class="n">new_assignment</span><span class="p">[</span><span class="n">variable</span><span class="p">]</span> <span class="o">=</span> <span class="n">value</span>
        <span class="k">if</span> <span class="n">is_consistent</span><span class="p">(</span><span class="n">new_assignment</span><span class="p">,</span> <span class="n">csp</span><span class="p">.</span><span class="n">constraints</span><span class="p">):</span>
            <span class="n">result</span> <span class="o">=</span> <span class="n">backtrack</span><span class="p">(</span><span class="n">new_assignment</span><span class="p">,</span> <span class="n">csp</span><span class="p">)</span>  <span class="c1"># Recur with the new assignment
</span>            <span class="k">if</span> <span class="n">result</span><span class="p">:</span>
                <span class="k">return</span> <span class="n">result</span>  <span class="c1"># Solution found
</span>    <span class="k">return</span> <span class="bp">None</span>  <span class="c1"># No solution found
</span>

<span class="k">def</span> <span class="nf">select_unassigned_variable</span><span class="p">(</span><span class="n">assignment</span><span class="p">:</span> <span class="nb">dict</span><span class="p">,</span> <span class="n">csp</span><span class="p">:</span> <span class="n">CSP</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span> <span class="o">|</span> <span class="bp">None</span><span class="p">:</span>
    <span class="s">"""
    Select unassigned variable using MRV (Minimum Remaining Values) heuristic
    with Degree heuristic as a tie-breaker.
    """</span>
    <span class="n">unassigned_vars</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="n">minimum_remaining_values</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="nb">float</span><span class="p">(</span><span class="s">"inf"</span><span class="p">)</span>

    <span class="c1"># First pass: find all variables with minimum remaining values
</span>    <span class="k">for</span> <span class="n">var</span> <span class="ow">in</span> <span class="n">csp</span><span class="p">.</span><span class="n">variables</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">var</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">assignment</span><span class="p">:</span>
            <span class="n">num_remaining_values</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="n">csp</span><span class="p">.</span><span class="n">domains</span><span class="p">[</span><span class="n">var</span><span class="p">])</span>
            <span class="k">if</span> <span class="n">num_remaining_values</span> <span class="o">&lt;</span> <span class="n">minimum_remaining_values</span><span class="p">:</span>
                <span class="n">minimum_remaining_values</span> <span class="o">=</span> <span class="n">num_remaining_values</span>
                <span class="n">unassigned_vars</span> <span class="o">=</span> <span class="p">[</span><span class="n">var</span><span class="p">]</span>
            <span class="k">elif</span> <span class="n">num_remaining_values</span> <span class="o">==</span> <span class="n">minimum_remaining_values</span><span class="p">:</span>
                <span class="n">unassigned_vars</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">var</span><span class="p">)</span>

    <span class="c1"># If only one variable with minimum remaining values, return it
</span>    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">unassigned_vars</span><span class="p">)</span> <span class="o">==</span> <span class="mi">1</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">unassigned_vars</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>

    <span class="c1"># Use degree heuristic as tie-breaker
</span>    <span class="n">max_degree</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="o">-</span><span class="mi">1</span>
    <span class="n">selected_variable</span> <span class="o">=</span> <span class="bp">None</span>
    <span class="k">for</span> <span class="n">var</span> <span class="ow">in</span> <span class="n">unassigned_vars</span><span class="p">:</span>
        <span class="n">num_neighbors</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="n">get_neighbors</span><span class="p">(</span><span class="n">var</span><span class="p">,</span> <span class="n">csp</span><span class="p">.</span><span class="n">constraints</span><span class="p">))</span>
        <span class="k">if</span> <span class="n">num_neighbors</span> <span class="o">&gt;</span> <span class="n">max_degree</span><span class="p">:</span>
            <span class="n">max_degree</span> <span class="o">=</span> <span class="n">num_neighbors</span>
            <span class="n">selected_variable</span> <span class="o">=</span> <span class="n">var</span>

    <span class="k">return</span> <span class="n">selected_variable</span>


<span class="k">def</span> <span class="nf">get_domain_values</span><span class="p">(</span><span class="n">variable</span><span class="p">,</span> <span class="n">assignment</span><span class="p">:</span> <span class="nb">dict</span><span class="p">,</span> <span class="n">csp</span><span class="p">:</span> <span class="n">CSP</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">:</span>
    <span class="s">"""
    Order domain values using the Least Constraining Values (LCV) heuristic.
    Returns values sorted by the number of choices they rule out for neighboring variables.
    """</span>
    <span class="n">neighbors</span> <span class="o">=</span> <span class="n">get_neighbors</span><span class="p">(</span><span class="n">variable</span><span class="p">,</span> <span class="n">csp</span><span class="p">.</span><span class="n">constraints</span><span class="p">)</span>
    <span class="n">unassigned_neighbors</span> <span class="o">=</span> <span class="p">[</span><span class="n">n</span> <span class="k">for</span> <span class="n">n</span> <span class="ow">in</span> <span class="n">neighbors</span> <span class="k">if</span> <span class="n">n</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">assignment</span><span class="p">]</span>

    <span class="n">value_counts</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">value</span> <span class="ow">in</span> <span class="n">csp</span><span class="p">.</span><span class="n">domains</span><span class="p">[</span><span class="n">variable</span><span class="p">]:</span>
        <span class="n">constraint_count</span> <span class="o">=</span> <span class="mi">0</span>
        <span class="k">for</span> <span class="n">unassigned_neighbor</span> <span class="ow">in</span> <span class="n">unassigned_neighbors</span><span class="p">:</span>
            <span class="k">if</span> <span class="n">value</span> <span class="ow">in</span> <span class="n">csp</span><span class="p">.</span><span class="n">domains</span><span class="p">[</span><span class="n">unassigned_neighbor</span><span class="p">]:</span>
                <span class="n">constraint_count</span> <span class="o">+=</span> <span class="mi">1</span>
        <span class="n">value_counts</span><span class="p">.</span><span class="n">append</span><span class="p">((</span><span class="n">value</span><span class="p">,</span> <span class="n">constraint_count</span><span class="p">))</span>

    <span class="c1"># Sort values by their constraint count (ascending)
</span>    <span class="n">value_counts</span><span class="p">.</span><span class="n">sort</span><span class="p">(</span><span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">x</span><span class="p">:</span> <span class="n">x</span><span class="p">[</span><span class="mi">1</span><span class="p">])</span>

    <span class="c1"># Return values ordered from least constraining to most constraining
</span>    <span class="k">return</span> <span class="p">[</span><span class="n">value</span> <span class="k">for</span> <span class="n">value</span><span class="p">,</span> <span class="n">_</span> <span class="ow">in</span> <span class="n">value_counts</span><span class="p">]</span>


<span class="k">def</span> <span class="nf">is_consistent</span><span class="p">(</span><span class="n">assignment</span><span class="p">:</span> <span class="nb">dict</span><span class="p">,</span> <span class="n">constraints</span><span class="p">:</span> <span class="nb">list</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
    <span class="s">"""
    Check if the assignment is consistent with the constraints for Latin square.
    """</span>
    <span class="c1"># Check if any constraints are violated
</span>    <span class="k">for</span> <span class="n">var1</span><span class="p">,</span> <span class="n">var2</span> <span class="ow">in</span> <span class="n">constraints</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">var1</span> <span class="ow">in</span> <span class="n">assignment</span> <span class="ow">and</span> <span class="n">var2</span> <span class="ow">in</span> <span class="n">assignment</span><span class="p">:</span>
            <span class="k">if</span> <span class="n">assignment</span><span class="p">[</span><span class="n">var1</span><span class="p">]</span> <span class="o">==</span> <span class="n">assignment</span><span class="p">[</span><span class="n">var2</span><span class="p">]:</span>
                <span class="k">return</span> <span class="bp">False</span>  <span class="c1"># assignment is not consistent
</span>    <span class="k">return</span> <span class="bp">True</span>  <span class="c1"># assignment is consistent
</span>

<span class="k">def</span> <span class="nf">get_neighbors</span><span class="p">(</span><span class="n">variable</span><span class="p">,</span> <span class="n">constraints</span><span class="p">:</span> <span class="nb">list</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">:</span>
    <span class="s">"""
    Get all neighbors of a variable based on the constraints.
    """</span>
    <span class="n">neighbors</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">var1</span><span class="p">,</span> <span class="n">var2</span> <span class="ow">in</span> <span class="n">constraints</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">var1</span> <span class="o">==</span> <span class="n">variable</span><span class="p">:</span>
            <span class="n">neighbors</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">var2</span><span class="p">)</span>
        <span class="k">elif</span> <span class="n">var2</span> <span class="o">==</span> <span class="n">variable</span><span class="p">:</span>
            <span class="n">neighbors</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">var1</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">neighbors</span>

<span class="k">if</span> <span class="n">__name__</span> <span class="o">==</span> <span class="s">"__main__"</span><span class="p">:</span>
    <span class="n">latin_square_csp</span> <span class="o">=</span> <span class="n">create_diagonal_latin_square_with_unary_constraints</span><span class="p">(</span><span class="n">size</span><span class="o">=</span><span class="mi">5</span><span class="p">)</span>
    <span class="n">ac3</span><span class="p">(</span><span class="n">latin_square_csp</span><span class="p">)</span>
    <span class="n">assignment</span> <span class="o">=</span> <span class="n">backtrack</span><span class="p">({},</span> <span class="n">latin_square_csp</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">assignment</span><span class="p">:</span>
        <span class="n">print_latin_square</span><span class="p">(</span><span class="n">assignment</span><span class="p">)</span>
    <span class="k">else</span><span class="p">:</span>
        <span class="k">print</span><span class="p">(</span><span class="s">"No solution found."</span><span class="p">)</span>
</code></pre></div></div>

<p>The number of backtrack calls of all heuristics combined:</p>

<table>
  <thead>
    <tr>
      <th>size</th>
      <th>Backtrack Calls</th>
      <th>Solution Found</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>2</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>2</td>
      <td>1</td>
      <td>No</td>
    </tr>
    <tr>
      <td>3</td>
      <td>1</td>
      <td>No</td>
    </tr>
    <tr>
      <td>4</td>
      <td>17</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>5</td>
      <td>26</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>6</td>
      <td>49</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>7</td>
      <td>91</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>8</td>
      <td>475</td>
      <td>Yes</td>
    </tr>
  </tbody>
</table>

<p>You’ll notice a big drop in the number of backtrack calls and not just that, it also cuts down the actual processing time if you run the code.</p>

<h3 id="comparisons">Comparisons</h3>

<p>The graph below compares six different approaches to solve diagonal Latin square with unary constraints.</p>

<p><img src="/assets/posts/35/csp_comparison.png" alt="Comparison of CSP solutions" /></p>

<p>The key insight is that the logarithmic scale really highlights the performance differences.
It shows that by combining advanced heuristics, you can solve larger Latin squares with a huge drop in computational effort.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Keep in mind that the number of backtrack calls is just one way to measure how efficient the search is.
Heuristics can definitely help guide the search more intelligently, but figuring out the best move at every step can take extra computational overhead.
So even if your heuristic seems to be reducing backtracking, if it’s working too hard behind the scenes, it might actually be slowing things down overall.</p>

<h2 id="references">References</h2>
<ul>
  <li><a href="https://en.wikipedia.org/wiki/Latin_square">Latin square</a></li>
  <li><a href="https://en.wikipedia.org/wiki/Constraint_satisfaction_problem">Constraint satisfaction problem</a></li>
  <li><a href="https://en.wikipedia.org/wiki/AC-3_algorithm">AC-3 algorithm</a></li>
</ul>]]></content><author><name>Mienxiu</name></author><category term="algorithm" /><category term="python" /><category term="testing" /><summary type="html"><![CDATA[A Latin square is an n x n grid filled with n different symbols (commonly numbers 1 to n), each appearing exactly once in every row and every column.]]></summary></entry><entry><title type="html">Interprocess Communication</title><link href="https://mienxiu.com/interprocess-communication/" rel="alternate" type="text/html" title="Interprocess Communication" /><published>2025-03-24T00:00:00+00:00</published><updated>2025-03-24T00:00:00+00:00</updated><id>https://mienxiu.com/interprocess-communication</id><content type="html" xml:base="https://mienxiu.com/interprocess-communication/"><![CDATA[<p>A process is either independent or cooperating:</p>
<ul>
  <li>independent: if it cannot affect or be affected by the other processes, and does not share data with any other processes</li>
  <li>cooperating: if it can affect or be affected by the other processes, and shares data with other processes</li>
</ul>

<p>There are several benefits to provide a system where processes can work together:</p>
<ul>
  <li>Information sharing. Since several users may be interested in the same piece of information (for instance, a shared file), we must provide an environment to allow concurrent access to such information.</li>
  <li>Computation speedup. If we want a particular task to run faster, we must break it into subtasks, each of which will be executing in parallel with the others. Notice that such a speedup can be achieved only if the computer has multiple processing cores.</li>
  <li>Modularity. We may want to construct the system in a modular fashion, dividing the system functions into separate processes or threads, as we discussed in Chapter 2.</li>
  <li>Convenience. Even an individual user may work on many tasks at the same time. For instance, a user may be editing, listening to music, and compiling in parallel.</li>
</ul>

<h2 id="interprocess-communication">Interprocess Communication</h2>
<p>Interprocess Communication (IPC) is a set of methods that the operating system provides to allow multiple processes to communicate and work together.</p>

<p><img src="/assets/posts/34/two_models_of_ipc.png" alt="Two models of IPC" /></p>

<p>There are two fundamental models that enable interprocess communication:</p>
<ul>
  <li>message passing model: communication takes place by means of messages exchanged between the cooperating processes via sockets, pipes, message queues.</li>
  <li>shared memory model: a region of memory that is shared by cooperating processes is established. Processes can then exchange information by reading and writing data to the shared region</li>
</ul>

<p>Message passing is useful for exchanging small amounts of data because it avoids conflicts and is easier to implement in distributed systems compared to shared memory.
However, shared memory can be faster since message passing relies on system calls, which require more time due to kernel involvement.</p>

<p>While shared memory can be faster because it bypasses system calls and kernel intervention, recent research on multi-core systems suggests that message passing often outperforms shared memory in such environments.</p>

<h2 id="message-passing-systems">Message-Passing Systems</h2>
<p>In message-passing systems, processes create messages and then send or receive these messages through a communication channel.
This communication channel may be implemented as a buffer or a FIFO queue.</p>

<p>For message-based ICP, the operating system kernel is responsible for:</p>
<ul>
  <li>setting up and managing the communication channel</li>
  <li>handling the actual message transfers by providing an interface that enables processes to send and receive messages</li>
  <li>ensuring synchronization</li>
</ul>

<p><img src="/assets/posts/34/message_passing.png" alt="Message-passing" /></p>

<p>The basic mechanism is as follows:</p>
<ol>
  <li>Establishing a Communication Channel: The OS creates a communication channel between processes.</li>
  <li>Sending a Message: The sending process sends data into a port via a system call (<code class="language-plaintext highlighter-rouge">send()</code>).</li>
  <li>Transmitting the Message: The OS transfers the message through the channel to the destination.</li>
  <li>Receiving a Message: The receiving process receives the message by reading from a port via system call (<code class="language-plaintext highlighter-rouge">recv()</code>).</li>
</ol>

<p>Advantages:</p>
<ul>
  <li>simplicity: The OS kernel abstracts the complexities of direct process communication and synchronization.</li>
  <li>safety: The OS kernel enforces access control and isolation, preventing unauthorized access and ensuring data integrity.</li>
</ul>

<p>Disadvantages:</p>
<ul>
  <li>overheads: Crossing the kernel boundary and copying data in and out of the kernel introduce performance overheads, especially in high-frequency messaging scenarios.</li>
</ul>

<p>There are three primary methods of message-based IPC: pipes, message queues, and sockets.
Below is a table that outlines the key differences and characteristics of each method:</p>

<table>
  <thead>
    <tr>
      <th>IPC Method</th>
      <th>Data Structure</th>
      <th>Communication Scope</th>
      <th>API/Standards</th>
      <th>Key Characteristics</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Pipes</td>
      <td>stream of bytes</td>
      <td>two endpoints (one-to-one)</td>
      <td>POSIX</td>
      <td>simple, unidirectional</td>
    </tr>
    <tr>
      <td>Message queues</td>
      <td>discrete messages</td>
      <td>multiple processes supported</td>
      <td>POSIX, System V</td>
      <td>supports message formatting, prioritization, and scheduling</td>
    </tr>
    <tr>
      <td>Sockets</td>
      <td>message-based / stream</td>
      <td>local and networked processes</td>
      <td>socket API (TCP/IP)</td>
      <td>uses socket abstraction as ports (<code class="language-plaintext highlighter-rouge">socket()</code>, <code class="language-plaintext highlighter-rouge">send()</code>, <code class="language-plaintext highlighter-rouge">recv()</code>)</td>
    </tr>
  </tbody>
</table>

<h2 id="shared-memory-systems">Shared-Memory Systems</h2>
<p>In shared-memory systems, multiple processes can read from and write to a common memory region.
Typically, the process that creates the shared memory segment allocates it within its own address space, and any other process that needs to communicate through this segment must attach it to its address space.</p>

<p>The operating system maps specific physical pages into the virtual address spaces of all cooperating processes.
As a result, even though each process’s virtual address space may place the shared memory at different locations, they all reference the same physical memory.
This arrangement ensures that while the underlying physical memory is shared, each process maintains an independent virtual address layout.</p>

<p>Advantages:</p>
<ul>
  <li>Reduced OS overheads: Once the physical memory is mapped into the processes’ address spaces, the operating system plays no further role in data transfers, as system calls are only required during the initial setup.</li>
  <li>Minimized data copying: Processes can directly access only the necessary information in the shared memory without the overhead of copying entire datasets.</li>
</ul>

<p>Disadvantages:</p>
<ul>
  <li>Explicit synchronization: Because multiple processes can concurrently access the shared memory area, explicit synchronization mechanisms are required to prevent race conditions.</li>
  <li>Communication protocol design: Programmers are responsible to establish the communication protocol, determining how messages are formatted and exchanged.</li>
  <li>Shared buffer management: The allocation and management of the shared memory buffer are also the responsibility of the programmer, including decisions on when and which process can access the buffer.</li>
</ul>

<p>In Unix-based systems, including Linux, the System V API and POSIX API are the most common interfaces for managing shared memory.</p>

<h2 id="message-based-vs-shared-memory-based">Message-based vs Shared memory-based</h2>
<p>Below is a table comparing message-based IPC and shared memory-based IPC:</p>

<table>
  <thead>
    <tr>
      <th>Feature</th>
      <th>Message-based IPC</th>
      <th>Shared memory-based IPC</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Data Transfer</td>
      <td>Data is transferred via a communication channel (port).</td>
      <td>Both processes share a region of memory for direct access.</td>
    </tr>
    <tr>
      <td>Data Copying</td>
      <td>The CPU copies data into the port, then into the target process.</td>
      <td>The CPU maps physical memory into the address spaces of both processes.</td>
    </tr>
    <tr>
      <td>Efficiency for Large Data</td>
      <td>Less efficient, as multiple copies are required.</td>
      <td>More efficient, as fewer copies are needed.</td>
    </tr>
  </tbody>
</table>

<p>In summary, message-based IPC consumes more CPU cycles, particularly when dealing with large data.
On the other hand, while shared memory-based IPC can be initially costly due to memory mapping, it proves to be more efficient for transferring large amounts of data.
Therefore, the best IPC method depends on the size of the data and performance requirements, with shared memory being more efficient for larger data transfers.</p>

<p>In practice, systems like Windows uses a hybrid approach, also called as Local Procedure Calls (LPC), choosing the best method based on the data size:</p>
<ul>
  <li>For small data, message passing is used.</li>
  <li>For large data, shared memory mapping is employed for efficiency.</li>
</ul>

<h2 id="shared-memory-apis">Shared Memory APIs</h2>
<p>Below are the key high-level operations for managing shared memory in IPC:</p>
<ol>
  <li>Create: Allocates physical memory for a shared memory segment (or a communication channel), and returns an unique identifier (key) for any other processes to refer to the segment.</li>
  <li>Attach: Allows a process to connect to the shared memory segment by using the key, establishing mappings between virtual addresses and physical memory addresses of the segment.</li>
  <li>Detach: Disconnects a process from the shared memory segment by invalidating the address mappings.</li>
  <li>Destroy: Deallocates the shared memory segment when no longer needed, preventing memory leaks and freeing system resources.</li>
</ol>

<p>Note that a shared memory segment can be repeatedly attached and detached by multiple processes until it is explicitly destroyed.
This is in contrast to regular, non-shared memory, which is automatically freed when the process that allocated it exits.</p>

<p>There are two primary standards for implementing IPC on Unix-like systems: System V IPC and POSIX IPC.
While both standards offer similar functionality, their system calls differ in naming and implementation.</p>

<p>The following table summarizes the system calls for shared memory operations in each standard:</p>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>System V</th>
      <th>POSIX</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Create</td>
      <td><code class="language-plaintext highlighter-rouge">shmget()</code></td>
      <td><code class="language-plaintext highlighter-rouge">shm_open()</code></td>
    </tr>
    <tr>
      <td>Attach</td>
      <td><code class="language-plaintext highlighter-rouge">shmat()</code></td>
      <td><code class="language-plaintext highlighter-rouge">mmap()</code></td>
    </tr>
    <tr>
      <td>Detach</td>
      <td><code class="language-plaintext highlighter-rouge">shmdt()</code></td>
      <td><code class="language-plaintext highlighter-rouge">munmap()</code></td>
    </tr>
    <tr>
      <td>Destroy</td>
      <td><code class="language-plaintext highlighter-rouge">shmctl()</code></td>
      <td><code class="language-plaintext highlighter-rouge">shm_unlink()</code></td>
    </tr>
  </tbody>
</table>

<h3 id="system-v-ipc">System V IPC</h3>
<!-- Ref: https://tldp.org/LDP/lpg/node21.html -->
<p>System V IPC is a set of communication mechanisms that were originally introduced in the UNIX System V operating system.
While System V IPC is not widely used today, it laid the foundation for later IPC methods and is still supported in many UNIX-like systems for backward compatibility.</p>

<p>The following examples demonstrate the basic usage of System V shared memory.</p>

<p>For creating and writing to shared memory:</p>
<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// create_and_write.c</span>
<span class="cp">#include</span> <span class="cpf">&lt;stdio.h&gt;</span><span class="c1">   // for printf()</span><span class="cp">
#include</span> <span class="cpf">&lt;sys/ipc.h&gt;</span><span class="c1"> // for ftok()</span><span class="cp">
#include</span> <span class="cpf">&lt;sys/shm.h&gt;</span><span class="c1"> // for shmget(), shmat(), shmdt()</span><span class="cp">
#include</span> <span class="cpf">&lt;string.h&gt;</span><span class="c1">  // for strcpy()</span><span class="cp">
</span>
<span class="kt">int</span> <span class="nf">main</span><span class="p">()</span>
<span class="p">{</span>
    <span class="n">key_t</span> <span class="n">key</span> <span class="o">=</span> <span class="n">ftok</span><span class="p">(</span><span class="s">"shmfile"</span><span class="p">,</span> <span class="mi">65</span><span class="p">);</span>                 <span class="c1">// generate unique key for System V IPC</span>
    <span class="kt">int</span> <span class="n">shmid</span> <span class="o">=</span> <span class="n">shmget</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="mi">1024</span><span class="p">,</span> <span class="mo">0666</span> <span class="o">|</span> <span class="n">IPC_CREAT</span><span class="p">);</span> <span class="c1">// create shared memory segment</span>
    <span class="kt">char</span> <span class="o">*</span><span class="n">str</span> <span class="o">=</span> <span class="p">(</span><span class="kt">char</span> <span class="o">*</span><span class="p">)</span><span class="n">shmat</span><span class="p">(</span><span class="n">shmid</span><span class="p">,</span> <span class="p">(</span><span class="kt">void</span> <span class="o">*</span><span class="p">)</span><span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">);</span>  <span class="c1">// attach shared memory segment to the process's address space</span>

    <span class="n">strcpy</span><span class="p">(</span><span class="n">str</span><span class="p">,</span> <span class="s">"Hello from System V shared memory!"</span><span class="p">);</span> <span class="c1">// write data to shared memory</span>
    <span class="n">printf</span><span class="p">(</span><span class="s">"Data written: %s</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">str</span><span class="p">);</span>

    <span class="n">shmdt</span><span class="p">(</span><span class="n">str</span><span class="p">);</span> <span class="c1">// detach shared memory segment from the process's address space</span>

    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Detailed explanations:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">key_t key = ftok("shmfile", 65);</code>: generates a key of type <code class="language-plaintext highlighter-rouge">key_t</code> based on the file’s inode (<code class="language-plaintext highlighter-rouge">"shmfile"</code>) and the provided integer (<code class="language-plaintext highlighter-rouge">65</code>, which is just an arbitrary number). This key uniquely identifies the shared memory segment.</li>
  <li><code class="language-plaintext highlighter-rouge">int shmid = shmget(key, 1024, 0666 | IPC_CREAT);</code>: uses the key generated earlier. The second parameter, <code class="language-plaintext highlighter-rouge">1024</code>, specifies the size of the memory segment in bytes. The third parameter, <code class="language-plaintext highlighter-rouge">0666|IPC_CREAT</code>, sets the permissions (read and write for everyone) and tells the system to create the segment if it doesn’t already exist.</li>
  <li><code class="language-plaintext highlighter-rouge">char *str = (char *)shmat(shmid, (void *)0, 0);</code>: returns a pointer to the shared memory. The first parameter is the shared memory ID obtained from <code class="language-plaintext highlighter-rouge">shmget()</code>. The second parameter is set to <code class="language-plaintext highlighter-rouge">(void*)0</code>, which lets the operating system choose the attach address. The third parameter is <code class="language-plaintext highlighter-rouge">0</code>, which means no special flags are used during attachment.</li>
  <li><code class="language-plaintext highlighter-rouge">shmdt(str);</code>: releases the memory region previously attached with <code class="language-plaintext highlighter-rouge">shmat()</code>, ensuring that the process no longer accesses the shared memory.</li>
</ul>

<p>Complie and run:</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gcc create_and_write.c <span class="nt">-o</span> create_and_write
./create_and_write
</code></pre></div></div>

<p>Output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Data written: Hello from System V shared memory!
</code></pre></div></div>

<p>For reading and destroying the shared memory:</p>
<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// read_and_destroy.c</span>
<span class="cp">#include</span> <span class="cpf">&lt;stdio.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;sys/ipc.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;sys/shm.h&gt;</span><span class="cp">
</span>
<span class="kt">int</span> <span class="nf">main</span><span class="p">()</span>
<span class="p">{</span>
    <span class="n">key_t</span> <span class="n">key</span> <span class="o">=</span> <span class="n">ftok</span><span class="p">(</span><span class="s">"shmfile"</span><span class="p">,</span> <span class="mi">65</span><span class="p">);</span>
    <span class="kt">int</span> <span class="n">shmid</span> <span class="o">=</span> <span class="n">shmget</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="mi">1024</span><span class="p">,</span> <span class="mo">0666</span> <span class="o">|</span> <span class="n">IPC_CREAT</span><span class="p">);</span> <span class="c1">// access shared memory segment</span>
    <span class="kt">char</span> <span class="o">*</span><span class="n">str</span> <span class="o">=</span> <span class="p">(</span><span class="kt">char</span> <span class="o">*</span><span class="p">)</span><span class="n">shmat</span><span class="p">(</span><span class="n">shmid</span><span class="p">,</span> <span class="p">(</span><span class="kt">void</span> <span class="o">*</span><span class="p">)</span><span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">);</span>  <span class="c1">// attach shared memory segment to the process's address space</span>

    <span class="n">printf</span><span class="p">(</span><span class="s">"Data read: %s</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">str</span><span class="p">);</span>

    <span class="n">shmdt</span><span class="p">(</span><span class="n">str</span><span class="p">);</span> <span class="c1">// detach shared memory segment from the process's address space</span>

    <span class="n">shmctl</span><span class="p">(</span><span class="n">shmid</span><span class="p">,</span> <span class="n">IPC_RMID</span><span class="p">,</span> <span class="nb">NULL</span><span class="p">);</span> <span class="c1">// destroy shared memory segment</span>
    <span class="n">printf</span><span class="p">(</span><span class="s">"Shared memory destroyed.</span><span class="se">\n</span><span class="s">"</span><span class="p">);</span>

    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Detailed explanations:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">int shmid = shmget(key, 1024, 0666 | IPC_CREAT);</code>: accesses the exsiting shared memory segment. Note that it does not create the shared memory as it already exists.</li>
  <li><code class="language-plaintext highlighter-rouge">shmctl(shmid, IPC_RMID, NULL);</code>: destroys (or marks for deletion) the shared memory segment. The command <code class="language-plaintext highlighter-rouge">IPC_RMID</code> tells the system to remove the shared memory segment. The third parameter is <code class="language-plaintext highlighter-rouge">NULL</code> because no additional options or data structures are needed for this operation.</li>
</ul>

<p>Complie and run:</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gcc read_and_destroy.c <span class="nt">-o</span> read_and_destroy
./read_and_destroy
</code></pre></div></div>

<p>Output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Data read: Hello from System V shared memory!
Shared memory destroyed.
</code></pre></div></div>

<p>As demonstrated here, even after a process exits, the shared memory remains allocated until it is explicitly removed, allowing other processes to access the data if needed.</p>

<h3 id="posix-ipc">POSIX IPC</h3>
<!-- Ref: https://man7.org/linux/man-pages/man7/shm_overview.7.html -->
<p>POSIX shared memory is a modern and widely-used inter-process communication (IPC) mechanism standardized in POSIX-compliant systems.</p>

<p>The following examples demonstrate the basic usage of POSIX shared memory.</p>

<p>For creating and writing to shared memory:</p>
<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// create_and_write.c</span>
<span class="cp">#include</span> <span class="cpf">&lt;stdio.h&gt;</span><span class="c1">    // for printf()</span><span class="cp">
#include</span> <span class="cpf">&lt;fcntl.h&gt;</span><span class="c1">    // for O_CREAT, O_RDWR</span><span class="cp">
#include</span> <span class="cpf">&lt;sys/mman.h&gt;</span><span class="c1"> // for shm_open(), mmap()</span><span class="cp">
#include</span> <span class="cpf">&lt;unistd.h&gt;</span><span class="c1">   // for ftruncate()</span><span class="cp">
#include</span> <span class="cpf">&lt;string.h&gt;</span><span class="c1">   // for strcpy()</span><span class="cp">
</span>
<span class="kt">int</span> <span class="nf">main</span><span class="p">()</span>
<span class="p">{</span>
    <span class="kt">int</span> <span class="n">shm_fd</span> <span class="o">=</span> <span class="n">shm_open</span><span class="p">(</span><span class="s">"/posix_shm"</span><span class="p">,</span> <span class="n">O_CREAT</span> <span class="o">|</span> <span class="n">O_RDWR</span><span class="p">,</span> <span class="mo">0666</span><span class="p">);</span> <span class="c1">// create POSIX shared memory object</span>
    <span class="n">ftruncate</span><span class="p">(</span><span class="n">shm_fd</span><span class="p">,</span> <span class="mi">1024</span><span class="p">);</span>                                     <span class="c1">// set size of shared memory segment</span>

    <span class="kt">char</span> <span class="o">*</span><span class="n">ptr</span> <span class="o">=</span> <span class="n">mmap</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">1024</span><span class="p">,</span> <span class="n">PROT_WRITE</span><span class="p">,</span> <span class="n">MAP_SHARED</span><span class="p">,</span> <span class="n">shm_fd</span><span class="p">,</span> <span class="mi">0</span><span class="p">);</span> <span class="c1">// map shared memory segment to process address space</span>

    <span class="n">strcpy</span><span class="p">(</span><span class="n">ptr</span><span class="p">,</span> <span class="s">"Hello from POSIX shared memory!"</span><span class="p">);</span> <span class="c1">// write data to shared memory</span>
    <span class="n">printf</span><span class="p">(</span><span class="s">"Data written: %s</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">ptr</span><span class="p">);</span>

    <span class="n">munmap</span><span class="p">(</span><span class="n">ptr</span><span class="p">,</span> <span class="mi">1024</span><span class="p">);</span> <span class="c1">// unmap shared memory segment</span>
    <span class="n">close</span><span class="p">(</span><span class="n">shm_fd</span><span class="p">);</span>     <span class="c1">// close file descriptor</span>

    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Detailed explanations:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">shm_open("/posix_shm", O_CREAT | O_RDWR, 0666);</code>: creates a POSIX shared memory object named <code class="language-plaintext highlighter-rouge">/posix_shm</code>. <code class="language-plaintext highlighter-rouge">O_CREAT</code> flag instructs <code class="language-plaintext highlighter-rouge">shm_open()</code> to create a new shared memory object if one does not already exist. If the object exists, it is opened. <code class="language-plaintext highlighter-rouge">O_RDWR</code> flag grants read and write access to the shared memory object. The permissions <code class="language-plaintext highlighter-rouge">0666</code> allow read/write access to everyone.</li>
  <li><code class="language-plaintext highlighter-rouge">ftruncate(shm_fd, 1024);</code>: resizes the shared memory segment to 1024 bytes. This is necessary as shared memory objects don’t automatically have a defined size when created.</li>
  <li><code class="language-plaintext highlighter-rouge">mmap(0, 1024, PROT_WRITE, MAP_SHARED, shm_fd, 0);</code>: maps the shared memory into the calling process’s address space with write permissions. Passing <code class="language-plaintext highlighter-rouge">0</code> lets the system choose the address. <code class="language-plaintext highlighter-rouge">1024</code> is the length of the memory to map, which is 1024 bytes in this case. <code class="language-plaintext highlighter-rouge">PROT_WRITE</code> flag allows the process to write to the mapped memory. <code class="language-plaintext highlighter-rouge">PROT_READ</code> can be added if reading is also needed. <code class="language-plaintext highlighter-rouge">shm_fd</code> is the file descriptor returned by <code class="language-plaintext highlighter-rouge">shm_open()</code>, which identifies the shared memory object to be mapped. The last <code class="language-plaintext highlighter-rouge">0</code> is the offset within the shared memory object at which the mapping should start.</li>
  <li><code class="language-plaintext highlighter-rouge">munmap(ptr, 1024);</code>: unmaps the previously mapped shared memory region from the process’s address space.</li>
  <li><code class="language-plaintext highlighter-rouge">close(shm_fd)</code>: closes the file descriptor <code class="language-plaintext highlighter-rouge">shm_fd</code> to ensure that the file system resources are properly freed and prevents memory leaks.</li>
</ul>

<p>Compile and run:</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gcc create_and_write.c <span class="nt">-o</span> create_and_write
./create_and_write
</code></pre></div></div>

<p>Output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Data written: Hello from POSIX shared memory!
</code></pre></div></div>

<p>For reading and destroying the shared memory:</p>
<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// read_and_destroy.c</span>
<span class="cp">#include</span> <span class="cpf">&lt;stdio.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;fcntl.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;sys/mman.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;unistd.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;sys/stat.h&gt;</span><span class="cp">
</span>
<span class="kt">int</span> <span class="nf">main</span><span class="p">()</span>
<span class="p">{</span>
    <span class="kt">int</span> <span class="n">shm_fd</span> <span class="o">=</span> <span class="n">shm_open</span><span class="p">(</span><span class="s">"/posix_shm"</span><span class="p">,</span> <span class="n">O_RDONLY</span><span class="p">,</span> <span class="mo">0666</span><span class="p">);</span> <span class="c1">// open existing shared memory object</span>

    <span class="k">struct</span> <span class="n">stat</span> <span class="n">shm_stat</span><span class="p">;</span>
    <span class="c1">// prevent segmentation fault if shared memory object does not exist</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">fstat</span><span class="p">(</span><span class="n">shm_fd</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">shm_stat</span><span class="p">)</span> <span class="o">==</span> <span class="o">-</span><span class="mi">1</span><span class="p">)</span>
    <span class="p">{</span> <span class="c1">// get size of shared memory</span>
        <span class="n">perror</span><span class="p">(</span><span class="s">"fstat failed"</span><span class="p">);</span>
        <span class="n">close</span><span class="p">(</span><span class="n">shm_fd</span><span class="p">);</span>
        <span class="k">return</span> <span class="mi">1</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="kt">char</span> <span class="o">*</span><span class="n">ptr</span> <span class="o">=</span> <span class="n">mmap</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="n">shm_stat</span><span class="p">.</span><span class="n">st_size</span><span class="p">,</span> <span class="n">PROT_READ</span><span class="p">,</span> <span class="n">MAP_SHARED</span><span class="p">,</span> <span class="n">shm_fd</span><span class="p">,</span> <span class="mi">0</span><span class="p">);</span> <span class="c1">// map shared memory segment for reading</span>

    <span class="n">printf</span><span class="p">(</span><span class="s">"Data read: %s</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">ptr</span><span class="p">);</span>

    <span class="n">munmap</span><span class="p">(</span><span class="n">ptr</span><span class="p">,</span> <span class="n">shm_stat</span><span class="p">.</span><span class="n">st_size</span><span class="p">);</span> <span class="c1">// unmap shared memory segment</span>
    <span class="n">close</span><span class="p">(</span><span class="n">shm_fd</span><span class="p">);</span>                 <span class="c1">// close file descriptor</span>

    <span class="n">shm_unlink</span><span class="p">(</span><span class="s">"/posix_shm"</span><span class="p">);</span> <span class="c1">// destroy shared memory segment</span>
    <span class="n">printf</span><span class="p">(</span><span class="s">"Shared memory destroyed.</span><span class="se">\n</span><span class="s">"</span><span class="p">);</span>

    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Detailed explanations:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">shm_open("/posix_shm", O_RDONLY, 0666);</code>: opens an existing shared memory object named <code class="language-plaintext highlighter-rouge">/posix_shm</code> in read-only mode (<code class="language-plaintext highlighter-rouge">O_RDONLY</code>).</li>
  <li><code class="language-plaintext highlighter-rouge">fstat(shm_fd, &amp;shm_stat);</code>: obtains the size of the shared memory object. The <code class="language-plaintext highlighter-rouge">&amp;shm_stat</code> is a pointer to a <code class="language-plaintext highlighter-rouge">struct stat</code> where the metadata (like the size of the shared memory) will be stored. The relevant field here is <code class="language-plaintext highlighter-rouge">st_size</code>, which holds the size of the shared memory object.</li>
  <li><code class="language-plaintext highlighter-rouge">shm_unlink("/posix_shm");</code>: destroys the shared memory object <code class="language-plaintext highlighter-rouge">/posix_shm</code> from the system.</li>
</ul>

<p>Compile and run:</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gcc read_and_destroy.c <span class="nt">-o</span> read_and_destroy
./read_and_destroy
</code></pre></div></div>

<p>Output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Data read: Hello from POSIX shared memory!
Shared memory destroyed.
</code></pre></div></div>

<p>Just like System V shared memory, POSIX shared memory also remains available to other processes until explicitly removed using <code class="language-plaintext highlighter-rouge">shm_unlink()</code>.</p>

<h2 id="design-considerations">Design Considerations</h2>
<p>When using shared memory, the operating system provides the shared memory area but imposes no restrictions on how the memory is used.
This means that it is the programmer’s responsibility to manage data passing and synchronization effectively.</p>

<p><img src="/assets/posts/34/segment_consideration.png" alt="segment consideration" /></p>

<p>A key design consideration is determining how many memory segments for communication.
There are two primary options:</p>
<ul>
  <li>One large segment</li>
  <li>Multiple smaller segments</li>
</ul>

<p>Using a single large segment simplifies memory management since there is only one shared area to allocate and free.
This reduces the complexity associated with managing multiple segments.
However, it still requires careful memory management to allocate and free memory for threads from different processes.</p>

<p>On the other hand, using multiple smaller segments often involves pre-allocating a pool of segments to minimize the overhead of segment creation.
In addition, the programmer must implement a mechanism, such as a queue, to allow threads to determine which available segment to use for communication.
After all, it adds some additional complexity to the design.</p>

<p>Another consideration is determining how large a shared memory segment should be.
Fixed-size segments work well if the data size is known and static, however, it limits flexibility since data sizes might not be static.
For dynamic data, transferring in rounds is an option, where data is split across multiple rounds.
The programmer needs to implement some protocol to track progress, using a data structure that includes the buffer, synchronization, and flags.</p>]]></content><author><name>Mienxiu</name></author><category term="c" /><category term="os" /><summary type="html"><![CDATA[A process is either independent or cooperating: independent: if it cannot affect or be affected by the other processes, and does not share data with any other processes cooperating: if it can affect or be affected by the other processes, and shares data with other processes]]></summary></entry></feed>