mooner / Resume KO/EN /

mooner:~/resume $ cat projects/amoa/README.md

AMOA

Hybrid-search backend

The backend of a learning service that layers playlists, reviews, recipe sharing, recommendations and unified search over short lecture clips. As the only backend developer I build the API, the data model, and search with its embedding indexing pipeline.

mooner:~/resume/projects/amoa $ tree -L 1

Structure01

구조

06 parts

Started on Spring Boot and rewritten on Ktor and Exposed; today it is a feature-based vertical slice layout where each feature’s domain owns its ports and infrastructure implements them.

app/
Assembly only: registers the features and holds the architecture-rule tests.
platform/
Database, Redis, outbox, error responses, and adapters for outside services such as embeddings and media.
common/
The contracts every feature relies on: error format, domain events, transactions, vector and search SPIs.
feature/
Eleven features such as account, catalog, playlist, recipe, recommendation and search. Inside, only route → application → domain ← infrastructure is allowed, enforced by tests.
docugen/
A DSL that generates OpenAPI 3.1 documents from route definitions.
search SPI
Search knows no other feature; it collects the search and suggestion providers each feature registers.

mooner:~/resume/projects/amoa $ cat docs/architecture.md

Architecture02

아키텍처

Data flow

QUERY/searchUNIFIED SEARCHsynonyms · query cacheeach provider · lecture · clip · recipeVECTOR ARM512 → 768 halfvecKEYWORD ARMpg_trgmRRFk = 60RESULTSRRF across typesCONTENT WRITEsame transactionEMBEDDING_JOBoutbox queueWORKERSKIP LOCKED · leaseEMBED SERVER768 dimsPGVECTORHNSW · halfvecread by the vector arm// no embedding server? keyword arm only,// the response still comes back

Search runs vector and keyword retrieval in one SQL statement and fuses the rankings (RRF). Embeddings are filled outside the request path by a worker draining an outbox queue.

  • The vector side takes HNSW candidates on the first 512 dimensions (halfvec) of a 768-dimension embedding, then re-ranks only those candidates on the full vector.
  • The keyword side ranks by pg_trgm similarity. The two are combined with a FULL OUTER JOIN and ordered by RRF (k = 60).
  • Unified search calls the lecture, clip and recipe providers in parallel and fuses their rankings with RRF once more.
  • If the embedding server is missing or failing, search drops to keyword-only and still answers.
  • Index jobs are enqueued in the same transaction that changes the content, claimed by the worker with FOR UPDATE SKIP LOCKED, retried with growing gaps and parked after the fifth failure.
  • Suggestions compare Hangul by jamo, so a syllable still being typed and initial-consonant queries both match.

mooner:~/resume/projects/amoa $ git log --grep=fix

Problem solving03

문제 해결

05 cases

  1. 01

    Index jobs silently stalled on every deploy

    Problem
    If the process restarted after claiming an index job but before saving, neither the claim query nor the backfill sweep ever picked that clip up again. It stayed unindexed with no error; locally 10 of 53 clips ended up that way, and the dev server restarts on every deploy.
    Approach
    next_attempt_at now means one thing, "invisible until this time": the retry time for waiting jobs and a ten-minute lease expiry for claimed ones. One condition claims both states, a job revived by its lease still spends an attempt, and a migration freed the rows already stuck.
    Result
    Seven lease tests were added and shown to fail when the claim condition is reverted. All 53 local clips were indexed.
  2. 02

    Recommendations returned 500 from limit 12 up

    Problem
    The two-stage vector search derived HNSW’s search width (ef_search) from the candidate count without checking pgvector’s cap of 1000. The default request (limit=20) became ef_search = 1800 and failed, as did every limit of 12 or more.
    Approach
    The cap lives in one place, and the fix trims the candidate count itself rather than ef_search, because lowering ef_search alone means more iterative scans and slower queries (p50 3.92 ms at 40 against 1.52 ms at 200). Every combination of request size 1–600 and over-fetch factor was checked to stay under the cap.
    Result
    The bug only reproduces on a real PostgreSQL, so integration tests pin it, and they were confirmed to fail on the old code.
  3. 03

    A third of the vector index, and what it cost

    Problem
    At 4,096 bytes per row, the HNSW index on 768-dimension vectors would outgrow a 128 MB buffer at about 33,000 rows, a size subtitle-level indexing would reach.
    Approach
    Embeddings moved to halfvec (maximum cosine-distance error 7e-5), and a two-stage search now indexes only the first 512 dimensions and re-ranks the candidates on all 768. On 20,000 synthetic rows, 256 dimensions reached recall@10 0.862, short of the 0.95 bar; 512 reached 0.984.
    Result
    The index shrank to 1,366 bytes per row, pushing the memory crossover to about 98,000 rows. At 20,000 rows the two-stage search is slower (p50 7.17 ms against 1.52 ms), so recipe search, with far less data, keeps a single 768-dimension index.
  4. 04

    Five ways in, and clips went missing

    Problem
    Five endpoints looked like search, and the frontend’s search screen used the lecture list, so clips never appeared in results. Adding one more unified endpoint would not stop the wrong one from being picked.
    Approach
    Unified search, recipe hybrid search with unified suggestions, and clip vector search landed in turn; then search was reduced to /search and /search/suggestions, with responses discriminated by type. The four breaking changes went in together.
    Result
    Provider mapping is an exhaustive when, so a new kind that is not handled fails to compile, and a missing registration fails the smoke test.
  5. 05

    Spring Boot to Ktor with the outside unchanged

    Problem
    Rewriting the Spring Boot and Modulith backend on Ktor and Exposed, the goal was that nothing visible from outside changes: API responses, the database schema, sessions.
    Approach
    Characterisation tests came from 56 recorded responses and 24 golden JSON files. Migrations were kept byte-for-byte with schema dumps watching for drift, and Redis keys and TTLs, the event outbox format and environment variable names all matched, so sessions survived both cut-over and rollback.
    Result
    It switched over with 167 tests green. Right after, a fat-jar build that overwrote Flyway’s service file had silently skipped two migrations; that was found and fixed in the build configuration.

Accepting requestsAnsan --:-- KST

mooner:~/resume $ ./contact.sh

Contact→

Name
문민기 / Minki Moon
Handle
@mooner
Base
Ansan, KR

Happy to trade coffee for conversation.

If anything here catches your eye, feel free to reach out.

// ping me anytime — i read all.

© 2026 Minki Mooncrafted with monospace affection · rev 2026.10.18