Wenlan
Wenlan is a source-backed AI knowledge base and LLM wiki for AI work: organize documents and decisions into pages you can find, inspect, and review.
翻訳待ち
No updates detected yet. GitHub releases will appear here.
Multi-tenant document Q&A with Next.js, Postgres and pgvector
翻訳待ち
Loading the full README. The saved excerpt is shown below.
Multi-tenant document Q&A. Upload txt, md or pdf files to an organization, then ask questions and get answers with a citation to the exact passage each claim came from. If the documents do not answer the question, it says so instead of guessing.Read the full README ↗Multi-tenant document Q&A. Upload txt, md or pdf files to an organization, then ask questions and get answers with a citation to the exact passage each claim came from. If the documents do not answer the question, it says so instead of guessing.
Live demo: https://askdocs-rho.vercel.app. Create an account, upload a few files from eval/corpus (made-up company documents, English and Bengali), and ask something like "How long do I have to pay an invoice before a penalty is added?" or "What does error code E-4021 mean?". The first upload after the app has been idle can wait two to three minutes in queued, because the worker runs on a free plan that sleeps (see Hosting).
Design: Figma file with phone and desktop screens.
flowchart LR
B[Browser] -->|upload, ask| W["Next.js app<br/>Vercel"]
W -->|"SQL (pooled)"| DB[("Neon Postgres<br/>pgvector, job queue")]
W -.->|"wake: GET /health"| K["Worker<br/>Render, sleeps when idle"]
K -->|"take job, write chunks"| DB
K -->|embed chunks| O["OpenRouter<br/>free models"]
W -->|"embed question, stream answer"| O
C["Vercel Cron<br/>once a day"] -->|cleanup job| W
Upload. The route checks the file, reserves storage on the organization row (SELECT ... FOR UPDATE, so two uploads cannot both squeeze under the limit), stores the file in Postgres and queues a job. It does not wait for indexing.
Index. The worker is a separate process. It takes the job (pg-boss, stored in the same Postgres), extracts the text (unpdf for PDFs), splits it into overlapping chunks that never break inside a character, embeds them with a free OpenRouter model and saves 2048-dimension halfvec vectors under an HNSW index. A job can run twice without duplicating chunks, and failures retry with backoff; a failed document shows a retry button.
Ask. The question is embedded (a repeated question reuses the saved vector), the six closest passages of this organization above a similarity of 0.25 are found, and a free chat model answers from those passages only, with [n] citations, streamed as it is written. An answer that cites no real passage is replaced by "I could not find that in your documents." When no passage is close enough the model is not called at all. Details: docs/decisions.md #23.
Tenant isolation. Every read and write of tenant data goes through getOrgScope (src/server/org-scope.ts), which first checks membership in the database and then puts org_id = ... inside every query, including the vector search. A route cannot forget the filter because it never writes one. A browser test with two organizations checks that one sees and gets nothing of the other's documents, and it was verified by removing the filter on purpose (the test failed).
pnpm eval measures retrieval on 8 documents and 14 questions (paraphrases, exact ids, numbers, Bengali, cross-language). First-place hits:
| Method | hit@1 | MRR |
|---|---|---|
| Meaning only (embeddings) | 13 / 14 | 0.964 |
| Meaning plus keywords on every word | 12 / 14 | 0.901 |
| Meaning plus keywords on ids and numbers only (used) | 14 / 14 | 1.000 |
Keyword search on every word made results worse, so keywords are limited to identifiers and numbers. The set is small and the last change was designed around the failing questions; docs/evaluation.md says what the numbers do not show, and how the "found nothing" threshold was chosen.
The full list, with reasons, is in docs/decisions.md. The ones a reader will ask about:
| Decision | Trade-off |
|---|---|
| Free hosting and free AI models only (#18, #19, #20, #26) | The worker sleeps after 15 minutes idle and the web app wakes it, so the first document after a pause waits two to three minutes. Free embedding models allow 50 requests a day, so the app saves question vectors and counts questions per user (20 an hour). |
| Separate worker process with a Postgres queue (#6, #12, #14) | One more thing to deploy, but uploads return at once, work retries, and a web outage never loses a job. |
| Files and vectors in Postgres (#11, #21) | One database to run, back up and isolate by org_id, at the cost of not scaling to very large files. Uploads are limited to 4 MB because of Vercel's request body limit (#15). |
| Hybrid search for ids and numbers only (#24) | Measured, not assumed: all-word keyword search lost to meaning alone. |
| Polling, not streaming, for document status (#22) | A 3 second delay in the list, but it works on serverless without a long-lived connection. |
| Tests run against a real Postgres, browser tests use a stand-in AI service (#25) | Tests cost no free requests and give the same result every time; they do not judge the quality of real answers (the evaluation and manual checks do). |
getOrgScope, and an invite code or daily caps on sign-up and AI use (docs/decisions.md #26 lists the known risk).I built this with Claude Code (Anthropic's coding agent), and I want to be exact about who did what.
This is the beginning of the README. Read the rest at the original. Read the full README ↗