Week One Capstone: Assembling Six Days of Parts Into a One-Command Question-Answering Service, and a Retrospective
Assemble the previous six days — parsing, chunking, indexing, retrieval, assembly, generation — into a real service: one ingestion command, one question-answering endpoint, one configuration guide, all started with a single Docker command, then look back at the reasoning behind each decision this week and the technical debt left behind.
Today's Goals
- Split the ingestion and query pipelines into clear module boundaries, and explain why each module can be swapped independently
- Bring the service up along with its database with one command, and run the full path from feeding in documents to getting a cited answer
- List three pieces of technical debt left from this week, and say which day of week two each one gets paid off
No new theory today. Six days built six parts one at a time, and today assembles them into a machine that can open for business, then looks back at whether each of this week's decisions still holds. Come back and tick off the three goals.
Plain-Language Walkthrough
Six trades have signed off and the library is not open yet
All week we borrowed the library as a metaphor: the corpus is the collection, chunking is cutting books into entries, the index is the card catalog, retrieval is finding books by the catalog, and a citation is a reference at the end of a paper. As of today all six trades have finished their work — the books are in, the entries are cut, the catalog exists in both keyword and vector form, and the rules for finding books and attributing sources are settled.
But the library is not open, because nobody can walk in. Every lab of the first six days was a script that ran and exited: you typed one command, looked at the output, and closed it. Nobody uses a knowledge base that way in the real world; they want an address, somewhere to drop documents, and the ability to ask at any time.
Between six scripts and one service lies not a volume of code but boundaries. Inside a script everything may call everything, since it all disappears after one run; a service cannot. A service runs long, gets modified by others, and has half its parts swapped in week two without being rebuilt. So today's only real decision is how many pieces this machine cuts into and where the cuts go.
The criterion for a cut is not similar code volume and not grouping by function but this sentence:
Whichever layer is most likely to be replaced wholesale is where the cut goes.
Walk this week by that standard and the three cuts are clear: an embedding model changes several times a year, and one change voids every vector in the store and forces a full recomputation; storage may move from PostgreSQL to a dedicated vector store, and either way both pipelines must speak through it; and the chunking strategy still gets tuned repeatedly in week two.
The first cut lands on embedding. The interface is D2's signature with not one word changed:
// The course-wide vectorization interface: this signature for all 14 days, never renamed
export type Embedder = (
texts: string[],
opts?: { dimensions?: number }
) => Promise<number[][]>
export interface EmbeddingBackend {
name: string // written into chunk_embeddings.model, to tell whether stored vectors came from the current model
dimensions: number
embed: Embedder
}
export function createEmbedder(cfg) {
// Three backends, one signature: offline hash, a local open model, an API.
// Code above never knows which one it got
if (useMock) return { name: `mock-hash-${dims}`, dimensions: dims, embed: hashBackend }
return { name: `${cfg.model}-${dims}`, dimensions: dims, embed: openaiBackend }
}from typing import Protocol
class EmbeddingBackend(Protocol):
"""The course-wide vectorization interface: this signature for all 14 days, never renamed"""
name: str # written into chunk_embeddings.model, to tell whether stored vectors came from this model
dimensions: int
async def embed(self, texts: list[str], dimensions: int | None = None) -> list[list[float]]: ...
def create_embedder(cfg) -> EmbeddingBackend:
# Three backends, one signature: offline hash, a local open model, an API.
# Code above never knows which one it got
if use_mock:
return HashBackend(dimensions=cfg.dimensions)
return OpenAIBackend(model=cfg.model, dimensions=cfg.dimensions)Note that this abstraction's reason is not the platitude that programming to an interface is good practice but something very concrete: a model change genuinely happens once a year, and each time you want to edit one configuration entry and run one rebuild rather than hunting call sites across the repository. The cost is equally concrete — one more layer of indirection and one more hop while debugging. Whether it is worth it depends on whether that event will actually happen. Do not abstract what will not happen; that is over-design.
Two pipelines, one junction
With the cuts drawn, the next question is what the ingestion and query pipelines share.
Ingestion is batch: tens of seconds to parse, chunk, vectorize, and write thirty documents, and a failure means rerunning. A query is an online request: a result within a few hundred milliseconds, and a failure is seen by the user on the spot. Their error handling and timeout strategies are simply not the same thing.
The easiest mistake is seeing that both need embedding, extracting a shared module, and then finding that ingestion wants batched retries while queries want fast failure, with the module filling up with if (isIngest). What is shared should be an interface, not a flow. In this version the two pipelines share exactly one thing: the storage layer's interface. Ingestion writes, queries read, and no business code is shared beyond that.
Ingestion pipeline Query pipeline
loadDocs ← parsing (D3) retrieve ← two paths (D1 + D2)
↓ ↑
chunker ← chunking (D4) Store ←──┘
↓ ↓
embed ← vectorizing (D2) answer ← assembly + cited generation (D6)
↓
Store ─────────────────────────────┘embed appears on both pipelines, and it is two calls to one interface, not a shared flow. There is a hard constraint here: vectors for chunks and vectors for questions must come from the same model. Querying an index built with model A using model B's question vectors makes the distances meaningless, raises no error, and quietly hands you a pile of garbage that looks like results. So every vector records which model computed it (the chunk_embeddings.model column), the only way to discover this class of accident afterwards.
The storage interface is only a few lines and is the thing in the whole service most worth settling first:
export interface Store {
init(): Promise<void> // a repeatable migration
// Replace one document and its chunks in one go. It must be a transaction:
// deleting old chunks without writing new ones erases the document from the store
replaceDoc(doc, chunks, vectors, model): Promise<void>
listChunks(): Promise<StoredChunk[]>
getChunk(chunkId: string): Promise<StoredChunk | null>
vectorSearch(query: number[], topK: number): Promise<VectorHit[]>
}
// The only selection point: a connection string means the real database, otherwise the in-memory one.
// Code outside this interface has no idea who it is talking to
export function createStore(): Store {
return process.env.DATABASE_URL ? new PgStore(process.env.DATABASE_URL) : new MemoryStore()
}class Store(Protocol):
async def init(self) -> None: ... # a repeatable migration
# Replace one document and its chunks in one go. It must be a transaction:
# deleting old chunks without writing new ones erases the document from the store
async def replace_doc(self, doc, chunks, vectors, model: str) -> None: ...
async def list_chunks(self) -> list[StoredChunk]: ...
async def get_chunk(self, chunk_id: str) -> StoredChunk | None: ...
async def vector_search(self, query: list[float], top_k: int) -> list[VectorHit]: ...
def create_store() -> Store:
"""The only selection point: a connection string means the real database, otherwise in-memory.
Code outside this protocol has no idea who it is talking to"""
url = os.environ.get("DATABASE_URL")
return PgStore(url) if url else MemoryStore()The in-memory implementation is not a stub pretending writes succeeded; it genuinely reimplements PostgreSQL's semantics — replacing a document's old chunks, ordering by cosine, breaking ties by id — at the cost of a full scan. Its benefit shows in week two: after D8 you run dozens of retrievals a day for evaluation, and if each needs a container up first, you will start skipping them.
Parameters out of the code: how far configuration should go
Week two repeatedly raises the same class of question: 500 characters or 300? Four results or eight? What happens with the vector path off?
Those questions have exactly one correct way of being answered: change one configuration entry, rerun, and read the numbers. If changing the chunk size means editing pipeline.ts and changing the number of paths means editing retrieve.ts, you will try three configurations a day at most and forget what you tried. So this version collects the parameters into one rag.config.json:
{
"chunking": { "strategy": "heading", "maxChars": 500, "minChars": 60, "overlapChars": 60 },
"embedding": { "backend": "auto", "model": "text-embedding-3-small", "dimensions": 384 },
"retrieval": {
"routes": ["bm25", "vector"],
"perRouteTopK": 8,
"topK": 4,
"weights": { "bm25": 1, "vector": 0.6 },
"gates": { "bm25MinScore": 6, "vectorMinCosine": 0.2 },
"bm25": { "k1": 1.2, "b": 0.75 }
},
"generation": { "model": "claude-sonnet-5", "maxContextChars": 6000 }
}Four sections for four swappable parts, showing at a glance which knobs this machine has. Once running, one environment variable overrides temporarily without editing the file: CHUNK_STRATEGY=fixed takes the same corpus from 134 chunks to 52, and ROUTES=bm25 turns the vector path off.
One boundary must be drawn: environment variables override and never define. The configuration file is always the manual, so one file tells you which parameters exist and what their defaults are. Scatter the defaults across a dozen process.env.XXX ?? someNumber expressions instead and nobody can say what configuration the system is running under, and a problem cannot be reproduced.
The part of the configuration most worth stopping at is those two gates: a candidate is reachable if any path's raw score clears its own line, and clearing none means an outright refusal. There is a counter-intuitive pothole here — the gate must sit on the raw score and never on the fused score. The fused score is normalized against this query's top score, so even when the most relevant item in the whole run scores 3, its fused score is still 1.0, and gating on it means the system never refuses again.
As for that 0.2 in vectorMinCosine, honestly: it was measured, not derived. I printed the offline hash vectors' similarity distribution, found genuinely relevant chunks landing just past 0.2 (0.20 to 0.27 measured), and found a question with no answer in the corpus topping out at 0.13, so the line went at 0.2. A real embedding's distribution is entirely different and needs recalibrating; and how to calibrate it correctly has no answer today — which is precisely the first debt.
Three endpoints, none omissible
The service exposes exactly three endpoints, and dropping any one makes it not work:
POST /ingest: given a directory, parse, chunk, vectorize, and store, returning how many documents were processed and how many chunks were cut.POST /ask: given a question, return the cited answer, the chunks used, and the candidates that failed the gates.GET /chunks/:chunkId: given a chunk id, return its original text.
The third is the easiest to cut and is precisely the redemption of this whole apparatus. D6 has the model emit only the chunk numbers [1] [2] issued to it this time, code checks that the sentence and that chunk genuinely correspond, and then the number is exchanged for a clickable id such as doc-006#c04 and returned to the user. Without something to click, the whole thing is no different from "as far as I know." A citation's value is not in being labeled but in being checkable.
The response body has another easily cut field: the candidates that failed the gates. It is useless to the user and useful to you — when production produces a "why did it not answer," the first thing to look at is what retrieval actually fetched: nothing fetched at all (an index or tokenization problem), fetched with insufficient score (a gate problem), or a sufficient score the model did not use (a prompt problem). The three failures have entirely different fixes, and without that field you cannot even classify them.
The ingestion endpoint has a detail that must be right. The keyword path's BM25 index lives in memory, so ingestion must rebuild the index afterwards:
app.post('/ingest', async (req) => {
const report = await ingest(dir, store, cfg, embedder)
// The keyword index must be refreshed after ingestion, or newly stored chunks are findable by vector
// and not by keyword. That one-path-works failure is the hardest to diagnose, since it just looks odd
const indexed = await retriever.refresh()
return { ...report, indexed } // indexed is returned precisely so this can be verified to have happened
})@app.post("/ingest")
async def ingest_endpoint(body: IngestBody):
report = await ingest(body.dir or CORPUS_DIR, store, cfg, embedder)
# The keyword index must be refreshed after ingestion, or newly stored chunks are findable by
# vector and not by keyword. That one-path-works failure is the hardest to diagnose
indexed = await retriever.refresh()
return {**report, "indexed": indexed} # returned precisely so this can be verifiedOne command to start: who runs the migration
One-command startup sounds like an operations concern and in fact decides how many times a day you can experiment. A project needing five minutes and six commands gets run twice a day; one that is just docker compose up -d gets run ten times an hour. Week two rebuilds indexes and reruns evaluations daily, and that gap gets multiplied dozens of times.
The database side needs three things prepared: installing the pgvector extension, creating three tables, and building the vector index. The question is who runs them and when. The least effort is hanging the schema script in the container's initialization directory, executed automatically the first time the database starts:
services:
postgres:
image: pgvector/pgvector:pg16
ports:
- '5557:5432'
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U rag -d rag']
interval: 5s
retries: 20
volumes:
- ./migrations:/docker-entrypoint-initdb.d:ro
- pgdata:/var/lib/postgresql/dataOne pothole here is worth remembering separately: scripts in the initialization directory execute once, only when the data volume is empty. Edit the schema statements, restart the container, and not one new statement runs, after which you spend an afternoon on "column does not exist." So the server also runs an equivalent set of statements written entirely with IF NOT EXISTS at startup as a backstop. The two look duplicated and solve two scenarios: a brand-new environment and one with existing data.
healthcheck is not decoration either: several seconds pass between a container having started and being able to accept connections, and without it the service fails to connect and exits during those seconds while you assume a configuration error.
Retrospective: do this week's six decisions still hold?
Now lay this week's decisions out one at a time and ask the same question: on what basis at the time, and does it still hold?
D1 doing pure BM25 with no vectors. The reasons were explainability, zero dependencies, and a baseline. Its value turns out to be larger than expected — today's self-test prints nine questions' results alongside D1's, showing at a glance which hits changed. Optimization without a baseline is all self-congratulation. What needs correcting is the implication that keywords would be superseded by vectors: they were not, and the keyword path still contributes the overwhelming majority of hits today.
D2 using the API embedding on the main line with a local model as fallback. The reason was convenience and still holds, plus one thing not anticipated then: precisely because it might change at any time, it forced that unified interface into existence, and that interface is today the cleanest layer in the service.
D3 unifying parse output into a node sequence with heading paths. This is the highest-return decision and it paid off sooner than expected. Today's heading-based chunking uses headingPath directly, chunk bodies can carry a locating note, and citation display can show which section something came from — all free from that data structure.
D4 choosing a chunking approach by hit rate rather than intuition. This one is awkward today: I chose heading-based cutting and cannot give strict evidence. All that was measured is chunks going from 52 to 134 and five of nine baseline questions changing their hit document sets. Better or worse, I cannot say — that is not evaluation, that is observation. The evidence waits for D8.
D5 staying on PostgreSQL rather than a dedicated vector store. A hundred-odd chunks cannot even use an approximate index, and a sequential scan beats HNSW on both speed and accuracy; the index line still goes into the migration script, because it must already be there the day the volume rises. The preconditions were written out plainly: data volume, write frequency, filter complexity, operational capacity, with any one crossed prompting a reassessment.
D6 citations verified by code rather than trusting the model. Today's self-test deliberately feeds in two bad citations: one with an out-of-range number and one whose number is real and whose content does not match. The former is easy to catch and the latter surfaces only after computing overlap. Its weight comes from being the one rule a prompt cannot cover: a prompt makes the model more inclined not to fabricate, and only code verification guarantees a fabrication does not reach the response.
In one line: five of six decisions hold, and the one that does not was not a wrong choice but a missing scale.
Three debts left for week two
An honest milestone must list what it did not achieve. This version has three debts, each with a definite repayment date:
First: no evaluation, so every configuration was guessed. 500-character chunks, four results, a 0.2 gate, weights of 1 to 0.6 — not one number has evidence. Worse, changing any parameter now leaves me unable to say whether it improved anything, only that the hit documents changed. This is the heaviest debt, because it makes the other two unverifiable. D8 builds a golden set, implements recall and ranking metrics, and measures this configuration's baseline; until then every "I think this is better" is a guess.
Second: the two paths run in parallel with crude fusion. Each is normalized against its own top score and weighted-summed, which is blind to a path whose top score is itself low, and there is no reranking, with the top four decided entirely by the fused score. D9 switches to reciprocal rank fusion (which looks only at rank rather than score, sidestepping the scale problem) followed by a cross-encoder rerank, proving with D8's evaluation how much each step contributed.
Third: no permissions and no incrementality. POST /ingest is a full rerun, fine for thirty documents and hours plus a real vectorization bill for thirty thousand. The content_hash in the documents table has not been read once today; it is groundwork for D13. Permissions are more direct: chunks carry a department and nothing uses it — filtering at generation time means it already leaked, since the material entered the context long before. D13 pushes filtering down into the retrieval query and adds cache layering and tracing.
Two smaller ones recorded here so they are not forgotten: the BM25 index lives in one machine's memory and will be inconsistent across a multi-instance deployment; and ingestion is serial with no concurrency and no retry on failure.
Source Reading
Hands-On Lab
Today's lab is week one's milestone, with two or three times the code of earlier days and very little new logic — most of it is rearranging six days' work behind interfaces. The four exercise points land on chunking, vectorization, retrieval gates, and citation verification, each turning one self-test item from a cross to a tick. The whole path runs without Docker and without an API key: with no connection string, storage falls back to the in-memory implementation, and MOCK=1 swaps vectors for deterministic hashes and generation for template assembly.
- Arrange the six days' modules along the eight files' boundaries, settling the storage interface first and then filling in the two implementations, and run the self-test to confirm the service starts.
- Complete the chunking exercise: the ingestion item's chunk count goes from 30 to 134, showing heading-level cutting genuinely took effect.
- Complete the vectorization and gate exercises: the vector item computes non-zero similarities and the team outing question reaches a refusal.
- Complete the citation verification exercise: feed in two bad citations and watch both the out-of-range one and the mismatched one appear in the blocked list.
- Bring up the containerized database, add the connection string, rerun the self-test, confirm the eight results match the in-memory version, and copy the nine lines beside day one's baseline into your own notes.
Interview Questions
Today's 4 questions are in the question bank below, weighted toward module boundaries, configuration and swappability, and a retrospective on all of week one's decisions. Expand each one and read the analysis before the key points — practicing the derivation beats memorizing bullets.
Checklist and Tomorrow
- Split the ingestion and query pipelines into clear module boundaries, and explain why each module can be swapped independently
- Bring the service up along with its database with one command, and run the full path from feeding in documents to getting a cited answer
- List three pieces of technical debt left from this week, and say which day of week two each one gets paid off
- State the criterion for where a cut goes, and use it to explain why the vectorization layer must be swappable
- Explain why a retrieval gate must sit on each path's raw score rather than the fused score
- All 5 acceptance criteria of the lab pass
- Answer at least 3 of the 4 interview questions without looking at the key points
Tomorrow (D8) we build the scale: produce a twenty-question golden set, implement recall, mean reciprocal rank, and normalized discounted cumulative gain, use a model as judge for faithfulness, and produce a baseline report for today's service. The order is deliberate — an evaluation needs something to evaluate, and today's machine is the object under evaluation. From tomorrow, whether each of this week's debts has been repaid is decided by the numbers.
Interview questions
How would you draw the module boundaries of a RAG system, and which layer most needs to be swappable? Why?你会怎么划分一个检索增强生成系统的模块边界?其中哪一层最应该做成可替换的,为什么?
Common in ChinaCommon overseasBasic#architecture#modularity#embeddingsHow to reason about it · think before answering
- This question separates people who have maintained such a system from people who have only built a demo. Reciting the pipeline diagram is not an answer; where you cut it is.
- Offer a reusable criterion first: cut where a layer is most likely to be replaced wholesale, not by lines of code or by tidy functional names.
- Apply it. Embedding models change several times a year, and each change invalidates every stored vector, so that layer must be an interface. Storage may move from PostgreSQL to a dedicated vector database, and both ingestion and query talk through it, so it is the single shared boundary. Chunking changes daily during tuning, so it belongs in config, not in code.
- Conclusion: the embedding layer is the one that must be swappable, because the swap is both likely and expensive, not because interfaces are good style.
- Name the cost of abstraction too: every indirection is one more hop while debugging, so the test is whether the change will actually happen.
- Expected follow-up: should the generation model be abstracted as well? Yes, but at lower priority, because swapping it does not force recomputation of stored data and rollback is cheap. It is a config value, not a layer.
分析过程 · 先想清楚再作答
- 这题考的是你有没有真的维护过这类系统。只按「解析、切块、检索、生成」复述一遍流程图,面试官会判定你只搭过 demo——流程图人人都会画,切口画在哪才是经验。
- 给一条可复用的判据再往下推:切口应该落在「将来最可能被整个换掉」的地方,而不是按代码量或者功能名称均分。
- 用它过一遍:embedding 一年会换好几次,换一次库里所有向量作废、必须全量重算,所以它必须是接口;存储可能从 PostgreSQL 换成专用向量库,而且摄取和查询都要通过它,所以它是两条链路的唯一交界;切块策略在调优期天天改,所以它必须是配置项而不是硬编码。
- 结论:最该做成可替换的是 embedding 那一层,理由不是「设计模式」,而是「换模型这件事真的会发生,且发生时代价极高」。
- 顺手点出抽象的代价:每多一层间接就多一次跳转和一份心智负担,所以判据是「那件事会不会真的发生」,不会发生的别抽象。
- 可预期的追问:那生成模型要不要也抽象?答案是要,但优先级低——换生成模型不需要重算任何存量数据,回滚也便宜,所以它是配置项而不是一层接口。
Key points
- Lead with the criterion: cut where a layer is most likely to be replaced wholesale.
- The embedding layer is the one to abstract: swapping models invalidates every stored vector and forces a full recompute.
- Storage is the single boundary shared by ingestion and query, so define its interface before either implementation.
- Chunking and retrieval routes belong in configuration because they change most often during tuning.
- Abstraction costs indirection, so only abstract changes that will actually happen.
答题要点
- 先给判据:切口落在最可能被整体替换的那一层,不按代码量或功能名称均分。
- embedding 是最该抽象的一层:换模型意味着存量向量全部作废、必须全量重算,代价高且真的会发生。
- 存储层是摄取与查询唯一的交界,接口要先定下来再谈两边实现。
- 切块与检索路数做成配置项,因为它们在调优期改动最频繁,改一次不该动代码。
- 抽象有成本,判据是那件事会不会真的发生;不会发生的抽象就是过度设计。
What should the ingestion path and the query path share, and what concretely goes wrong when you over-share?摄取链路和查询链路应该共享哪些代码?强行复用会带来什么具体问题?
Common in ChinaCommon overseasIntermediate#architecture#ingestion#retrievalHow to reason about it · think before answering
- The word to notice is 'over-share'. The interviewer wants the boundary, not a recital of DRY.
- Start from how the two paths differ. Ingestion is batch: tens of seconds, and a failure just means rerunning it. Query is online: hundreds of milliseconds, and a failure is visible to the user immediately. Error handling, timeouts and concurrency are simply not the same problem.
- Hence the rule: share the interface, not the flow. The only genuinely shared thing is the storage interface, plus the embedding function signature.
- Name the symptom of over-sharing: the extracted module fills up with isIngest branches, every change has to be verified on both paths, and eventually nobody dares touch it.
- Add the one thing that truly must match: chunks and queries must be embedded by the same model. That is shared configuration, not shared code, and the model name belongs in the vector table so a silent mismatch is detectable.
- Expected follow-up: what about chunking? The query path never chunks. Even when it needs a parent block, it reads it back through storage rather than importing the chunker.
分析过程 · 先想清楚再作答
- 题眼在「强行」两个字。面试官想看的是你能不能说出复用的边界,而不是背诵「不要重复自己」。
- 先说清两条链路的性质差异:摄取是批处理,几十秒跑完,失败重跑一遍就行;查询是在线请求,几百毫秒要出结果,失败用户当场看到。错误处理、超时、并发策略天然不同。
- 所以结论是:**共享接口,不共享流程**。两边唯一该共享的是存储层的那个接口,以及 embedding 的函数签名——注意后者共享的是签名和模型选择,不是调用流程。
- 给出强行复用的具体症状:抽出来的公共模块里开始出现 isIngest 这类分支,一个改动要同时验证两条链路,最后没人敢动它。
- 补一条真正必须一致的东西:给块算向量和给问题算向量必须用同一个模型。这不是复用代码,是复用配置——而且要把模型名写进向量表,否则模型换了没人发现,检索会静默地返回垃圾。
- 可预期的追问:那切块逻辑呢?查询侧压根不切块,所以它只属于摄取链路;真要在查询侧用到(比如 D11 的父子回填),走的也是存储层读回大块,不是把切块器搬过来。
Key points
- Share the interface, not the flow: storage is the only boundary, plus the embedding signature.
- The two paths have different error handling and latency budgets; batch can rerun, online must fail fast.
- Over-sharing shows up as isIngest branches and changes that must be verified twice.
- What must match is the model choice, not the code: record the model name alongside every stored vector.
- Chunking belongs to ingestion only; the query path reads larger units back through storage.
答题要点
- 共享接口不共享流程:唯一的交界是存储层,加上 embedding 的函数签名。
- 两条链路的错误处理与延迟约束根本不同,批处理可以重跑,在线请求必须快速失败。
- 强行复用的症状是公共模块里长出 isIngest 分支,改一次要验两条链路。
- 必须一致的是模型选择而不是代码:块与查询要用同一个 embedding 模型,并把模型名记进向量表。
- 切块只属于摄取;查询侧需要大块时通过存储层读回,而不是把切块器搬过去。
What three checks would you run before shipping a retrieval QA service, and why those three?一个检索问答服务上线前你会做哪三项检查?为什么偏偏是这三项?
Common in ChinaCommon overseasIntermediate#production-readiness#citations#refusalHow to reason about it · think before answering
- The discriminator is not how many checks you list but whether you can justify the three. Ten items with no ranking suggests you have never had to prioritize.
- Derive them by consequence: the failures that are invisible to users and most damaging go first.
- First, citations must be verifiable: every cited id resolves to a real chunk, and that chunk genuinely overlaps the sentence citing it. This ranks first because a wrong citation is undetectable by the user, and citations are the only source of trust this system has.
- Second, refusal must actually fire: ask a question the corpus cannot answer and confirm the system says so instead of inventing. Also invisible, and one discovered fabrication zeroes out trust in the whole product.
- Third, ingestion-to-retrieval consistency: freshly ingested documents are retrievable immediately, and the keyword and vector paths cover the same set. This guards against the 'one route finds it, the other does not' failure, which is the hardest to diagnose.
- Expected follow-up: why not latency and cost? Because those failures are visible. Users complain about slowness and the bill reports overspending; nobody will ever report the three above.
分析过程 · 先想清楚再作答
- 这题的区分度不在你能列几项,而在你能不能说清「为什么是这三项」。列十项而每项都不给理由,反而说明你没有排过优先级。
- 推导方式是按后果排序:哪种故障用户看不出来、又损失最大,哪一项就该排在前面。
- 第一项是引用可查证:每条引用的编号都能回查到真实存在的块,且那一块确实与该句有实质重合。这一项排第一是因为引用错了用户根本发现不了,而它恰恰是这类系统唯一的信任来源。
- 第二项是该拒答时真的拒答:构造一个语料里没有答案的问题,看它是回那句拒答话术还是开始编。这一项也属于用户看不出来的故障,且一旦编造被发现,整个系统的可信度归零。
- 第三项是摄取到检索的一致性:摄取完之后新文档立刻能被检索到,且关键词与向量两路的覆盖数量对得上。这一项防的是「一路能查一路查不到」这种最难排查的故障。
- 可预期的追问:为什么延迟和成本不在前三?因为它们是**看得见**的故障——慢了用户会抱怨,贵了账单会告诉你;而上面三项不检查就永远不会有人告诉你。
Key points
- State the ranking rule first: prioritize failures users cannot see but that cost the most.
- Check one, verifiable citations: every id resolves to a real chunk that overlaps the sentence citing it.
- Check two, refusal actually fires on a question the corpus cannot answer.
- Check three, ingestion and retrieval agree: new documents are immediately retrievable on both routes.
- Latency and cost matter but rank lower because those failures announce themselves.
答题要点
- 先给排序依据:优先检查用户发现不了、但后果最重的故障。
- 第一项引用可查证:编号能回查到真实的块,且该块与被引的那句话有实质重合。
- 第二项拒答生效:用一个语料里没有答案的问题验证系统会说查不到,而不是开始编。
- 第三项摄取与检索一致:新入库的文档立刻可检索,关键词与向量两路覆盖对得上。
- 延迟和成本重要但排在后面,因为它们是看得见的故障,会自己找上门。
What is the biggest risk in the RAG service you just assembled, and how would you prove that judgment?你刚拼出来的这个检索问答系统,现在最大的风险在哪里?你打算怎么证明这个判断?
Common in ChinaCommon overseasDeep dive#evaluation#risk-assessment#retrospectiveHow to reason about it · think before answering
- There are two halves here and the second is the real question. Naming a risk is easy; giving a method that could falsify your own claim is what separates answers from opinions.
- Rule out two common wrong answers: 'hallucination' is too vague to act on, and 'latency' mistakes a visible problem for the biggest one.
- The biggest risk is the absence of evaluation. Chunk size, top-k, thresholds and route weights were all guessed, and that makes every other risk unverifiable: you cannot even say whether a change helped.
- How to prove it: build a question set from the corpus with known answer documents, deliberately including unanswerable and multi-hop questions; implement recall and ranking metrics; produce a baseline for the current configuration; then move one parameter back and forth and watch whether the metrics move. If they do not move at all, the evaluation set is wrong, not the system.
- Add the accounting rule: every optimization reports three numbers, metric gain, latency added and cost added. A claim with only the first is not usable.
- Expected follow-up: how large must the set be? Start with roughly twenty questions covering the main question types to catch obvious regressions, then grow toward the real distribution once you have actual user questions. Chasing size first only yields questions you invented yourself.
分析过程 · 先想清楚再作答
- 这题有两半,后半句才是题眼。说出一个风险不难,难的是给出一个能证伪你自己判断的方法——答不出后半句,前半句就只是意见。
- 先排除两个常见的错误答案:说「幻觉」太笼统,没有指向任何可动的地方;说「延迟」则是把看得见的问题当成最大风险。
- 真正的最大风险是**没有评估**:切块大小、取几条、门槛定多少、两路怎么加权,全是拍出来的。它最重要的地方在于它让所有其他风险都无法验收——你连「改了之后变好还是变坏」都说不出口。
- 怎么证明:先从语料反向出一份带标准答案文档的问题集,刻意掺进无答案问题和需要跨文档的多跳问题;再实现召回率与排序指标,给当前配置跑出一个基线;然后把一个参数来回改两次,看指标动不动。如果指标对参数完全不敏感,说明是评估集有问题,不是系统没问题。
- 补一句成本口径:每一项优化都要同时报三笔账——指标涨了多少、延迟涨了多少、钱涨了多少。只报第一笔的结论不能用。
- 可预期的追问:评估集多大才够?先做二十题能覆盖主要问题类型的小集,用它挡住明显的退步;等真实用户问题攒起来,再按真实分布扩到几百题。一上来就追求规模,只会得到一堆自己出的、跟真实用法无关的题。
Key points
- The biggest risk is having no evaluation: every parameter was guessed, so no change can be judged.
- Prove it by building a golden set with known answer documents, including unanswerable and multi-hop questions, then baseline the current configuration.
- Validate the set itself by perturbing parameters: metrics that never move mean the questions are wrong.
- Report three numbers per optimization: metric gain, added latency, added cost.
- Start small but well covered, then grow toward the real question distribution.
答题要点
- 最大的风险是没有评估:所有参数都是拍的,导致任何改动的好坏都无法判断。
- 证明方式是先建标准答案集,刻意包含无答案问题与多跳问题,再跑出当前配置的基线。
- 用参数扰动反过来验证评估集本身:指标对参数完全不敏感,说明题出得有问题。
- 每项优化同时报三笔账:指标、延迟、成本;只报指标的结论不能用。
- 评估集先小而全,覆盖问题类型即可,等真实问题攒起来再按真实分布扩大。