← Back to blog
Engineering August 4, 2026 by Javier Arancibia

15 Lines of Bash Beat My 3,000-Line Search Engine

I built a Go search engine with TF-IDF, graph theory, and bidirectional stemming. It lost to a shell script embedded in a 79 KB binary. Then I made the Go engine learn from the shell script. Here's what happened.


The setup

I have 273 skills on my machine — markdown files (SKILL.md) that teach AI agents how to work on my projects. Each one has a name, a description, and a body of instructions. When an agent starts a task, it needs to find the right skill. This is a search problem.

I had two search engines for this:

skills.match — a built-in MCP tool in supercli's machin binary. The entire implementation is 12 lines of MFL that generate a 15-line bash script. It runs find + grep + sort + uniq + head on the filesystem every time it's called. No index, no state, no precomputation. The binary is 79 KB total.

memgraph — a standalone Go binary with a pre-built knowledge graph. It has TF-IDF scoring, IDF weighting (rare terms score higher), word-boundary matching, suffix stemming, graph boost (connected nodes reinforce each other), extended content reading (first 1000 chars of each skill body), and a Three.js galaxy UI. The scoring function alone is 130 lines of Go.

The benchmark

I ran 10 queries through both engines and checked which one found the right skill:

Queryskills.matchmemgraphWinner
local issue tracking in repobeads-trackerjar-portafolio-trackskills.match
deploy docker with traefikjar-apresnation-setuptraefik-cloudflare-setupmemgraph
generate changelog from git historychangelog-updatergenerate-vm-access-promptskills.match
run multiple AI agents in parallelsupercli-mcocoding-bridge-apiskills.match
audit website for securityaudit-websiteaudit-websitetie

Score: skills.match 3 wins, memgraph 1 win, ties 6. The shell script was beating the Go engine.

Why the shell script won

I dug into the code and found four reasons — each one a different kind of lesson.

1. The graph was stale

memgraph loads a pre-built graph from ~/.memgraph/skills-graph/graph.json. That file was 6 days old. Any skill created since then was invisible. skills.match scans the filesystem every call — it's always live.

The fix: load the newest graph.json by modification time, not a hardcoded path. 20 lines of Go.

2. Half the skills were missing

This was the big one. scanSkillFiles had this line:

if info.Type()&os.ModeSymlink != 0 {
    return nil  // skip symlinks
}

217 of my 424 SKILL.md files are symlinks — skills shared across ~/.agents/skills, ~/.claude/skills, ~/.config/devin/skills, and ~/.codeium/windsurf/skills. The graph had 219 nodes. It should have had 273. 25% of skills were invisible.

The changelog-updater skill — the exact right answer for "generate changelog from git history" — was a symlink to ~/.config/opencode/skills/changelog-updater. It wasn't in the graph. memgraph couldn't find it because it literally didn't know it existed.

Meanwhile, skills.match uses find -L (follow symlinks) and readlink -f (resolve to real path) + uniq -s 2 (dedup by path). Three standard Unix tools, one pipeline, correct result.

3. No stemming

Query: "local issue tracking in repo". The right skill is beads-tracker. memgraph ranked it 3rd, below jar-portafolio-track (a TODO reminder system, not an issue tracker).

The problem: "tracking" stems to "track". jar-portafolio-track has "track" in its name → +30 × IDF bonus. beads-tracker has "tracker" in its name, but "tracking" → "track" doesn't word-boundary-match "tracker". The stemming was one-sided — it stripped suffixes from the query word but not from the text word.

The fix: stem both sides. "tracking" → "track", "tracker" → "track". They match. Added er, ed, ers suffix stripping. 30 lines of Go.

4. IDF weighting backfired

Query: "generate changelog from git history". The word "generate" appears in 5 skill names. IDF gives it a weight of 3.78 (rare-ish). The name-match bonus is +30 × 3.78 = 113 points. So generate-vm-access-prompt scored 113 points just for having "generate" in its name — even though it has nothing to do with changelogs.

skills.match doesn't have this problem because it doesn't weight by rarity. Every word counts as 1. "generate" matching "generate" is worth the same as "changelog" matching "changelog". That's crude, but it doesn't produce false positives from common verbs.

The fix: add 18 generic action verbs (generate, deploy, configure, install, manage, etc.) to the stop words list. They no longer get the name-match bonus.

Making memgraph learn from the shell script

After fixing those four bugs, memgraph was competitive but not better. It was still losing on queries where the right skill matched more query words overall, but a different skill had one rare word in its name.

So I did the obvious thing: I copied skills.match's algorithm into memgraph.

The shell script's scoring is: count how many query words (after removing stop words) appear as substrings in name + " " + description. Each match is +1. Sort by count. That's it.

I added this as a signal to memgraph's scoreNode function: for each non-stopword query word that appears as a substring in name+description, add +40 points. This is pure word overlap — no IDF, no position weighting, no graph boost. It's the skills.match algorithm, bolted on as a tie-breaker.

Example: "run multiple AI coding agents in parallel". Query words (after stop words): multiple, coding, parallel.

  • supercli-mco: all 3 words in description → +120 overlap. Also gets IDF-weighted name match for "coding" → +113. Total: ~280.
  • coding-bridge-api: 1 word ("coding") in name → +40 overlap. Also +113 IDF name match. Total: ~240.

The overlap signal pushed supercli-mco to #1. The shell script's crude word count was the missing ingredient in the Go engine's sophisticated scoring.

The final score

After all fixes (stale graph, symlink ingestion, bidirectional stemming, stop words, overlap signal), I re-ran the 10-query benchmark:

  • 7 ties — both engines found the right skill
  • 3 disagreements — both engines were wrong (no matching skill exists for those queries)
  • 0 cases where skills.match was right and memgraph was wrong

memgraph is now on par with skills.match. Not better — on par. The 3,000-line Go engine with TF-IDF, graph theory, and a Three.js galaxy UI matches the performance of a 15-line bash pipeline.

What I learned

Simplicity is a feature, not a deficit. skills.match doesn't have stale graphs because it has no graph. It doesn't miss symlinks because find -L follows them by design. It doesn't have IDF false positives because it doesn't use IDF. Every bug in memgraph was a bug in complexity that skills.match didn't have.

Unix tools are underrated. find -L + readlink -f + uniq -s 2 solves symlink deduplication in one pipeline. I wrote 60 lines of Go to do the same thing in scanSkillFiles. The Unix pipeline was also correct from the start — my Go code had a bug in the symlink handling that I had to test and fix.

The best signal is often the simplest one. I spent time tuning IDF weights, graph boost factors, and stemming rules. The thing that actually fixed the ranking was a raw word count — the same algorithm the shell script had been using all along. I could have started with that and added complexity only where it proved necessary.

Freshness beats sophistication. The single biggest bug was the stale graph. Not a scoring bug — a data bug. The engine was sophisticated enough to find the right answer, but it was looking at data that was 6 days old. skills.match doesn't have this problem because it reads the filesystem every time. Sometimes the best index is no index.

Where memgraph still wins

This isn't a "shell script is always better" story. memgraph has real advantages:

  • Content depth — it reads the first 1000 chars of each skill body, not just the 120-char frontmatter description. Catches terms that are in the instructions but not in the summary.
  • Relationships — the related field surfaces connected skills. "You need beads-tracker? Also look at agent-memory-toolbox." That's genuinely useful for discovery.
  • Speed — 10 ms per query vs 200 ms for the filesystem scan. Both are fast enough, but at scale the difference matters.
  • Score separation — memgraph's scores have clean separation (386 vs 51 vs 48). skills.match's integer counts cluster (3, 3, 2, 2). Ties are more common in the shell script.

The ideal setup is both: memgraph as the primary (better rankings when the graph is fresh), skills.match as the fallback (catches new skills immediately). Merge the results, dedup by path.

The takeaway

I'm not going to delete memgraph. The graph relationships and content depth are worth keeping. But I'm also not going to pretend the 15-line bash script isn't good enough for 90% of queries. It is.

The next time I'm tempted to build a sophisticated search engine, I'll start with find | grep | sort | head and see how far that gets me. The answer, apparently, is "farther than I expected."


The code is on GitHub: memgraph and supercli (skills.match is in supercli-machin-cli/app.mfl). The issue that tracked all the fixes is here.

Enjoyed this post?

Follow for more on agent-first engineering, self-hosted systems, and building for autonomy.

Follow @javimosch