factcircuit
FactCircuit는 불변 출처와 단계별 검증, 제한된 검색, 재현 가능한 평가를 제공하는 감사 가능한 주장·증거 검증 루프입니다
파일을 업로드하고 RAG·벡터 임베딩으로 인사이트를 발견합니다. Cognify는 고급 AI로 정보를 지능적으로 검색·분석합니다
Upload files and discover insights with RAG and vector embeddings. Cognify uses advanced AI to search and analyze your information intelligently.
데이터TypeScript · JavaScriptGitHub @rodrigorom2105온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기AI-Powered Knowledge & Workflow Platform - Production SaaS MVP
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Upload a PDF, ask questions about it, get answers grounded in the document with citations back to the source passages.
Documents are processed in the background: the text is extracted, chunked, embedded, and stored in pgvector. Questions are answered by embedding the query, retrieving the most similar chunks, and streaming a completion constrained to that retrieved context.README 전문 보기 ↗Upload a PDF, ask questions about it, get answers grounded in the document with citations back to the source passages.
Documents are processed in the background: the text is extracted, chunked, embedded, and stored in pgvector. Questions are answered by embedding the query, retrieving the most similar chunks, and streaming a completion constrained to that retrieved context.
| Layer | Choice |
|---|---|
| Framework | Next.js 16 (App Router), React 19, TypeScript |
| Styling | Tailwind CSS v4, shadcn/ui, Radix UI |
| Auth, database, storage | Supabase (Postgres + pgvector) |
| Background jobs | Inngest |
| Embeddings | OpenAI text-embedding-3-small (1536 dims) |
| Answers | OpenAI gpt-4o-mini, streamed |
| Hosting | Vercel |
Ingestion — uploads skip the app server: prepareUpload (src/lib/actions/documents.ts) validates the file and returns a signed Storage upload URL, the browser sends the PDF straight to Supabase Storage, and completeUpload re-checks the stored object's size and type, inserts a documents row with status processing, and emits a document.uploaded event. Routing the file through a server action capped uploads at Vercel's 4.5 MB request-body limit; the documents bucket enforces the 10 MB / PDF-only limits itself. The Inngest function process-document then runs three steps:
unpdf, normalize it, split into chunks of 1500 characters with 300 overlap (350 minimum).document_chunks, then set the document to ready.Intermediate data passes between steps through the processing_temp table rather than the event payload, which keeps step output under Inngest's size limits.
Retrieval — POST /api/query authenticates the user, confirms the document belongs to them and is ready, embeds the question, calls the match_document_chunks RPC for the 8 nearest chunks, and streams an answer. The system prompt restricts the model to the retrieved passages and instructs it to say it could not find the information rather than fill gaps.
user_usageThis project uses pnpm. Do not install with npm, yarn, or bun.
pnpm-lock.yaml is the only lockfile, and the pnpm version is pinned via the packageManager field in package.json. Vercel installs with pnpm --frozen-lockfile, so a lockfile that disagrees with package.json fails the build with ERR_PNPM_OUTDATED_LOCKFILE before anything compiles.
package-lock.json, yarn.lock, and bun.lockb are gitignored to keep a stray npm install from reintroducing a competing lockfile.
# install
pnpm install
# add or upgrade a dependency — always through pnpm
pnpm add <pkg>
pnpm update <pkg>
If you don't have pnpm, corepack ships with Node and will use the pinned version:
corepack enable
Copy the variables below into .env.local:
# Supabase
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
# OpenAI
OPENAI_API_KEY=
# Inngest
INNGEST_EVENT_KEY=
INNGEST_SIGNING_KEY=
# App
NEXT_PUBLIC_APP_URL=http://localhost:3000
SUPABASE_SERVICE_ROLE_KEY bypasses row-level security and is used only by the background worker (src/lib/supabase/service.ts). It must never be exposed to the client.
DOCUMENTS_BUCKET is optional and defaults to documents.
Background processing needs the Inngest dev server running alongside Next, in a second terminal:
pnpm dev # terminal 1 — Next.js on :3000
pnpm inngest # terminal 2 — Inngest dev server on :8288
Without the dev server, uploads will store the file and create the row, but nothing will process it and the document stays in processing.
| Command | What it does |
|---|---|
pnpm dev | Next.js dev server |
pnpm build | Production build |
pnpm inngest | Inngest dev server (required for background jobs) |
pnpm type-check | tsc --noEmit |
pnpm lint | ESLint, --max-warnings 0 |
pnpm format | Prettier over src/ |
pnpm db:types | Regenerate database.types.ts from the remote schema |
pnpm eval:recall | Recall@5 chunking evaluation |
pnpm loadtest <fetch|run|report|cleanup> | End-to-end load test against a deployment — see below |
pnpm supabase:start / :stop / :status / :reset | Local Supabase stack |
scripts/eval-recall.mjs compares chunking configurations by Recall@K and Hit@K against a labelled eval set, rather than by raw similarity score — a higher similarity number does not mean the right passage was retrieved.
pnpm eval:recall
Results are written to eval/recall/results.latest.json. See eval/recall/README.md for how to build the eval set.
The current committed run covers only 2 cases. That is enough to show the 1000/200 baseline dropping a passage the tuned 1500/300 config retrieves, but not enough to separate configurations that both score 1.0. Expand the set before treating those numbers as settled.
| Table | Purpose |
|---|---|
documents | One row per upload: filename, storage path, status, page count |
document_chunks | Chunk text, chunk_index, and the pgvector embedding |
processing_temp | Step-to-step handoff for the Inngest pipeline |
queries | Question and answer history per user |
user_usage | Upload and query counters for tier limits |
subscriptions | Billing schema — see "Not implemented" below |
RLS is enabled with owner-scoped policies (auth.uid() = user_id) on the user-facing tables. Auth session refresh happens in src/proxy.ts (Next.js 16 replaced middleware.ts with proxy.ts).
supabase db reset does not reproduce this schema. The database was built by hand in the Supabase dashboard, so supabase/migrations/ starts partway through its history: it contains only incremental changes applied after that point, with no migration that creates the original tables, policies, functions, or the pgvector index.
Treat the remote database as the source of truth until a baseline is captured (supabase db dump against the live project, committed as the first migration). Until then, new changes should still be added as migration files so the gap stops growing.
Every Supabase client is parameterised with the Database type from src/lib/supabase/database.types.ts, so .from(), .rpc(), inserts, and updates are checked against the real columns. That file is generated — never edit it by hand:
pnpm db:types
Regenerate it after every schema change, in the same commit as the migration. Because there is no baseline migration, the generator reads the remote project, not supabase/migrations/.
Row shapes are re-exported from src/types/index.ts (Document, Query, DocumentChunk, UserUsage) — derive from those rather than hand-writing an interface, which silently drifts from the database.
Two consequences worth knowing:
documents.status is plain text with no constraint, so its generated type is string. The DocumentStatus union in src/types/index.ts constrains what the app writes; it is not a guarantee about what the database returns. Making it a real Postgres enum would push the union into the generated type.string by PostgREST, so embeddings are sent as JSON.stringify(embedding). This is the same bytes on the wire as passing the raw array — Postgres parses [0.1,0.2] as a vector literal either way.Each row in queries records four token counts, kept separate because they are billed at different rates — summing them into one number would make any cost calculation wrong.
| Column | Model | What it covers |
|---|---|---|
prompt_tokens | gpt-4o-mini | System prompt + the 8 retrieved chunks + the question |
completion_tokens | gpt-4o-mini | The generated answer |
tokens_used | gpt-4o-mini | prompt_tokens + completion_tokens |
embedding_tokens | text-embedding-3-small | Embedding the question for retrieval |
prompt_tokens is dominated by the retrieved context, not the question: 8 chunks run roughly 1,500–3,700 tokens depending on the document, while a typical question is ~10. Chunk size and the match_count: 8 in src/app/api/query/route.ts are what move cost per query.
NULL means not measured, which distinguishes rows written before token metering worked from a genuine zero. Filter them out when computing averages:
select
count(*) as queries,
round(avg(prompt_tokens)) as avg_input,
round(avg(completion_tokens)) as avg_output,
round(avg(embedding_tokens)) as avg_embed,
round((sum(prompt_tokens) / 1e6 * 0.15
+ sum(completion_tokens) / 1e6 * 0.60)::numeric, 6) as chat_usd,
round((sum(embedding_tokens) / 1e6 * 0.02)::numeric, 8) as embed_usd
from queries
where prompt_tokens is not null;
Ingestion is metered separately on documents.embedding_tokens: embedding every chunk of an upload is a one-off cost that belongs to the document, not to any question asked about it. Cost across both lifecycles:
select
(select round((sum(embedding_tokens) / 1e6 * 0.02)::numeric, 6)
from documents where embedding_tokens is not null) as ingest_usd,
(select round((sum(prompt_tokens) / 1e6 * 0.15
+ sum(completion_tokens) / 1e6 * 0.60
+ sum(embedding_tokens) / 1e6 * 0.02)::numeric, 6)
from queries where prompt_tokens is not null) as query_usd;
Rates are gpt-4o-mini at $0.15/$0.60 per 1M input/output tokens and text-embedding-3-small at $0.02 per 1M. They are hardcoded in the queries above, not in the schema, so check them against current pricing before quoting a figure.
Ingestion timing lives on documents, measured inside each Inngest step and returned from it, so step replays do not re-measure:
| Column | What it covers |
|---|---|
processing_started_at / processing_completed_at | created_at → started is queue wait; started → completed is pipeline time |
chunk_count | Chunks stored for the document |
processing_metrics | Per-step ms and retries, text size before/after normalization, chunk size stats |
error_message | Why processing failed, when status = 'failed' |
Each queries row also records embed_ms, retrieval_ms, ttft_ms (request received → first token), generation_ms, total_ms, chunks_returned, and the top and mean similarity of the retrieved chunks. All of them are NULL for rows written before instrumentation.
scripts/loadtest.mjs drives a deployed instance through the same HTTP surface the dashboard uses — POST /api/documents/upload-url and POST /api/documents (thin wrappers over the prepareUpload and completeUpload server actions, with the PDF going straight to Storage in between) and POST /api/query — so ingestion runs on the real Inngest environment.
pnpm loadtest fetch # download the corpus in loadtest/corpus.json
pnpm loadtest run --base-url https://… --users 5 # N concurrent users, 3 docs each, 6 questions per doc
pnpm loadtest report # snapshot DB rows, write loadtest/results/summary.md
pnpm loadtest cleanup --run <runId> # delete the run's users, rows and files
email_confirm: true — sign-up sends a confirmation email, and Supabase's built-in mailer allows only a few per hour. Every account is loadtest+<runId>-<n>@example.com; cleanup will not touch anything else.cleanup refuses a run that report has not snapshotted yet.The app deploys to Vercel. Two things are easy to get wrong:
Inngest syncing. Install the Inngest Vercel integration so every deployment syncs automatically and each git branch gets its own Inngest branch environment. Production and branch environments use different event and signing keys — branch environments share one set among themselves, separate from production's. Set both scopes accordingly in Vercel.
Do not set INNGEST_ENV in Vercel with an "All Environments" scope; it overrides the automatic branch detection and collapses every preview into a single environment.
Deployment Protection. Vercel's Deployment Protection blocks Inngest from reaching /api/inngest. Under Standard Protection this affects preview deployments; under All Deployments it affects production too. Either disable it (Project → Settings → Deployment Protection) or, on a Pro plan, configure Protection Bypass for Automation and add the bypass secret to the Inngest integration settings.
OpenAI rate limits. Every question shares the account's gpt-4o-mini tokens-per-minute limit; each one costs about 3,300 prompt tokens plus the 700-token max_tokens reservation. When the limit is hit, the SDK retries with backoff, and if that fails /api/query answers 429 with Retry-After rather than an error page.
Note that src/app/api/inngest/route.ts declares maxDuration = 300. Vercel's Hobby plan caps function duration well below that, so large documents can be cut off mid-processing.
Honest status, so nobody goes looking for these:
subscriptions table and NEXT_PUBLIC_ENABLE_STRIPE flag exist, but there is no Stripe code. Tier limits are enforced against user_usage counters only.pnpm test). End-to-end verification is the load test and the retrieval eval harness.