Skip to content
← Selected projects

Compoze

2025 · sole founder · sold to a client

Compoze was a company I ran on evenings and weekends alongside my job at TikTok. It built assistants that answer questions from a company's own documents, and a client bought it at the end of 2025.

Staff could ask a question and get an answer with its sources attached, so they could open the document and check it instead of searching through files by hand.

An answered question in Compoze: a knowledge base search over three documents, a structured answer containing a table, and cited sources listed with match scores.
fig. 1 — an answer with the search it ran and the sources it used, each with a match score.

Figure 02 · User flow

An admin fills the knowledge base, then a user asks questions

  • Admin
  • User
  • Answer

ADMINper tenantConnect Lark / DriveOAuthKnowledge baseselect filessource per fileIngest statusqueued → readyfailures countedFailed?YESRetry from stepNOReady to searchindexedUSERstaffPick an agent4 domain agentsown tools eachEmpty stateexample promptstool list shownAsk a question≥ 0.35?NORefusesnames what's missingYESCited answertool call shownsources + scoresOpens citationsthen trusts itSEARCHABLE
fig. 2 — an admin connects the document sources and watches them process; a user picks an assistant, asks a question and checks the sources.

Working alone

Each engagement ran from the first customer conversation through deployment and training, and I handled all of it. Organising the knowledge base took the longest, because only the customer could decide which documents belonged in it and which should stay out. Running Compoze beside a full-time job also meant it had to keep working without constant attention.

Figure 03 · The whole system

How a question and a document move through the system

  • Request
  • Response
  • Ingest

SOURCESLark / FeishuOAuth · MCPGoogle DriveOAuthDirect uploadbrowserAPP · ONE CODEBASE, MULTI-TENANTDomain agents4 · tenant-gated1Admin portalRBAC · 3 rolesJob statusqueued/failed/DLQRAG search9 tools · spotlight2Answer + citationsscore per chunk5Model gateway9 models4Eval + tracesgolden set · CI gate · cost/tenantPROVIDERSOpenAIembed · llmAnthropicllmGooglellmLlamaParseextractionASYNC · QSTASH WORKERS · OFF THE REQUEST PATHDownloadAsource apiExtractBLlamaParseChunkC~1000 charsEmbedD1536-dimStoreEto pgvectorPOSTGRES · PGVECTOR — THE ONLY STORETENANT ID ON EVERY ROW · RLStenantsusers · rolesdocumentsstatuschunks+ embeddings3messages+ citationsOAUTHLLMCALLCOSINE · FLOOR 0.35CHUNKS + SCORESASYNC · QSTASHCHUNKSSOURCESAPPPROVIDERS
fig. 3 — a question goes through the assistant, search and the model gateway in one request. Documents are processed separately in the background. Everything lives in one Postgres database, with every row tagged by customer.
Compoze chat in its empty state, showing the selected agent, four example prompts and the list of tools it can use.
fig. 4 — a new chat shows example prompts and the tools the assistant can use, so someone new can see what to ask.

One codebase for every customer

Compoze ran on two stacks: a Python assistant on LlamaIndex and FastAPI with LlamaCloud doing the indexing, and the platform on this page, built on Next.js and Postgres with pgvector. Every customer shared one codebase and one database. Each customer's rows carry a tenant id and are kept apart twice, once in the app and once with Postgres row-level security. One database kept migrations and billing simple, but it meant the tenant checks had to be right everywhere. Nine models from OpenAI, Anthropic and Google sit behind one gateway, chosen per environment in config.

Finding the right passages

Documents are split into chunks of about a thousand characters, and each chunk gets a one-line header saying where in the document it came from. A question searches up to five knowledge bases at once, using both embeddings and plain keyword search, since embeddings alone miss exact words like codes and names. Anything under a 0.35 similarity score is dropped, and a reranker puts the rest in order.

Figure 05 · Query

How a question searches five knowledge bases and checks its citations

  • Question
  • Retrieval
  • Answer

User questionASKSDomain agent1 of 4 · tenant-gatedTOOL CALLContextual RAG searchOpenAI embed · 1536-dimBM25 + VECTOR · RRFUP TO 5 KNOWLEDGE BASES · PARALLELkb 1kb 2kb 3kb 4kb 5FLOOR 0.35Cross-encoder rerankfinal orderTOP CHUNKSSummarise referenceskept chunks onlyPROMPT CONTEXTModel gateway9 models · prompt cacheWRITESEVERY CITATIONCitation entailment checkclaim ⊨ chunkAnswer with citationsmatch score per chunk
fig. 5 — one question searching five knowledge bases at once. Passages under the 0.35 cutoff are dropped, and each citation is checked before the answer is shown.

Each citation is checked against the sentence it supports, and the source links and scores let readers see how well a passage backs up the answer. When even the best passage is under the cutoff, the assistant says it can't answer instead of guessing.

How I knew it worked

I kept a fixed set of questions and expected answers from logs and testing, including questions the documents couldn't answer, where the right reply was a refusal. Search and the written answer are scored separately. The set runs in CI, so a change to the prompt, the chunking or the model fails if it does worse than the version on main.

Getting the documents in

Files come in from Lark and Google Drive, and processing runs in the background on QStash. The failure cases took more of my time than the normal path, because a job that dies halfway leaves a document that looks uploaded but never shows up in answers. So each step can be retried on its own, uploading the same file twice does nothing, and anything that keeps failing lands in a dead-letter queue with the error attached. That gave me a record of what failed and what needed retrying.

Figure 06 · Ingest

How an uploaded document is processed, and where failures go

  • Document
  • Failure

Lark / FeishuOAuth · MCP: docs, wiki, sheetsGoogle DriveOAuthFILE SELECTEDIngest jobstatus: queuedASYNC · UPSTASH QSTASHASYNC · QSTASH JOBContent hashskip if seenNEW FILEDownloadRAW FILEExtractLlamaParsePLAIN TEXTRedact PIIbefore chunkingCLEAN TEXTChunk~1000 CHARSBatch embedcache by hashOPENAI · 1536-DIMStoreON FAILURECHUNKS + EMBEDDINGSJOB COMPLETEstatus: failed · DLQretry from stepPostgres · pgvectorchunks + vectorsstatus: readyshown in the UI
fig. 6 — duplicate files are dropped, personal data is removed before chunking, and a failed run can be retried from the step that broke.
The Compoze admin knowledge base page: a document table with type, size, source connector and ingest status, status filter counts above it, and a banner saying processing continues in the background.
fig. 7 — the admin view. Each document shows where it came from and how far it got; the counts at the top are how an admin finds the failures.
The Compoze agent picker showing four domain agents, each with a short description.
fig. 8 — four assistants, each with its own prompt, tools and examples. Which ones a customer sees is set in config.

Safety

A synced document could contain instructions aimed at the model. I mark retrieved text as data in the prompt and limit each agent to its own tools. That limits what a planted instruction can do, but it doesn't stop the model from reading it.

Figure 09 · Chat

The answer keeps generating if the reader closes the tab

  • Question
  • Tokens
  • Hand-off

BrowserChat routeQStash workflowASK1STREAM TOKENS2DISCONNECTS MID-ANSWER3ABORT LISTENER HANDS OFF THE JOB4DURABLE STEPS1 init2 load context3 RAG query4 generation5 finalise5retry resumes afterthe last completed stepFALLBACKQStash unreachable →plain streaming
fig. 9 — if the reader closes the tab mid-answer, the job moves to QStash and finishes in the background, picking up from the last step that completed.
Implementation details
domain agents
4
agent tools
9
models / providers
9 / 3
knowledge bases per search
5, in parallel
similarity floor
0.35 cosine
test specs
27
api routes
53
database tables
23
typescript
~76k lines

Edge middleware replaced any client-supplied identity header with the user and tenant id from the verified JWT, so routes trusted it and skipped a database round trip. Indexes led with the tenant id, and chat creation was an atomic insert-on-conflict.

cursor pagination
fetch limit + 1 to learn whether a next page exists, no COUNT
adaptive polling
3s while processing, 5s while pending, 30s idle, previous data kept so the table never flashes
connector caches
per-connector LRU with per-action TTLs for Drive and Lark, plus a sliding-window rate limit per tenant
sse anti-buffering
no-transform cache headers, X-Accel-Buffering off, framework compression disabled, per-route duration budgets on fluid compute
tool surface
only the tools the agent and the tenant connectors allow reach the model, with a tighter step budget on background runs

With no one else reviewing the code, I leaned on automated checks: typed route contracts, Zod validation on every payload and contract tests on each push. Each request is traced with tokens, model and tenant, which is what pricing was based on.

Ong Jun Xiong

SOFTWARE ENGINEER · SINGAPORE

ContactHobbiesArchiveNotesUI PackGitHubLinkedInSource

© 2026 Ong Jun Xiong