knowledge base search

in daily useagentic automation · python · vector search

problem
Support agents searched a large knowledge base by hand while customers waited.
what i built
An automation that keeps the knowledge base in a vector database, synced from HubSpot, and finds the right article for a question asked in plain words. To make it easy for everyone to use, I shared it as one simple command: an agent types /kb and a question, and gets the article's steps and when to escalate.
result
Used every day by 12 full-time support agents. Finding an article by hand took about 20 seconds; with /kb, the answer arrives in about 3.

demo

>/kb screen went black after last night's update

kb · searched the knowledge base · 3 matches

$ python scripts/search.py "screen went black after last night's update"

  1. 0.56Black screen after a firmware updateMedia Player X1 · playback · E-104
  2. 0.14Touchscreen not responding to tapsInteractive Kiosk K1 · hardware · E-620
  3. 0.14Screen flickers or shows linesCommercial Display 55in · hardware

vector search over 10 articles · confident match · returned the steps for #1007

Black screen after a firmware update (article #1007)

  1. Check the player's status LED. Blinking amber means the update is still installing: wait 15 minutes (updates on X1 firmware 4.2+ take longer).
  2. If the LED is solid green, reboot the player from the management console. Content should appear within 3 minutes.
  3. Confirm a playlist is assigned to the device in the console.
  4. Re-push the current playlist.

Escalate if still black after re-pushing the playlist, or the player shows offline for more than 30 minutes.

>/kb kiosk shows E-620, taps do nothing

kb · exact match on error code E-620

$ python scripts/search.py "kiosk shows E-620, taps do nothing"

  1. 1.00Touchscreen not responding to tapsInteractive Kiosk K1 · hardware · E-620 · matched on the exact code
  2. 0.08Display shows 'No signal'Commercial Display 55in · hardware
  3. 0.07New content not showing, old content still playingContent Management Console · content

an exact error code outranks similarity · returned the steps for #1011

Touchscreen not responding to taps (article #1011)

  1. Ask site staff to wipe the screen; moisture or grease blocks touch input.
  2. Reboot the kiosk from the management console.
  3. In the console, confirm the touch driver shows as connected.
  4. Run the touch calibration from the kiosk's maintenance menu.

Escalate if touch is still unresponsive after calibration, or the driver shows as disconnected.

>

Real output from the demo version, replayed. The articles are made up.

how it works

  1. knowledge base. Articles live in HubSpot, where the support team writes and edits them.
  2. sync job. A scheduled job compares HubSpot with the index and re-processes only what was added, edited or removed.
  3. vector database. Each article is stored as vectors, so a question can be matched by meaning and not only by exact words.
  4. /kb. An agent types /kb and a question in Claude Code. The skill tells Claude how to search and how to answer.
  5. python search. The script looks for an exact error code first, then ranks articles by similarity to the question.
  6. answer. Claude replies with the article's steps and when to escalate, and names the article it used.

code

kb.pyexcerpt

def sync(conn: sqlite3.Connection, articles: list[Article],
         source: str) -> SyncResult:
    """Make the index match `articles`, re-embedding only new or changed articles."""
    existing = {aid: (h, title) for aid, h, title in conn.execute(
        "SELECT article_id, content_hash, title FROM articles")}
    incoming = {a.article_id: a for a in articles}
    res = SyncResult([], [], [], 0)

    with conn:
        for aid in existing.keys() - incoming.keys():
            conn.execute("DELETE FROM chunks WHERE article_id = ?", (aid,))
            conn.execute("DELETE FROM articles WHERE article_id = ?", (aid,))
            res.removed.append(existing[aid][1])
        for aid, art in incoming.items():
            h = art.content_hash
            if aid not in existing:
                res.added.append(art.title)
            elif existing[aid][0] == h:
                res.unchanged += 1
                continue
            else:
                conn.execute("DELETE FROM chunks WHERE article_id = ?", (aid,))
                res.updated.append(art.title)
            row = {**asdict(art), "content_hash": h}
            conn.execute(f"INSERT OR REPLACE INTO articles ({', '.join(row)}) "
                         f"VALUES ({', '.join(':' + c for c in row)})", row)
            conn.executemany(
                "INSERT INTO chunks (article_id, section, text, vector) VALUES (?,?,?,?)",
                [(aid, sec, txt, embed(txt).tobytes()) for sec, txt in art.chunks()])
        conn.execute(
            "INSERT INTO sync_log (source, added, updated, removed, unchanged) "
            "VALUES (?,?,?,?,?)",
            (source, len(res.added), len(res.updated), len(res.removed), res.unchanged))
    return res


def search(conn: sqlite3.Connection, query: str, k: int = 3) -> list[Hit]:
    """Top k articles: an exact error code first, then by best chunk similarity."""
    qvec = embed(query)
    codes = {re.sub(r"^[Ee]-?", "E-", c) for c in ERROR_CODE_RE.findall(query)}

    # brute force; fine for a few hundred articles (see README for timings)
    best: dict[int, tuple[float, str]] = {}
    for aid, section, blob in conn.execute(
            "SELECT article_id, section, vector FROM chunks"):
        vec = array.array("f")
        vec.frombytes(blob)
        score = cosine(qvec, vec)
        if aid not in best or score > best[aid][0]:
            best[aid] = (score, section)

    if codes:  # exact error-code match beats similarity
        marks = ",".join("?" * len(codes))
        for (aid,) in conn.execute(
                f"SELECT article_id FROM articles WHERE error_code IN ({marks})",
                sorted(codes)):
            best[aid] = (1.0, "error code")

    top = sorted(best.items(), key=lambda item: item[1][0], reverse=True)[:k]
    if not top:
        return []
    rows = {r[0]: r for r in conn.execute(
        f"SELECT article_id, title, product, category, error_code, body "
        f"FROM articles WHERE article_id IN ({','.join('?' * len(top))})",
        [aid for aid, _ in top])}
    return [Hit(*rows[aid], score=max(score, 0.0), matched=section)
            for aid, (score, section) in top]
From the version I rebuilt from scratch on made-up articles: the sync that re-embeds only what changed, and the two-pass search, exactly as they are in the repo. It shares no code with the original.full repo on github

technical breakdown

keeping the index in sync

The first version loaded a routine bulk CSV export from HubSpot. Once the HubSpot API was available, I replaced that with a cron job that cross-references the HubSpot knowledge base for changes and updates the internal vector database to match.

In the demo version, the sync compares a content hash per article, so only new or edited articles are processed again. Applying a month of changes to the sample knowledge base adds one article, updates one, removes one and leaves the other eight alone.

search

The demo version ranks in two passes. If the question contains an error code such as E-620, the article with that code goes straight to the top, because exact codes are where similarity search is weakest. Otherwise each article is split into two chunks, one holding the symptoms and one holding the fix, and the question is compared against both.

Below a confidence floor, the skill says it found no confident match and recommends escalating. The demo uses a small built-in embedder so it runs anywhere with no API keys; a production setup swaps in a real embedding model.

safeguards

shared as one simple command

To make the pipeline and search easy for the whole team to use, I packaged them in a simplified form: a Claude Code skill, so an agent only types /kb and a question. I set up release control for it, so every agent automatically stays on the latest version.

about the demo

The demo above replays real output from a version I rebuilt from scratch on made-up articles. It follows a similar workflow to the original and shares none of the company's code or data. It has 10 automated tests covering sync and search. The full rebuild is public on GitHub: vuln7/KBsearch, with instructions to run it in a few seconds using Python alone.

stack

status

In use every day by 12 full-time support agents at a digital-signage company. It became the team's search engine for the knowledge base, and a lookup that took about 20 seconds by hand now takes about 3, from asking /kb to getting the answer.

what i learned

How to build workflows and pipelines in HubSpot, how to automate HubSpot in general, and how to automate processes a company depends on every day.

more projects