diff --git a/.revision-progress b/.revision-progress index 2aa5a1e..deec5f1 100644 --- a/.revision-progress +++ b/.revision-progress @@ -2,3 +2,5 @@ phase1 done 2026-10-23 15:05:36:目录设计、CONTRIBUTING、schema、tools/build_catalog.py、run_examples.py、check_sensitive.py、31 个技术栈首页骨架、周文件 frontmatter phase2a done 2026-10-23:后端 7 个技术栈(go/gin/postgresql/mysql/redis/mq/grpc-microservices)完成,提交 5eb67c5 phase2 done 2026-10-23:后端 7 个 + 运维 5 个技术栈(docker/nginx/linux/cicd/git)全部完成;run_examples 14 项通过;上半部分提交 5eb67c5 +phase3a done 2026-10-23:python/asyncio/fastapi 完成,提交 7f52513 +steering 2026-10-23 15:47:新增 BOSS直聘调研(research/boss-2026-10-23.md)。新增 domain cs 和 type research;新增 12 个技术栈(backend: mongodb/elasticsearch/data-pipeline/nodejs/java-spring;devops: k8s;ai: agent-design/llm-serving/ai-coding;cs: algorithms/network-os/web-security),共 43 个。待办:plan/README 第 4 节加 BOSS 对照+更新延伸表;README 链接 research;SSR 专题加 Next.js 课;第 6 阶段调整周计划(Go 保持 3 周,加强 Python/Agent/RAG/作品集)和 week15 应聘材料要求。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 86a7edd..aa98c1d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,12 +25,13 @@ zhuangang/ │ ├── scenarios/.md # type: scenario,该技术栈的企业场景 │ ├── interview.md # type: interview,分级面试题 │ └── examples/ # 可运行的示例代码 -└── scenarios/ # 跨技术栈的企业服务场景(type: scenario,domain: cross) - ├── index.md - └── .md +├── scenarios/ # 跨技术栈的企业服务场景(type: scenario,domain: cross) +│ ├── index.md +│ └── .md +└── research/ # 岗位调研记录(type: research,domain: plan),只记录实际看到的内容 ``` -- `domain` 取值:`backend`(Go、数据库、缓存、消息队列、微服务)、`devops`(Docker、Nginx、Linux、CI/CD、Git)、`python`、`ai`(RAG、Agent 等)、`frontend`、`cross`(跨栈场景)、`plan`(计划)。 +- `domain` 取值:`backend`(Go、数据库、缓存、消息队列、微服务)、`devops`(Docker、Nginx、Linux、CI/CD、Git)、`python`、`ai`(RAG、Agent 等)、`frontend`、`cs`(计算机基础:算法、网络与操作系统、Web 安全)、`cross`(跨栈场景)、`plan`(计划)。 - `` 是技术栈标识,只用小写字母、数字和 `-`,例如 `redis`、`grpc-microservices`、`fe-testing-perf`。 ## 2. frontmatter @@ -58,7 +59,7 @@ order: 2 | --- | --- | --- | | `id` | 是 | 全局唯一、**发布后不再修改**(平台用它做进度记录和链接)。格式:小写字母数字,用 `.` 分段 | | `title` | 是 | 标题 | -| `type` | 是 | `index` / `lesson` / `scenario` / `interview` / `project` / `week` | +| `type` | 是 | `index` / `lesson` / `scenario` / `interview` / `project` / `week` / `research`(岗位调研记录,放在 `research/`) | | `domain` | 是 | 见上 | | `stack` | lesson、scenario、interview 必填 | 列表;跨栈场景写多个 | | `level` | lesson、scenario、interview 必填 | `basic`(基础)/ `intermediate`(进阶)/ `advanced`(高级) | diff --git a/README.md b/README.md index 3f732ff..4defeef 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,9 @@ | 我想…… | 看这里 | | --- | --- | | 按 16 周计划一步步学 | [plan/README.md](plan/README.md)(总纲、进度记录),每周文件链接到对应专题 | -| 单独补某个技术栈 | [topics/index.md](topics/index.md)(31 个技术栈:课程、场景、面试题、示例) | +| 单独补某个技术栈 | [topics/index.md](topics/index.md)(43 个技术栈,分后端、DevOps、Python、AI、前端、计算机基础六个领域:课程、场景、面试题、示例) | | 看企业里的综合方案 | [scenarios/index.md](scenarios/index.md)(客服知识库、工单、审批流、秒杀、多租户……) | +| 看岗位要求和调研 | [plan/README.md 第 4 节](plan/README.md)(岗位要求对照、延伸技术栈)、[research/](research/index.md)(2026-10-23 BOSS直聘北京约 34 个岗位的调研记录) | | 准备面试 | 每个技术栈目录下的 `interview.md`(基础 / 进阶 / 场景题,带参考答案和追问) | | 写新内容 / 做学习平台 | [CONTRIBUTING.md](CONTRIBUTING.md)(目录、frontmatter、各类型写法)、`catalog.json`(自动生成的内容目录) | @@ -16,6 +17,7 @@ ```text plan/ 16 周日程(type: week),链接到专题,不重复讲概念 +research/ 岗位调研记录(type: research),只记录实际看到的内容 topics/ 专项学习:<领域>/<技术栈>/{index.md, NN-课程.md, scenarios/, interview.md, examples/} scenarios/ 跨技术栈的企业服务场景 tools/ build_catalog.py(校验 + 生成目录)、run_examples.py(运行示例)、check_sensitive.py(脱敏检查) diff --git a/catalog.json b/catalog.json index bdfc901..54b2b83 100644 --- a/catalog.json +++ b/catalog.json @@ -2,6 +2,20 @@ "schema_version": 1, "generated_by": "tools/build_catalog.py", "stacks": [ + { + "stack": "agent-design", + "domain": "ai", + "title": "Agent 设计:规划、上下文工程与多 Agent", + "index": "topics/ai/agent-design/index.md", + "lessons": 0, + "scenarios": 0, + "interview_questions": { + "基础": 0, + "进阶": 0, + "场景题": 0 + }, + "examples": [] + }, { "stack": "agent-ops", "domain": "ai", @@ -16,6 +30,20 @@ }, "examples": [] }, + { + "stack": "ai-coding", + "domain": "ai", + "title": "AI 编程工具与产出验证", + "index": "topics/ai/ai-coding/index.md", + "lessons": 0, + "scenarios": 0, + "interview_questions": { + "基础": 0, + "进阶": 0, + "场景题": 0 + }, + "examples": [] + }, { "stack": "function-calling", "domain": "ai", @@ -58,6 +86,20 @@ }, "examples": [] }, + { + "stack": "llm-serving", + "domain": "ai", + "title": "模型接入与部署(了解级)", + "index": "topics/ai/llm-serving/index.md", + "lessons": 0, + "scenarios": 0, + "interview_questions": { + "基础": 0, + "进阶": 0, + "场景题": 0 + }, + "examples": [] + }, { "stack": "lowcode", "domain": "ai", @@ -105,6 +147,38 @@ "domain": "ai", "title": "RAG 检索增强生成", "index": "topics/ai/rag/index.md", + "lessons": 3, + "scenarios": 1, + "interview_questions": { + "基础": 4, + "进阶": 4, + "场景题": 3 + }, + "examples": [ + "topics/ai/rag/examples/mini-rag" + ] + }, + { + "stack": "vector-db", + "domain": "ai", + "title": "向量数据库", + "index": "topics/ai/vector-db/index.md", + "lessons": 2, + "scenarios": 1, + "interview_questions": { + "基础": 3, + "进阶": 3, + "场景题": 2 + }, + "examples": [ + "topics/ai/vector-db/examples/vector-stores" + ] + }, + { + "stack": "data-pipeline", + "domain": "backend", + "title": "数据管道与第三方接口对接", + "index": "topics/backend/data-pipeline/index.md", "lessons": 0, "scenarios": 0, "interview_questions": { @@ -115,10 +189,10 @@ "examples": [] }, { - "stack": "vector-db", - "domain": "ai", - "title": "向量数据库", - "index": "topics/ai/vector-db/index.md", + "stack": "elasticsearch", + "domain": "backend", + "title": "Elasticsearch", + "index": "topics/backend/elasticsearch/index.md", "lessons": 0, "scenarios": 0, "interview_questions": { @@ -177,6 +251,34 @@ "topics/backend/grpc-microservices/examples/resilience" ] }, + { + "stack": "java-spring", + "domain": "backend", + "title": "Java / Spring Boot(了解级)", + "index": "topics/backend/java-spring/index.md", + "lessons": 0, + "scenarios": 0, + "interview_questions": { + "基础": 0, + "进阶": 0, + "场景题": 0 + }, + "examples": [] + }, + { + "stack": "mongodb", + "domain": "backend", + "title": "MongoDB", + "index": "topics/backend/mongodb/index.md", + "lessons": 0, + "scenarios": 0, + "interview_questions": { + "基础": 0, + "进阶": 0, + "场景题": 0 + }, + "examples": [] + }, { "stack": "mq", "domain": "backend", @@ -210,6 +312,20 @@ "topics/backend/mysql/examples/deep-pagination" ] }, + { + "stack": "nodejs", + "domain": "backend", + "title": "Node.js 后端(Express / NestJS)", + "index": "topics/backend/nodejs/index.md", + "lessons": 0, + "scenarios": 0, + "interview_questions": { + "基础": 0, + "进阶": 0, + "场景题": 0 + }, + "examples": [] + }, { "stack": "postgresql", "domain": "backend", @@ -242,6 +358,48 @@ "topics/backend/redis/examples/stock-lock-ratelimit" ] }, + { + "stack": "algorithms", + "domain": "cs", + "title": "数据结构与算法(面试)", + "index": "topics/cs/algorithms/index.md", + "lessons": 0, + "scenarios": 0, + "interview_questions": { + "基础": 0, + "进阶": 0, + "场景题": 0 + }, + "examples": [] + }, + { + "stack": "network-os", + "domain": "cs", + "title": "网络与操作系统基础", + "index": "topics/cs/network-os/index.md", + "lessons": 0, + "scenarios": 0, + "interview_questions": { + "基础": 0, + "进阶": 0, + "场景题": 0 + }, + "examples": [] + }, + { + "stack": "web-security", + "domain": "cs", + "title": "Web 安全", + "index": "topics/cs/web-security/index.md", + "lessons": 0, + "scenarios": 0, + "interview_questions": { + "基础": 0, + "进阶": 0, + "场景题": 0 + }, + "examples": [] + }, { "stack": "cicd", "domain": "devops", @@ -290,6 +448,20 @@ "topics/devops/git/examples/git-lab" ] }, + { + "stack": "k8s", + "domain": "devops", + "title": "Kubernetes 基础", + "index": "topics/devops/k8s/index.md", + "lessons": 0, + "scenarios": 0, + "interview_questions": { + "基础": 0, + "进阶": 0, + "场景题": 0 + }, + "examples": [] + }, { "stack": "linux", "domain": "devops", @@ -470,6 +642,28 @@ } ], "items": [ + { + "id": "ai.agent-design.index", + "title": "Agent 设计:规划、上下文工程与多 Agent", + "type": "index", + "domain": "ai", + "stack": [ + "agent-design" + ], + "tags": [], + "level": null, + "prerequisites": [], + "related": [], + "est_hours": null, + "week": [ + 10, + 11, + 13 + ], + "order": null, + "path": "topics/ai/agent-design/index.md", + "sections": [] + }, { "id": "ai.agent-ops.index", "title": "Agent 记忆、评测与可观测性", @@ -491,6 +685,29 @@ "path": "topics/ai/agent-ops/index.md", "sections": [] }, + { + "id": "ai.ai-coding.index", + "title": "AI 编程工具与产出验证", + "type": "index", + "domain": "ai", + "stack": [ + "ai-coding" + ], + "tags": [], + "level": null, + "prerequisites": [], + "related": [], + "est_hours": null, + "week": [ + 1, + 8, + 10, + 15 + ], + "order": null, + "path": "topics/ai/ai-coding/index.md", + "sections": [] + }, { "id": "ai.function-calling.index", "title": "Function Calling 工具调用", @@ -553,6 +770,27 @@ "path": "topics/ai/langgraph/index.md", "sections": [] }, + { + "id": "ai.llm-serving.index", + "title": "模型接入与部署(了解级)", + "type": "index", + "domain": "ai", + "stack": [ + "llm-serving" + ], + "tags": [], + "level": null, + "prerequisites": [], + "related": [], + "est_hours": null, + "week": [ + 10, + 13 + ], + "order": null, + "path": "topics/ai/llm-serving/index.md", + "sections": [] + }, { "id": "ai.lowcode.index", "title": "Dify / Coze 低代码平台", @@ -616,6 +854,86 @@ "path": "topics/ai/prompt-engineering/index.md", "sections": [] }, + { + "id": "ai.rag.generation-eval", + "title": "生成与评测:带引用的提示词、拒答、检索评测和答案评测", + "type": "lesson", + "domain": "ai", + "stack": [ + "rag" + ], + "tags": [ + "提示词", + "引用", + "拒答", + "幻觉", + "Recall@k", + "MRR", + "评测集" + ], + "level": "intermediate", + "prerequisites": [ + "ai.rag.hybrid-rerank" + ], + "related": [ + "ai.rag.scenario.permission-aware-kb", + "ai.prompt-engineering.index", + "ai.agent-ops.index", + "ai.rag.interview" + ], + "est_hours": 3, + "week": [ + 6 + ], + "order": 3, + "path": "topics/ai/rag/03-generation-eval.md", + "sections": [ + "解释说明", + "案例", + "代码示例", + "易错点", + "面试常问" + ] + }, + { + "id": "ai.rag.hybrid-rerank", + "title": "检索优化:向量 + 关键词混合检索、RRF 融合、重排、查询改写", + "type": "lesson", + "domain": "ai", + "stack": [ + "rag" + ], + "tags": [ + "混合检索", + "BM25", + "RRF", + "重排", + "rerank", + "查询改写" + ], + "level": "intermediate", + "prerequisites": [ + "ai.rag.pipeline-chunking" + ], + "related": [ + "ai.rag.generation-eval", + "ai.vector-db.ann-metrics", + "ai.rag.interview" + ], + "est_hours": 3, + "week": [ + 6 + ], + "order": 2, + "path": "topics/ai/rag/02-hybrid-rerank.md", + "sections": [ + "解释说明", + "案例", + "代码示例", + "易错点", + "面试常问" + ] + }, { "id": "ai.rag.index", "title": "RAG 检索增强生成", @@ -624,7 +942,12 @@ "stack": [ "rag" ], - "tags": [], + "tags": [ + "RAG", + "检索", + "知识库", + "评测" + ], "level": null, "prerequisites": [], "related": [], @@ -635,7 +958,180 @@ ], "order": null, "path": "topics/ai/rag/index.md", - "sections": [] + "sections": [ + "学习路线", + "企业场景", + "面试题", + "示例代码", + "在 16 周计划中", + "官方资料" + ] + }, + { + "id": "ai.rag.interview", + "title": "RAG 面试题", + "type": "interview", + "domain": "ai", + "stack": [ + "rag" + ], + "tags": [ + "RAG", + "检索", + "评测", + "面试" + ], + "level": "intermediate", + "prerequisites": [], + "related": [ + "ai.rag.pipeline-chunking", + "ai.rag.hybrid-rerank", + "ai.rag.generation-eval" + ], + "est_hours": null, + "week": [ + 5, + 6, + 16 + ], + "order": null, + "path": "topics/ai/rag/interview.md", + "sections": [ + "基础", + "进阶", + "场景题" + ], + "questions": { + "基础": 4, + "进阶": 4, + "场景题": 3 + } + }, + { + "id": "ai.rag.pipeline-chunking", + "title": "RAG 流水线和文档切分:加载、清洗、切分策略、元数据", + "type": "lesson", + "domain": "ai", + "stack": [ + "rag" + ], + "tags": [ + "RAG", + "文档加载", + "切分", + "chunk", + "元数据", + "嵌入" + ], + "level": "basic", + "prerequisites": [ + "python.python.syntax-for-js" + ], + "related": [ + "ai.rag.hybrid-rerank", + "ai.vector-db.ann-metrics", + "ai.rag.interview" + ], + "est_hours": 3, + "week": [ + 5 + ], + "order": 1, + "path": "topics/ai/rag/01-pipeline-chunking.md", + "sections": [ + "解释说明", + "案例", + "代码示例", + "易错点", + "面试常问" + ] + }, + { + "id": "ai.rag.scenario.permission-aware-kb", + "title": "带权限的企业知识库问答:不同部门看到不同内容", + "type": "scenario", + "domain": "ai", + "stack": [ + "rag", + "vector-db", + "elasticsearch", + "fastapi" + ], + "tags": [ + "企业知识库", + "权限过滤", + "文档解析", + "混合检索", + "重排", + "拒答", + "增量更新" + ], + "level": "advanced", + "prerequisites": [ + "ai.rag.generation-eval" + ], + "related": [ + "ai.rag.interview", + "ai.vector-db.scenario.multi-tenant-index", + "backend.elasticsearch.index" + ], + "est_hours": 3, + "week": [ + 5, + 6 + ], + "order": null, + "path": "topics/ai/rag/scenarios/permission-aware-kb.md", + "sections": [ + "业务背景与问题", + "约束", + "方案设计", + "关键代码", + "取舍", + "易错点", + "面试怎么讲" + ] + }, + { + "id": "ai.vector-db.ann-metrics", + "title": "向量检索原理:距离度量、暴力检索、HNSW / IVF 近似索引", + "type": "lesson", + "domain": "ai", + "stack": [ + "vector-db" + ], + "tags": [ + "向量检索", + "余弦相似度", + "内积", + "L2", + "HNSW", + "IVF", + "PQ", + "召回率" + ], + "level": "basic", + "prerequisites": [ + "ai.rag.pipeline-chunking" + ], + "related": [ + "ai.vector-db.selection-pgvector", + "ai.rag.hybrid-rerank", + "ai.vector-db.interview" + ], + "est_hours": 2.5, + "week": [ + 5 + ], + "order": 1, + "path": "topics/ai/vector-db/01-ann-metrics.md", + "sections": [ + "解释说明", + "案例", + "代码示例", + "易错点", + "面试常问" + ] }, { "id": "ai.vector-db.index", @@ -645,7 +1141,13 @@ "stack": [ "vector-db" ], - "tags": [], + "tags": [ + "向量数据库", + "HNSW", + "pgvector", + "Chroma", + "Milvus" + ], "level": null, "prerequisites": [], "related": [], @@ -656,6 +1158,175 @@ ], "order": null, "path": "topics/ai/vector-db/index.md", + "sections": [ + "学习路线", + "企业场景", + "面试题", + "示例代码", + "在 16 周计划中", + "官方资料" + ] + }, + { + "id": "ai.vector-db.interview", + "title": "向量数据库面试题", + "type": "interview", + "domain": "ai", + "stack": [ + "vector-db" + ], + "tags": [ + "向量数据库", + "HNSW", + "pgvector", + "面试" + ], + "level": "intermediate", + "prerequisites": [], + "related": [ + "ai.vector-db.ann-metrics", + "ai.vector-db.selection-pgvector", + "ai.rag.interview" + ], + "est_hours": null, + "week": [ + 5, + 6, + 16 + ], + "order": null, + "path": "topics/ai/vector-db/interview.md", + "sections": [ + "基础", + "进阶", + "场景题" + ], + "questions": { + "基础": 3, + "进阶": 3, + "场景题": 2 + } + }, + { + "id": "ai.vector-db.scenario.multi-tenant-index", + "title": "多租户知识库的向量索引:隔离、过滤召回和增量更新", + "type": "scenario", + "domain": "ai", + "stack": [ + "vector-db", + "postgresql" + ], + "tags": [ + "多租户", + "元数据过滤", + "增量更新", + "召回率", + "SaaS" + ], + "level": "advanced", + "prerequisites": [ + "ai.vector-db.selection-pgvector" + ], + "related": [ + "ai.rag.scenario.permission-aware-kb", + "ai.vector-db.interview" + ], + "est_hours": 2.5, + "week": [ + 6 + ], + "order": null, + "path": "topics/ai/vector-db/scenarios/multi-tenant-index.md", + "sections": [ + "业务背景与问题", + "约束", + "方案设计", + "关键代码", + "取舍", + "易错点", + "面试怎么讲" + ] + }, + { + "id": "ai.vector-db.selection-pgvector", + "title": "向量库选型和 pgvector 实战:Chroma / pgvector / Milvus / Elasticsearch", + "type": "lesson", + "domain": "ai", + "stack": [ + "vector-db", + "postgresql" + ], + "tags": [ + "向量库选型", + "pgvector", + "Chroma", + "Milvus", + "Elasticsearch", + "元数据过滤" + ], + "level": "intermediate", + "prerequisites": [ + "ai.vector-db.ann-metrics" + ], + "related": [ + "ai.vector-db.scenario.multi-tenant-index", + "backend.postgresql.index", + "backend.elasticsearch.index" + ], + "est_hours": 3, + "week": [ + 5, + 6 + ], + "order": 2, + "path": "topics/ai/vector-db/02-selection-pgvector.md", + "sections": [ + "解释说明", + "案例", + "代码示例", + "易错点", + "面试常问" + ] + }, + { + "id": "backend.data-pipeline.index", + "title": "数据管道与第三方接口对接", + "type": "index", + "domain": "backend", + "stack": [ + "data-pipeline" + ], + "tags": [], + "level": null, + "prerequisites": [], + "related": [], + "est_hours": null, + "week": [ + 14 + ], + "order": null, + "path": "topics/backend/data-pipeline/index.md", + "sections": [] + }, + { + "id": "backend.elasticsearch.index", + "title": "Elasticsearch", + "type": "index", + "domain": "backend", + "stack": [ + "elasticsearch" + ], + "tags": [], + "level": null, + "prerequisites": [], + "related": [], + "est_hours": null, + "week": [ + 6, + 14 + ], + "order": null, + "path": "topics/backend/elasticsearch/index.md", "sections": [] }, { @@ -1261,6 +1932,46 @@ "面试怎么讲" ] }, + { + "id": "backend.java-spring.index", + "title": "Java / Spring Boot(了解级)", + "type": "index", + "domain": "backend", + "stack": [ + "java-spring" + ], + "tags": [], + "level": null, + "prerequisites": [], + "related": [], + "est_hours": null, + "week": [ + 14 + ], + "order": null, + "path": "topics/backend/java-spring/index.md", + "sections": [] + }, + { + "id": "backend.mongodb.index", + "title": "MongoDB", + "type": "index", + "domain": "backend", + "stack": [ + "mongodb" + ], + "tags": [], + "level": null, + "prerequisites": [], + "related": [], + "est_hours": null, + "week": [ + 14 + ], + "order": null, + "path": "topics/backend/mongodb/index.md", + "sections": [] + }, { "id": "backend.mq.index", "title": "消息队列(RabbitMQ / Kafka)", @@ -1724,6 +2435,27 @@ "面试常问" ] }, + { + "id": "backend.nodejs.index", + "title": "Node.js 后端(Express / NestJS)", + "type": "index", + "domain": "backend", + "stack": [ + "nodejs" + ], + "tags": [], + "level": null, + "prerequisites": [], + "related": [], + "est_hours": null, + "week": [ + 7, + 14 + ], + "order": null, + "path": "topics/backend/nodejs/index.md", + "sections": [] + }, { "id": "backend.postgresql.index", "title": "PostgreSQL", @@ -2142,6 +2874,69 @@ "面试怎么讲" ] }, + { + "id": "cs.algorithms.index", + "title": "数据结构与算法(面试)", + "type": "index", + "domain": "cs", + "stack": [ + "algorithms" + ], + "tags": [], + "level": null, + "prerequisites": [], + "related": [], + "est_hours": null, + "week": [ + 16 + ], + "order": null, + "path": "topics/cs/algorithms/index.md", + "sections": [] + }, + { + "id": "cs.network-os.index", + "title": "网络与操作系统基础", + "type": "index", + "domain": "cs", + "stack": [ + "network-os" + ], + "tags": [], + "level": null, + "prerequisites": [], + "related": [], + "est_hours": null, + "week": [ + 14, + 16 + ], + "order": null, + "path": "topics/cs/network-os/index.md", + "sections": [] + }, + { + "id": "cs.web-security.index", + "title": "Web 安全", + "type": "index", + "domain": "cs", + "stack": [ + "web-security" + ], + "tags": [], + "level": null, + "prerequisites": [], + "related": [], + "est_hours": null, + "week": [ + 7, + 14, + 16 + ], + "order": null, + "path": "topics/cs/web-security/index.md", + "sections": [] + }, { "id": "devops.cicd.index", "title": "CI/CD", @@ -2720,6 +3515,26 @@ "面试怎么讲" ] }, + { + "id": "devops.k8s.index", + "title": "Kubernetes 基础", + "type": "index", + "domain": "devops", + "stack": [ + "k8s" + ], + "tags": [], + "level": null, + "prerequisites": [], + "related": [], + "est_hours": null, + "week": [ + 14 + ], + "order": null, + "path": "topics/devops/k8s/index.md", + "sections": [] + }, { "id": "devops.linux.commands", "title": "常用命令:文件、文本处理、进程、端口、权限、systemd", @@ -4433,6 +5248,50 @@ "面试常问" ] }, + { + "id": "research.boss-2026-10-23", + "title": "BOSS直聘北京岗位调研(2026-10-23)", + "type": "research", + "domain": "plan", + "stack": [], + "tags": [ + "岗位调研", + "BOSS直聘", + "北京", + "AI 应用", + "Agent", + "全栈" + ], + "level": null, + "prerequisites": [], + "related": [], + "est_hours": null, + "week": [], + "order": null, + "path": "research/boss-2026-10-23.md", + "sections": [ + "原始记录:BOSS直聘 北京岗位调研(2026-10-23,登录后只读)", + "技术出现次数(约数)", + "反复出现、计划里要补强的要求", + "代表岗位(职位 | 公司 | 经验 | URL 前缀 https://www.zhipin.com)" + ] + }, + { + "id": "research.index", + "title": "岗位调研记录", + "type": "index", + "domain": "plan", + "stack": [], + "tags": [], + "level": null, + "prerequisites": [], + "related": [], + "est_hours": null, + "week": [], + "order": null, + "path": "research/index.md", + "sections": [] + }, { "id": "scenarios.index", "title": "跨技术栈企业服务场景", @@ -4468,7 +5327,8 @@ "运维与工程(`devops`)", "Python(`python`)", "AI 应用(`ai`)", - "前端(`frontend`)" + "前端(`frontend`)", + "计算机基础(`cs`)" ] } ] diff --git a/plan/README.md b/plan/README.md index 0209658..a2b31d8 100644 --- a/plan/README.md +++ b/plan/README.md @@ -116,13 +116,36 @@ stack: [] ### 调研说明 -- 来源:智联招聘,2026-10-22 检索“AI Agent开发”和“Go全栈”,共约 17 个岗位。BOSS 直聘需要登录,没有拿到数据。 -- 样本不大,只能看出方向,不代表整个市场。下面只写这次实际看到的内容,没有额外统计数字。建议每过一个月自己再搜一次,更新这一节。 +两次调研,都只记录实际看到的内容,样本不大,只能看出方向,不代表整个市场。建议每过一个月自己再搜一次,在 [research/](../research/index.md) 新建一个文件并更新这一节。 + +| 日期 | 来源 | 搜索词 | 样本 | 记录 | +| --- | --- | --- | --- | --- | +| 2026-10-22 | 智联招聘 | “AI Agent开发”“Go全栈” | 约 17 个岗位 | 本节下方的两张表 | +| 2026-10-23 | BOSS直聘(北京,登录后只读) | 全栈工程师 Go / AI应用开发 / Agent开发 / 大模型应用 全栈 | 读了 37 个,有效约 34 个;薪资因字体混淆读不到 | [research/boss-2026-10-23.md](../research/boss-2026-10-23.md) | ### 学历和经验 -- 学历:约 95% 的岗位写的是“本科”;有 1 个要求统招硕士,1 个标注统招本科;没看到要求 985 / 211 的。 -- 经验:大多要求 3~5 年。 +- 智联:约 95% 的岗位写的是“本科”;有 1 个要求统招硕士,1 个标注统招本科;没看到要求 985 / 211 的。经验大多要求 3~5 年。 +- BOSS直聘:明确写“统招”的只有 2 个(都是硕士 / 985 方向的视频算法岗,不在本计划的目标范围内),其余基本是本科。 +- 结论:成人本科可以正常投递绝大多数岗位;简历如实写“本科”,标了“统招”的岗位也可以投,由对方判断。 + +### BOSS直聘(2026-10-23)技术出现次数(约数) + +| 技术 | 出现的岗位数 | 技术 | 出现的岗位数 | +| --- | --- | --- | --- | +| Agent / 智能体 | 22 | Redis | 9 | +| Python | 21 | LangChain / LangGraph / AutoGen / LlamaIndex | 9 | +| RAG | 17 | MCP / Function Calling / Tool Calling | 9 | +| AI 编程工具(Claude Code / Cursor / Codex) | 16 | Docker | 7 | +| Vue / React | 14 | 向量库(Milvus / FAISS / Chroma) | 7 | +| Prompt 工程 | 13 | LoRA / 微调 | 7 | +| Java | 12 | PostgreSQL | 5 | +| Go(硬性要求只有 2 个) | 10 | Kafka / MQ | 5 | +| MySQL | 10 | CI/CD | 5 | +| TS / JS | 10 | FastAPI、MongoDB、Node.js | 各 4 | +| | | K8s | 3 | + +和第一次调研相比,新出现或明显加重的要求:**AI 编程工具 + 对 AI 产出的验证**(不少岗位要求附“用 AI 做过的项目 + 怎么确认它有效”,有的要会话记录)、**Agent 工程化**(规划、上下文工程、Skills、A2A、评测、幻觉防护、Bad Case 分析、成本延迟、可观测性)、**AI 原生前后端**(流式、结构化输出、任务状态、异常恢复)、**RAG 全链路含 Elasticsearch**、**模型侧了解**(多模型接入、vLLM 部署、LoRA)、**数据管道和第三方接口**、**计算机基础**、**MongoDB / ES / K8s**、**Next.js / Node.js**、**Java**(全栈岗位常见)。完整的 12 类要求和代表性岗位链接见[调研记录](../research/boss-2026-10-23.md)。 ### AI Agent 开发岗位(按出现频率从高到低) @@ -136,8 +159,8 @@ stack: [] | Prompt / Context Engineering、Agent 评测 | 第 5 周(提示词、上下文拼接)、第 6 周(RAG 评测)、第 11 周(调提示词)、第 13 周(Agent 评测、可观测性) | | 工作流编排、Memory、多 Agent(加分项) | 第 11 周(编排)、第 6 周(短期记忆)、第 12 周(长期记忆、持久化、人机协作) | | Docker、Linux、Git | 第 3 周(Docker、Compose、Nginx、CI),第 6、13、14 周部署;Git 全程 | -| AI 编程工具(Cursor / Claude Code) | 全程使用;第 10 周把自己的 MCP 服务接进 Cursor | -| Go / Java(加分项) | 第 1~3 周 Go;第 14 周可选 Go 网关 | +| AI 编程工具(Cursor / Claude Code / Codex) | 全程使用并记录;第 10 周把自己的 MCP 服务接进 Cursor;第 15 周整理“AI 编程 + 验证”案例;专题 [ai-coding](../topics/ai/ai-coding/index.md) | +| Go / Java(加分项) | 第 1~3 周 Go;第 14 周可选 Go 网关;Java 只做了解([java-spring 专题](../topics/backend/java-spring/index.md)) | ### Go 全栈岗位 @@ -167,13 +190,26 @@ stack: [] | Redis | 五种数据结构和用途、缓存模式(旁路缓存、穿透 / 击穿 / 雪崩)、分布式锁(SET NX PX + Lua 释放)、限流、Streams 消费组 | 第 3 周、第 6 周、第 14 周 | | MySQL | 索引(B+ 树、联合索引最左前缀、覆盖索引)、EXPLAIN、事务和隔离级别、MVCC、锁、与 PostgreSQL 的差异 | 第 2 周、第 14 周 | | 微服务 / 消息队列 | RabbitMQ(交换机、队列、确认、死信)、Kafka(主题、分区、消费组、位移)概念、幂等消费、gRPC 基础(proto、一元调用、流式) | 第 3 周、第 14 周 | +| AI 编程工具(BOSS 16 个岗位) | Claude Code / Cursor / Codex 的工作流(先计划后执行、小步提交)、编写 Skills / 规则文件沉淀团队约定、对 AI 产出的验证(测试、代码审查、真实环境验证)、保留会话记录作为作品证据 | 全程;第 10、15 周;专题 [ai-coding](../topics/ai/ai-coding/index.md) | +| Agent 工程化(BOSS 22 个岗位) | ReAct、规划(Plan-and-Execute)、上下文工程、Skills、多 Agent 和 A2A、Agentic RAG、评测集、幻觉防护和质量门禁、Bad Case 分析、成本和延迟、可观测性 | 第 10~13 周;专题 [agent-design](../topics/ai/agent-design/index.md)、[agent-ops](../topics/ai/agent-ops/index.md) | +| AI 原生前后端 | 流式输出(SSE)、结构化输出(JSON Schema / Pydantic)、任务状态(排队 / 运行 / 等待人工 / 完成 / 失败)、异常恢复(检查点续跑、重试、降级) | 第 5、12 周;专题 [fastapi](../topics/python/fastapi/index.md)、[langgraph](../topics/ai/langgraph/index.md) | +| RAG 全链路 | 文档解析(PDF 表格、OCR)、切分、混合检索(向量 + Elasticsearch BM25)、重排、权限过滤、评测 | 第 5~6 周;专题 [rag](../topics/ai/rag/index.md)、[elasticsearch](../topics/backend/elasticsearch/index.md) | +| 模型侧(了解级) | 多模型 API 接入和路由、降级,vLLM 部署基础(OpenAI 兼容接口),LoRA / QLoRA 是什么、什么时候需要微调 | 第 10、13 周;专题 [llm-serving](../topics/ai/llm-serving/index.md) | +| 数据管道 / 第三方接口 | 批处理和流处理、失败重试、幂等、数据质量校验和告警、第三方 API 限流和字段变化 | 第 14 周;专题 [data-pipeline](../topics/backend/data-pipeline/index.md) | +| MongoDB / Elasticsearch / K8s | MongoDB 文档模型和索引;ES 倒排索引、中文分词、和数据库同步;K8s 的 Pod / Deployment / Service / 探针 / 滚动发布(基础,不深入运维) | 第 6、14 周;专题 [mongodb](../topics/backend/mongodb/index.md)、[elasticsearch](../topics/backend/elasticsearch/index.md)、[k8s](../topics/devops/k8s/index.md) | +| Next.js / Node.js | Next.js App Router、服务端组件、路由处理器做 AI 流式接口;Node.js 的 Express 中间件、NestJS 模块和依赖注入(了解级) | 第 7、14 周;专题 [ssr](../topics/frontend/ssr/index.md)、[nodejs](../topics/backend/nodejs/index.md) | +| Java(全栈岗位常见) | 了解级:能读懂 Spring Boot 的分层(Controller / Service / Repository)和常用注解,能说清和 Go / Python 的差异;不作为主线 | 第 14 周;专题 [java-spring](../topics/backend/java-spring/index.md) | +| 计算机基础 | 面试常见的数据结构和算法题、HTTP / TCP、操作系统(进程线程、内存、I/O)、Web 安全(XSS、CSRF、SQL 注入、CORS) | 第 14、16 周,平时每天一道题;专题 [algorithms](../topics/cs/algorithms/index.md)、[network-os](../topics/cs/network-os/index.md)、[web-security](../topics/cs/web-security/index.md) | ### 对学习重点的影响 -1. Python 是主语言,Go 是加分项;Go 只用 3 周,做到“能做出一个带数据库、鉴权、缓存、队列、部署的完整项目”为止。 -2. RAG 和 LangChain / LangGraph 是硬要求,单独做成项目二和项目三。 +1. Python 是主语言,Go 是加分项;Go 只用 3 周(BOSS 34 个岗位里 Go 硬性要求只有 2 个,其他多是“任选其一”),做到“能做出一个带数据库、鉴权、缓存、队列、部署的完整项目”为止。 +2. RAG、Agent、LangChain / LangGraph 是硬要求,单独做成项目二和项目三,并补上 Agent 工程化(评测、幻觉防护、Bad Case、成本延迟、可观测性)。 3. 工具调用、MCP、评测、记忆这些“新要求”都要有能演示的代码,而不是只看过文章。 -4. 前端是差异化优势,第 7 周把工程化短板补齐,两个项目都有像样的界面。 +4. **AI 编程能力要拿证据**:全程用 AI 编程工具,但每个功能都要有验证方法(测试、审查、真实运行),保留会话记录,第 15 周整理成“AI 编程案例”。 +5. 前端是差异化优势,第 7 周把工程化短板补齐(加 Next.js),两个项目都有像样的界面。 +6. MongoDB、Elasticsearch、K8s、Node.js、Java、模型侧只做到“了解级、能回答基础面试题”,不另开项目,避免偏离主线。 +7. 计算机基础(算法、网络、操作系统、Web 安全)平时每天少量练习,第 16 周集中复习。 --- diff --git a/research/boss-2026-10-23.md b/research/boss-2026-10-23.md new file mode 100644 index 0000000..f2aeb7c --- /dev/null +++ b/research/boss-2026-10-23.md @@ -0,0 +1,49 @@ +--- +id: research.boss-2026-10-23 +title: BOSS直聘北京岗位调研(2026-10-23) +type: research +domain: plan +stack: [] +tags: [岗位调研, BOSS直聘, 北京, AI 应用, Agent, 全栈] +--- + +> 说明:登录后只读浏览,人工整理。样本约 34 个有效岗位,只代表这一次看到的情况,不代表整个市场;数字是“大约出现了多少个岗位”,不是精确统计。薪资因页面字体混淆没有读到。和第一次(2026-10-22,智联招聘约 17 个岗位)的对照及对计划的影响见 [plan/README.md 第 4 节](../plan/README.md)。 + +## 原始记录:BOSS直聘 北京岗位调研(2026-10-23,登录后只读) +查询:全栈工程师 Go / AI应用开发 / Agent开发 / 大模型应用 全栈。共读 37 个,去掉校招、重复和转发后约 34 个有效。薪资被字体混淆,读不到。 +明确写统招的只有 2 个(都是硕士/985 方向的视频算法岗),其余基本是本科。 + +## 技术出现次数(约数) +Agent/智能体 22;Python 21;RAG 17;AI 编程工具(Claude Code/Cursor/Codex/Vibe coding)16;Vue/React 14;Prompt 工程 13;Java 12;Go 10(硬性要求仅 2 个:银河智学 Gin/Kratos/Go-zero,华顺信安 goroutine/channel/锁);MySQL 10;TS/JS 10;Redis 9;LangChain/LangGraph/AutoGen/LlamaIndex 9;MCP/Function Calling/Tool Calling 9;Docker 7;向量库 Milvus/FAISS/Chroma 7;LoRA/微调 7;PostgreSQL 5;Kafka/MQ 5;CI/CD 5;FastAPI 4;MongoDB 4;Node.js 4;K8s 3。 + +## 反复出现、计划里要补强的要求 +1. AI 编程工具深度使用 + 对 AI 产出的复核验证(测试、代码审查、真实环境验证),不少岗位要求附「用 AI 做过的项目 + 怎么确认它有效」,有的要求提交会话记录;"编写 Skills、沉淀开发工作流"。 +2. Agent 工程:任务规划(Planning)、工具调用、Memory、Context 管理/Context Engineering、Skills 体系、多 Agent 编排、ReAct、MCP、A2A、Agentic RAG、Workflow 编排、Agent 评测与可靠性(自动化评测、幻觉拦截、质量门禁)、Bad Case 分析、成本/延迟优化、可观测性、权限认证与准入。 +3. AI 原生应用前后端:流式输出、结构化输出、任务执行、任务状态与异常恢复、工具调用的交互展示(和用户现有经验高度吻合,要作为卖点写进作品集)。 +4. RAG 全链路:文档解析、切片、向量化、检索、重排序、混合检索(含 Elasticsearch)、效果基线与评测。 +5. 模型侧(了解级即可):主流大模型 API(OpenAI/Claude/DeepSeek/通义/豆包)对接与调度、本地部署 vLLM/SGLang/TGI、LoRA/QLoRA 微调流程、Embedding。 +6. 后端通用:API 设计、数据建模、索引与 SQL 优化、事务、缓存、消息队列、异步任务、失败重试、幂等、限流、数据一致性;数据采集清洗、批处理/流处理、数据质量监控与告警;第三方 API 对接(限流、字段变化、重试)。 +7. 基础:数据结构与算法、HTTP/HTTPS、TCP/IP、操作系统、Linux/Shell、Web 安全(XSS、CSRF、SQL 注入、CORS)。 +8. 中间件/部署:MongoDB、Elasticsearch、Kafka、Docker、K8s 基础、CI/CD、云服务基础、日志监控与回滚流程。 +9. 前端:Vue3+TS、React、Next.js、组件库、Monorepo、性能优化、监控;H5/小程序加分;Node.js(Express/NestJS)作为后端选项出现。 +10. 语言:Python 是 Agent 岗主语言;Java 在全栈岗里出现多(Spring Boot),Go 多为"任选其一"。 +11. 应聘材料:1-2 个代表项目(产品链接/仓库/截图/演示视频)、独立负责范围、从问题定义到上线迭代的完整案例、AI 编程开发案例和质量控制方式;开源/技术博客加分。 +12. 软能力:直接对接客户需求、To B 交付节奏与验收、产品意识、在交付速度/质量/复杂度之间取舍。 + +## 代表岗位(职位 | 公司 | 经验 | URL 前缀 https://www.zhipin.com) +- 全栈开发工程师 | 百图生科 | 经验不限 | /job_detail/b36f1dcf0fdb6aeb0nN72ty8ElZX.html +- 全栈研发工程师(Go+React) | 银河智学 | 5-10年 | /job_detail/3785eaec03d601770nFz2Ny-GVpV.html +- 全栈工程师(AI方向) | 绝尘教育 | 1-3年 | /job_detail/5bf1a6e829a777a20nZy2dS4F1VV.html +- 全栈开发工程师(偏前端,Vue3+TS) | 花径教育 | 3-5年 | /job_detail/56652c8f4dd3db1c0nx53d-5GFRT.html +- AI应用全栈工程师 | 某人工智能公司(猎头) | 经验不限 | /job_detail/45e375af4dd645b10nx72N61E1NV.html +- 全栈开发工程师 | 恺望数据 | 经验不限 | /job_detail/1ab6eceb5d99a18f0nNz09y5EFpW.html +- 全栈工程师 | 懂车帝 | 经验不限 | /job_detail/597d18e439600d1c0nx50tq-E1BR.html +- AI应用开发 | 某上市公司(猎头 科锐国际) | 3-5年 | /job_detail/a445ec27c4ac26c40nN70tu6EFRY.html +- ai应用开发工程师 | 创想智控 | 3-5年 | /job_detail/4ba3a3c5f62ac57d0nB53Nu5ElNW.html +- AI应用开发(LLM&Agent) | BOSS直聘 | 1-3年 | /job_detail/83dab0fe4dbc53420nBy2tW-FltX.html +- AI Agent 应用开发工程师 | 豆神教育 | 3-5年 | /job_detail/fa7108ed417a58c10nJ-0969FFVY.html +- Agent开发(go方向) | 华顺信安 | 1-3年 | /job_detail/eecf379ac433f9e80nN809u5EFVV.html +- AI Agent 全栈开发工程师(Python+LangGraph+FastAPI) | 出门问问 | 3-5年 | /job_detail/6f058d163b4fb2ed0nN709S7GFdT.html +- 大模型应用开发工程师 | 某量化私募(猎头) | 经验不限 | /job_detail/db50e9d09105d3490nx50t61E1JU.html +- AI工程师(大模型应用) | 实战云 | 3-5年 | /job_detail/e1709cbf9930330a0nZ92dW1GFtV.html +- 全栈工程师(AI 应用方向) | 沧蓝科技 | 3-5年 | /job_detail/ac9017d6159f5b400nN62ti8FldW.html diff --git a/research/index.md b/research/index.md new file mode 100644 index 0000000..79d784b --- /dev/null +++ b/research/index.md @@ -0,0 +1,18 @@ +--- +id: research.index +title: 岗位调研记录 +type: index +domain: plan +stack: [] +--- + +# 岗位调研记录 + +每次调研单独一个文件,只记录实际看到的内容,不做推测性统计。调研结论如何影响学习计划,写在 [plan/README.md 第 4 节](../plan/README.md)。 + +| 日期 | 来源 | 样本 | 文件 | +| --- | --- | --- | --- | +| 2026-10-22 | 智联招聘(“AI Agent开发”“Go全栈”) | 约 17 个岗位 | 结论已写入 [plan/README.md 第 4 节](../plan/README.md) | +| 2026-10-23 | BOSS直聘北京(全栈 Go / AI 应用 / Agent / 大模型应用全栈) | 约 34 个有效岗位 | [boss-2026-10-23.md](boss-2026-10-23.md) | + +建议每月重新调研一次,新建一个文件并更新计划第 4 节的对照表。 diff --git a/schema/frontmatter.schema.json b/schema/frontmatter.schema.json index 51be866..5dc5837 100644 --- a/schema/frontmatter.schema.json +++ b/schema/frontmatter.schema.json @@ -8,8 +8,8 @@ "properties": { "id": {"type": "string", "pattern": "^[a-z0-9]+(\\.[a-z0-9-]+)+$", "description": "全局唯一、稳定不变的内容 ID"}, "title": {"type": "string"}, - "type": {"enum": ["index", "lesson", "scenario", "interview", "project", "week"]}, - "domain": {"enum": ["backend", "devops", "python", "ai", "frontend", "cross", "plan"]}, + "type": {"enum": ["index", "lesson", "scenario", "interview", "project", "week", "research"]}, + "domain": {"enum": ["backend", "devops", "python", "ai", "frontend", "cs", "cross", "plan"]}, "stack": {"type": "array", "items": {"type": "string"}, "description": "技术栈标识,与 topics/// 目录名一致"}, "tags": {"type": "array", "items": {"type": "string"}}, "level": {"enum": ["basic", "intermediate", "advanced"]}, diff --git a/tools/build_catalog.py b/tools/build_catalog.py index 98df416..112831d 100644 --- a/tools/build_catalog.py +++ b/tools/build_catalog.py @@ -17,11 +17,11 @@ import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -CONTENT_DIRS = ["plan", "topics", "scenarios"] +CONTENT_DIRS = ["plan", "topics", "scenarios", "research"] SCHEMA_VERSION = 1 -TYPES = {"index", "lesson", "scenario", "interview", "project", "week"} -DOMAINS = {"backend", "devops", "python", "ai", "frontend", "cross", "plan"} +TYPES = {"index", "lesson", "scenario", "interview", "project", "week", "research"} +DOMAINS = {"backend", "devops", "python", "ai", "frontend", "cs", "cross", "plan"} LEVELS = {"basic", "intermediate", "advanced"} QUESTION_LEVELS = ["基础", "进阶", "场景题"] ID_RE = re.compile(r"^[a-z0-9]+(?:\.[a-z0-9-]+)+$") @@ -79,11 +79,12 @@ def parse_frontmatter(text: str) -> tuple[dict | None, str, list[str]]: return meta, text[end + 5:], errs -def scan_body(body: str) -> tuple[bool, list[str], list[str], dict[str, int]]: - """返回:围栏是否闭合、二级标题、链接目标、按级别统计的面试题数。""" +def scan_body(body: str) -> tuple[bool, list[str], list[str], dict[str, int], list[str]]: + """返回:围栏是否闭合、二级标题、链接目标、按级别统计的面试题数、缺少参考答案或追问的题目。""" in_fence, fence_mark = False, "" headings, links = [], [] counts = {k: 0 for k in QUESTION_LEVELS} + questions: list[list] = [] # [标题, 有参考答案, 有追问] for line in body.splitlines(): m = FENCE_RE.match(line) if m: @@ -99,8 +100,17 @@ def scan_body(body: str) -> tuple[bool, list[str], list[str], dict[str, int]]: q = QUESTION_RE.match(line) if q: counts[q.group(1)] += 1 + questions.append([line[4:].strip(), False, False]) + elif line.startswith("#"): + if questions and line.startswith(("# ", "## ", "### ")): + questions.append([None, True, True]) # 题目之后的其他标题,结束上一题 + elif questions: + questions[-1][1] |= line.startswith("**参考答案**") + questions[-1][2] |= line.startswith("**追问**") links += LINK_RE.findall(line) - return not in_fence, headings, links, counts + incomplete = [f"{t}(缺少{'“**参考答案**”' if not a else ''}{'“**追问**”' if not f else ''})" + for t, a, f in questions if t and not (a and f)] + return not in_fence, headings, links, counts, incomplete def main() -> int: @@ -136,7 +146,9 @@ def main() -> int: for w in meta.get("week", []) if isinstance(meta.get("week"), list) else []: if not isinstance(w, int) or not 1 <= w <= 16: errors.append(f"{rel}: week 取值 {w!r} 必须是 1~16 的整数") - closed, headings, links, counts = scan_body(body) + closed, headings, links, counts, incomplete = scan_body(body) + if t == "interview": + errors += [f"{rel}: 面试题 {q}" for q in incomplete] if not closed: errors.append(f"{rel}: 代码块围栏没有闭合") for sec in REQUIRED_SECTIONS.get(t, []): diff --git a/topics/ai/agent-design/index.md b/topics/ai/agent-design/index.md new file mode 100644 index 0000000..ebf3df1 --- /dev/null +++ b/topics/ai/agent-design/index.md @@ -0,0 +1,15 @@ +--- +id: ai.agent-design.index +title: Agent 设计:规划、上下文工程与多 Agent +type: index +domain: ai +stack: [agent-design] +tags: [] +week: [10, 11, 13] +--- + +# Agent 设计:规划、上下文工程与多 Agent + +ReAct、Planning、Context Engineering、Skills、多 Agent 与 A2A、Agentic RAG。 + +> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。 diff --git a/topics/ai/ai-coding/index.md b/topics/ai/ai-coding/index.md new file mode 100644 index 0000000..3d07ffe --- /dev/null +++ b/topics/ai/ai-coding/index.md @@ -0,0 +1,15 @@ +--- +id: ai.ai-coding.index +title: AI 编程工具与产出验证 +type: index +domain: ai +stack: [ai-coding] +tags: [] +week: [1, 8, 10, 15] +--- + +# AI 编程工具与产出验证 + +Claude Code / Cursor / Codex 工作流、Skills 与规则、评审和测试验证、会话记录作为作品证据。 + +> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。 diff --git a/topics/ai/llm-serving/index.md b/topics/ai/llm-serving/index.md new file mode 100644 index 0000000..1771501 --- /dev/null +++ b/topics/ai/llm-serving/index.md @@ -0,0 +1,15 @@ +--- +id: ai.llm-serving.index +title: 模型接入与部署(了解级) +type: index +domain: ai +stack: [llm-serving] +tags: [] +week: [10, 13] +--- + +# 模型接入与部署(了解级) + +多模型 API 路由与降级、vLLM 部署基础、LoRA / QLoRA 概念。 + +> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。 diff --git a/topics/ai/rag/01-pipeline-chunking.md b/topics/ai/rag/01-pipeline-chunking.md new file mode 100644 index 0000000..dd39d0e --- /dev/null +++ b/topics/ai/rag/01-pipeline-chunking.md @@ -0,0 +1,114 @@ +--- +id: ai.rag.pipeline-chunking +title: RAG 流水线和文档切分:加载、清洗、切分策略、元数据 +type: lesson +domain: ai +stack: [rag] +tags: [RAG, 文档加载, 切分, chunk, 元数据, 嵌入] +level: basic +prerequisites: [python.python.syntax-for-js] +est_hours: 3 +related: [ai.rag.hybrid-rerank, ai.vector-db.ann-metrics, ai.rag.interview] +week: [5] +order: 1 +--- + +# RAG 流水线和文档切分:加载、清洗、切分策略、元数据 + +## 解释说明 + +**为什么需要 RAG**:大模型不知道公司内部资料,也不知道训练截止之后的信息;直接问会“编”(幻觉)。RAG 把相关资料放进提示词,让模型“开卷答题”,并且能给出引用来源。和微调相比:知识更新只需要更新索引,成本低、可追溯。 + +**两条流水线** + +```mermaid +flowchart LR + subgraph 离线:建索引 + A[加载文档 PDF/Word/网页] --> B[清洗] --> C[切分 chunk] --> D[嵌入向量] --> E[(向量库 + 元数据)] + end + subgraph 在线:问答 + Q[用户问题] --> R[检索 top-k] --> F[权限过滤 / 重排] --> P[拼提示词] --> L[大模型生成] --> O[答案 + 引用] + end + E --> R +``` + +**加载和清洗**:PDF 的页眉页脚、目录、页码、表格是最常见的噪音;扫描件需要 OCR。清洗质量直接决定检索质量。 + +**切分(chunking)**——RAG 效果最关键的环节之一: + +| 策略 | 做法 | 适合 | +| --- | --- | --- | +| 固定长度 + 重叠 | 每 N 个字符 / token 一块,相邻块重叠一部分 | 没有结构的长文本 | +| 按结构切分 | 按标题、段落、列表切,节太长再细分 | 制度、手册、Markdown、网页(**首选**) | +| 按句子 / 语义切分 | 句子为单位,按语义相似度合并 | 长篇叙述 | +| 父子块 | 用小块检索(精准),返回所在的大块给模型(上下文完整) | 问答需要上下文的场景 | + +块的大小:太小(一句话)上下文不完整,“满十年 10 天”不知道说的是年假还是病假;太大则包含多个主题,检索不精准、浪费 token。中文制度类文档常见几百字一块,需要用评测来调。 + +**元数据**:每个块带上文档 ID、标题、小节路径、页码、更新时间、权限标签。用途:引用来源、按权限过滤、按时间过滤、增量更新时删除旧块。**把标题路径拼进块的正文**(“员工手册 / 年假:……”)能明显改善孤立块的检索。 + +**嵌入(embedding)**:把文本变成向量,语义相近的文本向量距离近。选择嵌入模型要看:中文效果、向量维度、最大输入长度、价格 / 是否能私有部署。**建索引和查询必须用同一个模型**,换模型要全量重建索引。具体模型和参数以各服务商官方文档为准。 + +## 案例 + +公司制度问答第一版:把 PDF 直接按 500 字切分,结果“病假”一节被切成两半,问“病假工资怎么发”检索到的块只有前半段;页眉“内部资料 第 3 页”混进每个块。改进:先转 Markdown 去掉页眉页脚,按标题切分,每块带“文档 / 小节”前缀,节太长再按句子切并保留重叠。 + +## 代码示例 + +按标题切分 + 句子级细分 + 重叠 + 标题前缀(摘自 [examples/mini-rag/mini_rag.py](examples/mini-rag/mini_rag.py)): + +```python +import re +from dataclasses import dataclass + + +@dataclass +class Chunk: + id: str + doc_id: str + text: str + acl: set[str] + + +def split(doc_id: str, title: str, text: str, acl: set[str], max_len: int = 80, overlap: int = 15) -> list[Chunk]: + out: list[Chunk] = [] + for sec in re.split(r"\n(?=# )", text.strip()): # 按一级标题分节 + heading, _, body = sec.partition("\n") + heading = heading.lstrip("# ").strip() + sentences = [s for s in re.split(r"(?<=[。!?])", body.strip()) if s] + buf, pieces = "", [] + for s in sentences: # 节内按句子累积,超长就切 + if buf and len(buf) + len(s) > max_len: + pieces.append(buf) + buf = buf[-overlap:] # 保留重叠,避免句子间信息断开 + buf += s + if buf: + pieces.append(buf) + for p in pieces: + out.append(Chunk(f"{doc_id}#{len(out)}", doc_id, f"【{title} / {heading}】{p}", acl)) + return out + + +doc = """# 年假 +入职满一年的正式员工享有带薪年假 5 天,满十年 10 天,满二十年 15 天。年假需提前 3 个工作日在 OA 中申请。 +# 病假 +病假需提供医院证明,每年累计 10 天以内按全额工资发放。""" +for c in split("hr-leave", "员工手册", doc, {"all"}, max_len=40, overlap=8): + print(c.id, c.text) +``` + +输出中每一块都带有“【员工手册 / 年假】”这样的前缀,即使块本身只有“满十年 10 天”,也能被“年假”相关的问题检索到。 + +## 易错点 + +- **不看切分结果就建索引**:一定要抽样打印切分后的块,检查是否切断了表格、步骤、条款。 +- **按字符数重叠会截断词语**:上面的输出里第二块以“十年 15 天”开头(原文是“满二十年 15 天”)。实际项目更常用“重叠最后一两句”而不是固定字符数。 +- **只用固定长度切分**:结构化文档的标题信息丢失。 +- **块没有元数据**:无法引用来源、无法做权限过滤、更新文档时删不掉旧块。 +- **建索引和查询用了不同的嵌入模型**(或同一模型不同版本):向量空间不一致,检索结果混乱。 +- **文档更新后只追加不删除**:新旧版本同时被检索到,答案自相矛盾。用文档 ID 先删后加。 +- **把整篇文档塞进提示词**:成本高、模型容易忽略中间内容,而且受上下文长度限制。 + +## 面试常问 + +见 [RAG 面试题](interview.md):RAG 和微调的区别、切分策略怎么选、chunk 大小怎么定、元数据有什么用。 diff --git a/topics/ai/rag/02-hybrid-rerank.md b/topics/ai/rag/02-hybrid-rerank.md new file mode 100644 index 0000000..e93a9c7 --- /dev/null +++ b/topics/ai/rag/02-hybrid-rerank.md @@ -0,0 +1,94 @@ +--- +id: ai.rag.hybrid-rerank +title: 检索优化:向量 + 关键词混合检索、RRF 融合、重排、查询改写 +type: lesson +domain: ai +stack: [rag] +tags: [混合检索, BM25, RRF, 重排, rerank, 查询改写] +level: intermediate +prerequisites: [ai.rag.pipeline-chunking] +est_hours: 3 +related: [ai.rag.generation-eval, ai.vector-db.ann-metrics, ai.rag.interview] +week: [6] +order: 2 +--- + +# 检索优化:向量 + 关键词混合检索、RRF 融合、重排、查询改写 + +## 解释说明 + +**向量检索和关键词检索各有短板** + +| | 向量检索 | 关键词检索(BM25) | +| --- | --- | --- | +| 擅长 | 语义相近、换种说法(“带薪假” ↔ “年假”) | 精确词:产品型号、错误码、人名、条款编号 | +| 不擅长 | 精确的编号、罕见专有名词 | 同义词、口语化的问题 | + +所以生产环境常用**混合检索**:两路各取 top-N,再融合。 + +**融合:RRF(Reciprocal Rank Fusion)**:`score(d) = Σ 1 / (k + rank_i(d))`,`k` 常取 60。只用排名、不用原始分数,不需要处理两种分数的量纲差异,简单稳健。也可以按权重加权归一化后的分数。 + +**中文关键词检索要先分词**(jieba 等),否则 BM25 按单字或整句计算,效果很差。Elasticsearch / OpenSearch 需要安装中文分词插件;PostgreSQL 可以用 `pg_trgm` 或中文分词扩展。 + +**重排(rerank)**:第一阶段召回 20~50 个候选,用交叉编码器(cross-encoder)重排模型对“问题 + 候选块”逐对打分,取前 3~5 个给大模型。重排更准但更慢,只对少量候选使用。重排模型可以用云服务或私有部署的开源模型,以对应官方文档为准。 + +**查询改写** + +- 多轮对话中,“那病假呢?”要结合历史改写成独立问题“病假怎么规定的?”再检索; +- 把一个复杂问题拆成多个子问题分别检索; +- 生成假设答案再用它去检索(HyDE)。 +改写会多一次模型调用,增加延迟和成本,按需使用。 + +**元数据过滤**:按权限、文档类型、时间等过滤,**在检索阶段**完成(向量库的 filter 或预过滤),见[权限场景](scenarios/permission-aware-kb.md)。 + +## 案例 + +IT 知识库:用户问“报错 0x80070005 怎么办”,纯向量检索返回的都是“权限问题”的泛泛文章,没有命中包含这个错误码的那篇;加入 BM25 后,精确匹配错误码的文档排第一。反过来,“在家怎么连公司网络”在 BM25 下命中很差(文档里写的是“VPN 远程办公”),向量检索能找到。混合之后两类问题都能覆盖。 + +## 代码示例 + +RRF 融合(摘自 [examples/mini-rag](examples/mini-rag/),示例中还包含向量检索和 BM25 检索的实现): + +```python +def rrf(result_lists: list[list[str]], k: int = 60, top: int = 5) -> list[tuple[str, float]]: + """result_lists:每一路检索按相关度排好序的文档 ID 列表。""" + scores: dict[str, float] = {} + for results in result_lists: + for rank, doc_id in enumerate(results, start=1): + scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank) + return sorted(scores.items(), key=lambda kv: -kv[1])[:top] + + +vector_hits = ["vpn-guide", "permission-faq", "printer"] # 语义检索结果 +bm25_hits = ["error-0x80070005", "vpn-guide", "office-install"] # 关键词检索结果 +for doc_id, score in rrf([vector_hits, bm25_hits]): + print(f"{doc_id:18s} {score:.4f}") +# vpn-guide 两路都排得靠前,融合后排第一;只出现在一路的文档按名次排在后面 +``` + +中文 BM25(`rank_bm25` + `jieba`): + +```python +import logging + +import jieba +from rank_bm25 import BM25Okapi + +jieba.setLogLevel(logging.WARNING) +docs = ["Windows 更新报错 0x80070005 的处理方法", "VPN 远程办公连接指南", "打印机共享设置"] +bm25 = BM25Okapi([jieba.lcut(d) for d in docs]) +print(bm25.get_scores(jieba.lcut("报错 0x80070005 怎么办")).round(2)) # 第一篇得分最高 +``` + +## 易错点 + +- **只做向量检索**:错误码、型号、人名这类精确查询效果差。 +- **中文 BM25 不分词**:分数几乎没有意义。 +- **直接把两路的原始分数相加**:余弦相似度在 0~1,BM25 可能是 0~20,量纲不同。用 RRF 或先归一化。 +- **重排所有候选**:重排模型慢,只对召回的前几十个使用。 +- **top-k 设得很大以为更保险**:无关内容增多,模型更容易被干扰,成本也更高。 +- **多轮对话直接用最后一句检索**:“那病假呢”检索不到东西,需要改写。 + +## 面试常问 + +见 [RAG 面试题](interview.md):为什么要混合检索、RRF 原理、重排的作用、多轮对话怎么检索。 diff --git a/topics/ai/rag/03-generation-eval.md b/topics/ai/rag/03-generation-eval.md new file mode 100644 index 0000000..7ea2cc4 --- /dev/null +++ b/topics/ai/rag/03-generation-eval.md @@ -0,0 +1,109 @@ +--- +id: ai.rag.generation-eval +title: 生成与评测:带引用的提示词、拒答、检索评测和答案评测 +type: lesson +domain: ai +stack: [rag] +tags: [提示词, 引用, 拒答, 幻觉, Recall@k, MRR, 评测集] +level: intermediate +prerequisites: [ai.rag.hybrid-rerank] +est_hours: 3 +related: [ai.rag.scenario.permission-aware-kb, ai.prompt-engineering.index, ai.agent-ops.index, ai.rag.interview] +week: [6] +order: 3 +--- + +# 生成与评测:带引用的提示词、拒答、检索评测和答案评测 + +## 解释说明 + +**提示词的关键要素** + +1. 角色和任务:“你是公司制度问答助手”; +2. **只依据资料回答**,资料中没有就明确说没有(减少幻觉); +3. 资料编号 + 要求用 `[编号]` 标注依据(可追溯、可校验); +4. 输出格式和长度要求; +5. 资料放在问题前面,并用清晰的分隔符包起来。 + +**拒答**:检索结果的最高相似度低于阈值、或者重排分数太低时,不调用大模型,直接回答“知识库中没有找到相关内容”,并引导到人工渠道。宁可不答,不要编。阈值需要用评测数据调。 + +**引用校验**:检查答案中的 `[编号]` 是否都在给出的资料范围内;可以进一步检查被引用的资料里是否包含答案中的关键数字。 + +**评测**——没有评测就无法判断改动是变好还是变坏: + +| 层面 | 指标 | 怎么做 | +| --- | --- | --- | +| 检索 | Recall@k(正确资料是否在前 k 个里)、MRR(正确资料的排名倒数的平均值) | 人工标注“问题 → 应命中的文档 / 块”,几十到几百条 | +| 生成 | 忠实度(答案是否都有资料支撑)、相关性、正确性 | 人工抽查 + 用大模型按评分标准打分(LLM-as-judge) | +| 线上 | 点赞 / 点踩、转人工率、无答案率 | 埋点、定期分析差评样本 | + +评测集要覆盖:常见问题、需要精确匹配的问题(编号、数字)、多轮追问、**知识库里没有答案的问题**(测试拒答)、无权限的问题。 + +工具:Ragas 等框架提供了忠实度、上下文召回等指标的实现,接口变化较快,以官方文档为准(https://docs.ragas.io/)。 + +## 案例 + +制度问答助手上线前建立了 9 条以上的检索评测集([examples/mini-rag](examples/mini-rag/) 中的 `EVAL`,真实项目要几十到上百条),比较纯向量、纯 BM25、混合三种检索的 Recall@3 和 MRR;之后每次调整切分或检索参数都跑一遍,指标下降就不合并。 + +## 代码示例 + +检索评测(Recall@k、MRR): + +```python +def recall_and_mrr(results: dict[str, list[str]], expected: dict[str, str], k: int = 3) -> tuple[float, float]: + """results:问题 → 检索出的文档 ID(已排序);expected:问题 → 应命中的文档 ID。""" + hit, rr = 0, 0.0 + for q, gold in expected.items(): + top = results[q][:k] + if gold in top: + hit += 1 + rr += 1 / (top.index(gold) + 1) + n = len(expected) + return hit / n, rr / n + + +results = {"年假几天": ["hr-leave", "fin"], "VPN 连不上": ["hr-leave", "it-vpn"], "报销时限": ["it-vpn", "x", "y"]} +expected = {"年假几天": "hr-leave", "VPN 连不上": "it-vpn", "报销时限": "fin"} +print(recall_and_mrr(results, expected)) # (0.666..., 0.5):第 1 题排第 1,第 2 题排第 2,第 3 题没命中 +``` + +带引用的提示词和拒答、引用校验: + +```python +import re + + +def build_prompt(question: str, chunks: list[str]) -> str: + context = "\n".join(f"[{i}] {c}" for i, c in enumerate(chunks, start=1)) + return ("你是公司制度问答助手。只根据<资料>回答;资料中没有的内容,回答“资料中没有相关规定”。" + "每个结论后用 [编号] 标注依据。\n" + f"<资料>\n{context}\n\n问题:{question}") + + +def answer_or_refuse(question: str, hits: list[tuple[str, float]], min_score: float = 0.3) -> str: + if not hits or hits[0][1] < min_score: + return "知识库中没有找到相关内容,建议咨询 HR 或提交工单。" + return build_prompt(question, [text for text, _ in hits]) # 交给大模型 + + +def citations_valid(answer: str, n_chunks: int) -> bool: + cited = {int(x) for x in re.findall(r"\[(\d+)\]", answer)} + return bool(cited) and cited <= set(range(1, n_chunks + 1)) + + +print(answer_or_refuse("股票期权怎么行权", [("差旅住宿标准……", 0.12)])) +print(citations_valid("满十年年假 10 天 [1]。", 3), citations_valid("答案 [5]", 3)) # True False +``` + +## 易错点 + +- **没有评测集,靠几个问题“感觉效果不错”**:改了参数也不知道变好还是变坏。 +- **评测集里没有“无答案”的问题**:拒答能力从未被测试。 +- **拒答阈值拍脑袋**:要用评测数据画出“阈值 vs 准确率 / 拒答率”来选。 +- **完全依赖 LLM 打分**:评分模型本身有偏差,要和人工标注对比校准。 +- **提示词里资料和指令混在一起**:资料里的内容可能被模型当成指令(提示词注入),用分隔符包裹并在指令中说明。 +- **引用编号不校验**:模型可能引用不存在的编号,或者引用了但内容对不上。 + +## 面试常问 + +见 [RAG 面试题](interview.md):怎么减少幻觉、怎么评测 RAG、拒答怎么做、线上效果怎么监控。 diff --git a/topics/ai/rag/examples/mini-rag/mini_rag.py b/topics/ai/rag/examples/mini-rag/mini_rag.py new file mode 100644 index 0000000..b31b93b --- /dev/null +++ b/topics/ai/rag/examples/mini-rag/mini_rag.py @@ -0,0 +1,205 @@ +# requires: jieba, rank_bm25, numpy +"""最小 RAG 流水线:切分 → 嵌入 → 混合检索(向量 + BM25,RRF 融合)→ 权限过滤 → 拒答阈值 → 带引用的提示词 → 检索评测。 + +为了不依赖外部服务,这里的“嵌入”用字符 n-gram 哈希向量代替真实的嵌入模型(只能捕捉字面相似,不懂语义), +目的是把流程跑通、把评测方法讲清楚。换成真实嵌入模型时,只需要替换 embed() 函数。 +运行:python mini_rag.py +""" +from __future__ import annotations + +import hashlib +import logging +import re +from dataclasses import dataclass, field + +import jieba +import numpy as np +from rank_bm25 import BM25Okapi + +jieba.setLogLevel(logging.WARNING) + +DIM = 512 + + +@dataclass +class Doc: + id: str + title: str + text: str + acl: set[str] # 允许访问的部门;"all" 表示全员 + + +@dataclass +class Chunk: + id: str + doc_id: str + title: str + text: str + acl: set[str] + vec: np.ndarray = field(repr=False, default=None) + + +DOCS = [ + Doc("hr-leave", "员工手册-休假制度", """# 年假 +入职满一年的正式员工享有带薪年假 5 天,满十年 10 天,满二十年 15 天。年假需提前 3 个工作日在 OA 中申请。 +# 病假 +病假需提供医院证明,每年累计 10 天以内按全额工资发放。 +# 调休 +加班可在 3 个月内申请调休,过期作废。""", {"all"}), + Doc("it-vpn", "IT 指南-远程办公", """# VPN +在家办公需先安装公司 VPN 客户端,使用工号和动态口令登录。连接失败请检查系统时间是否准确。 +# 设备 +笔记本电脑丢失需在 24 小时内报告 IT 部门并修改所有密码。""", {"all"}), + Doc("fin-reimburse", "财务制度-报销", """# 差旅报销 +出差住宿标准:一线城市每晚 500 元以内,其他城市 350 元以内。发票须在出差结束后 30 天内提交。 +# 餐费 +出差餐补每天 100 元,无需发票。""", {"all"}), + Doc("hr-salary", "薪酬制度(仅 HR)", """# 调薪 +年度调薪在每年 3 月进行,幅度由绩效等级决定,A 级 10% 至 15%。 +# 保密 +薪酬信息属于保密信息,不得在公开渠道讨论。""", {"hr"}), +] + + +def split(doc: Doc, max_len: int = 80, overlap: int = 15) -> list[Chunk]: + """按 Markdown 标题分节;节内超过 max_len 再按句子切,相邻块保留 overlap 个字符的重叠。 + 每块前面加上“文档标题 / 小节标题”,让孤立的块也有上下文(例如“满十年 10 天”属于“年假”)。""" + out: list[Chunk] = [] + for sec in re.split(r"\n(?=# )", doc.text.strip()): + heading, _, body = sec.partition("\n") + heading = heading.lstrip("# ").strip() + sentences = [s for s in re.split(r"(?<=[。!?])", body.strip()) if s] + buf = "" + pieces: list[str] = [] + for s in sentences: + if buf and len(buf) + len(s) > max_len: + pieces.append(buf) + buf = buf[-overlap:] + buf += s + if buf: + pieces.append(buf) + for p in pieces: + out.append(Chunk(f"{doc.id}#{len(out)}", doc.id, doc.title, f"【{doc.title} / {heading}】{p}", doc.acl)) + return out + + +def embed(text: str) -> np.ndarray: + """字符 1~2 gram 哈希到 DIM 维并 L2 归一化(真实项目替换为嵌入模型 API)。""" + v = np.zeros(DIM, dtype=np.float32) + t = re.sub(r"\s+", "", text) + for n in (1, 2): + for i in range(len(t) - n + 1): + h = int.from_bytes(hashlib.md5(t[i:i + n].encode()).digest()[:4], "little") + v[h % DIM] += 1.0 + norm = np.linalg.norm(v) + return v / norm if norm else v + + +def tokenize(text: str) -> list[str]: + return [w for w in jieba.lcut(text) if w.strip() and not re.fullmatch(r"[,。、:;!?【】/\s]+", w)] + + +class Index: + def __init__(self, docs: list[Doc]) -> None: + self.chunks = [c for d in docs for c in split(d)] + for c in self.chunks: + c.vec = embed(c.text) + self.matrix = np.stack([c.vec for c in self.chunks]) + self.bm25 = BM25Okapi([tokenize(c.text) for c in self.chunks]) + + def allowed(self, user_depts: set[str]) -> np.ndarray: + return np.array([("all" in c.acl) or bool(c.acl & user_depts) for c in self.chunks]) + + def vector_search(self, q: str, mask: np.ndarray, k: int) -> list[tuple[int, float]]: + scores = self.matrix @ embed(q) # 已归一化,点积 = 余弦相似度 + scores[~mask] = -np.inf # 权限过滤:检索阶段就排除,而不是生成后再删 + order = np.argsort(-scores)[:k] + return [(int(i), float(scores[i])) for i in order if np.isfinite(scores[i])] + + def bm25_search(self, q: str, mask: np.ndarray, k: int) -> list[tuple[int, float]]: + scores = np.array(self.bm25.get_scores(tokenize(q))) + scores[~mask] = -np.inf + order = np.argsort(-scores)[:k] + return [(int(i), float(scores[i])) for i in order if np.isfinite(scores[i]) and scores[i] > 0] + + def hybrid_search(self, q: str, mask: np.ndarray, k: int, rrf_k: int = 60) -> list[tuple[int, float]]: + """RRF:score = Σ 1 / (rrf_k + 排名)。只用排名,不用担心两种分数量纲不同。""" + fused: dict[int, float] = {} + for results in (self.vector_search(q, mask, 20), self.bm25_search(q, mask, 20)): + for rank, (i, _) in enumerate(results, start=1): + fused[i] = fused.get(i, 0.0) + 1.0 / (rrf_k + rank) + return sorted(fused.items(), key=lambda kv: -kv[1])[:k] + + +def answer_context(index: Index, q: str, user_depts: set[str], k: int = 3, min_sim: float = 0.25) -> dict: + mask = index.allowed(user_depts) + hits = index.hybrid_search(q, mask, k) + # 拒答:最相关块的向量相似度太低,说明知识库里很可能没有答案 + best_sim = max((float(index.matrix[i] @ embed(q)) for i, _ in hits), default=0.0) + if not hits or best_sim < min_sim: + return {"refuse": True, "prompt": None, "sources": []} + sources = [index.chunks[i] for i, _ in hits] + context = "\n".join(f"[{n}] {c.text}" for n, c in enumerate(sources, start=1)) + prompt = ( + "你是公司制度问答助手。只根据下面的资料回答,资料里没有的内容回答“资料中没有相关规定”。" + "回答时用 [编号] 标注依据。\n\n" + f"资料:\n{context}\n\n问题:{q}" + ) + return {"refuse": False, "prompt": prompt, "sources": [c.id for c in sources], "best_sim": round(best_sim, 3)} + + +# ---------------- 检索评测 ---------------- +EVAL = [ # (问题, 期望命中的文档) + ("年假有几天", "hr-leave"), + ("工作满十年有多少天带薪假", "hr-leave"), + ("病假要什么材料", "hr-leave"), + ("加班多久内可以调休", "hr-leave"), + ("VPN 连不上怎么办", "it-vpn"), + ("电脑丢了怎么处理", "it-vpn"), + ("出差住酒店能报多少钱", "fin-reimburse"), + ("发票什么时候交", "fin-reimburse"), + ("出差吃饭有补贴吗", "fin-reimburse"), +] + + +def evaluate(index: Index, method: str, k: int = 3) -> tuple[float, float]: + mask = index.allowed({"all"}) + hit, rr = 0, 0.0 + for q, expected in EVAL: + results = getattr(index, f"{method}_search")(q, mask, k) + docs = [index.chunks[i].doc_id for i, _ in results] + if expected in docs: + hit += 1 + rr += 1 / (docs.index(expected) + 1) + return hit / len(EVAL), rr / len(EVAL) + + +def main() -> None: + index = Index(DOCS) + print(f"共 {len(DOCS)} 篇文档,切成 {len(index.chunks)} 块") + + print("\n检索评测(Recall@3 / MRR):") + scores = {} + for m in ("vector", "bm25", "hybrid"): + recall, mrr = evaluate(index, m) + scores[m] = recall + print(f" {m:7s} recall@3={recall:.2f} MRR={mrr:.2f}") + + r = answer_context(index, "满十年的年假几天?", {"dev"}) + print("\n问:满十年的年假几天? 引用:", r["sources"]) + print(r["prompt"][:120], "……") + + r_hr = answer_context(index, "A 级调薪幅度是多少", {"hr"}) + r_dev = answer_context(index, "A 级调薪幅度是多少", {"dev"}) + print("\nHR 提问调薪:", r_hr["sources"][:1], "| 研发提问调薪:", r_dev["sources"]) + assert r_hr["sources"][0].startswith("hr-salary") + assert not any(s.startswith("hr-salary") for s in r_dev["sources"]) # 无权限的内容不会进入上下文 + + r_none = answer_context(index, "公司股票期权怎么行权", {"dev"}) + print("问:公司股票期权怎么行权 → 拒答" if r_none["refuse"] else f"未拒答:{r_none}") + assert r_none["refuse"] + assert scores["hybrid"] >= max(scores["vector"], scores["bm25"]) - 1e-9 + + +if __name__ == "__main__": + main() diff --git a/topics/ai/rag/index.md b/topics/ai/rag/index.md index 0731a50..03d139c 100644 --- a/topics/ai/rag/index.md +++ b/topics/ai/rag/index.md @@ -4,12 +4,42 @@ title: RAG 检索增强生成 type: index domain: ai stack: [rag] -tags: [] +tags: [RAG, 检索, 知识库, 评测] week: [5, 6] --- # RAG 检索增强生成 -加载切分、嵌入、检索、混合检索与重排、引用与拒答、评测。 +RAG(Retrieval-Augmented Generation):先从知识库检索相关资料,再让大模型基于资料回答。这是企业 AI 应用里落地最多的形态(制度问答、客服知识库、文档助手),也是 AI 应用岗位面试的必考题。重点不在“调通一个 demo”,而在**切分、混合检索、权限、引用和拒答、评测**这些决定效果和能否上线的环节。 -> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。 +## 学习路线 + +| 顺序 | 课程 | 级别 | 预计 | +| --- | --- | --- | --- | +| 1 | [RAG 流水线和文档切分:加载、清洗、切分策略、元数据](01-pipeline-chunking.md) | 基础 | 3 小时 | +| 2 | [检索优化:向量 + 关键词混合检索、RRF 融合、重排、查询改写](02-hybrid-rerank.md) | 进阶 | 3 小时 | +| 3 | [生成与评测:带引用的提示词、拒答、检索评测和答案评测](03-generation-eval.md) | 进阶 | 3 小时 | + +## 企业场景 + +- [带权限的企业知识库问答:不同部门看到不同内容](scenarios/permission-aware-kb.md) + +## 面试题 + +- [RAG 面试题](interview.md) + +## 示例代码 + +- [examples/mini-rag](examples/mini-rag/):不依赖外部服务的最小 RAG 流水线——按标题切分、混合检索(向量 + BM25 + RRF)、权限过滤、拒答阈值、带引用的提示词、Recall@3 / MRR 评测(需要 `jieba`、`rank_bm25`、`numpy`) + +## 在 16 周计划中 + +- [第 5 周:RAG(一)](../../../plan/week05-rag-1.md)、[第 6 周:RAG(二)](../../../plan/week06-rag-2.md) + +## 官方资料 + +RAG 相关的框架和模型服务更新很快,接口和参数以官方文档为准: + +- LangChain 检索相关文档:https://docs.langchain.com/ +- LlamaIndex:https://developers.llamaindex.ai/ +- Ragas(RAG 评测):https://docs.ragas.io/ diff --git a/topics/ai/rag/interview.md b/topics/ai/rag/interview.md new file mode 100644 index 0000000..21c737d --- /dev/null +++ b/topics/ai/rag/interview.md @@ -0,0 +1,85 @@ +--- +id: ai.rag.interview +title: RAG 面试题 +type: interview +domain: ai +stack: [rag] +tags: [RAG, 检索, 评测, 面试] +level: intermediate +related: [ai.rag.pipeline-chunking, ai.rag.hybrid-rerank, ai.rag.generation-eval] +week: [5, 6, 16] +--- + +# RAG 面试题 + +## 基础 + +### 【基础】什么是 RAG?和微调有什么区别? + +**参考答案**:RAG 先从外部知识库检索相关资料,再把资料和问题一起交给大模型生成答案。和微调相比:知识更新只需要更新索引,成本低;答案可以附引用、可追溯;能做权限控制。微调改变的是模型的行为和风格(格式、领域语气、特定任务能力),不适合频繁变化的事实性知识。很多场景两者结合,但先做 RAG。 + +**追问**:什么情况下需要 RAG 和微调结合?→ 既要最新的企业知识,又要固定的输出格式或领域表达时:RAG 提供知识,微调(或更常见的:提示词 + 少量示例)约束格式。先用 RAG 和提示词解决,确实不够再考虑微调。 + +### 【基础】RAG 的完整流程是怎样的? + +**参考答案**:离线:加载文档 → 解析清洗 → 切分 → 嵌入 → 写入向量库(带元数据),关键词检索还要写入倒排索引。在线:问题(必要时改写)→ 检索(向量 / 关键词 / 混合)→ 权限和元数据过滤 → 重排 → 拼接提示词 → 大模型生成 → 返回答案和引用;全程记录日志用于评测和分析。 + +**追问**:文档解析这一步常见的问题有哪些?→ PDF 表格被拆散、多栏排版顺序错乱、扫描件需要 OCR、页眉页脚和水印混入正文。表格转成 Markdown 表格整体入库,解析结果要抽样人工检查。 + +### 【基础】chunk 大小怎么定?切分有哪些策略? + +**参考答案**:没有固定答案,要用评测集对比。太小上下文不完整,太大主题混杂、浪费 token。策略:固定长度加重叠、按文档结构(标题 / 段落)切分、按句子 / 语义切分、父子块(小块检索、返回大块)。结构化文档优先按结构切,并把标题路径加到块内容里。 + +**追问**:父子块(小块检索、返回大块)解决什么问题?→ 小块语义集中、检索准,但上下文不完整;命中小块后把它所在的大块(父块)交给模型,兼顾检索精度和上下文完整。 + +### 【基础】为什么要混合检索? + +**参考答案**:向量检索擅长语义相似,但对错误码、型号、人名等精确词效果差;BM25 擅长精确匹配,但不理解同义表达。两者互补,用 RRF 等方法融合后效果通常更稳定。中文关键词检索要先分词。 + +**追问**:中文 BM25 要注意什么?Elasticsearch 怎么做混合检索?→ 中文要先分词(jieba、ES 的 IK 等分词插件),否则按单字匹配效果差;ES 可以同时做 BM25 和 kNN 向量检索,再用 RRF 等方式融合,具体语法随版本变化,以官方文档为准。 + +## 进阶 + +### 【进阶】怎么减少 RAG 的幻觉? + +**参考答案**:检索层面:提高召回质量(切分、混合检索、重排),召回不到时拒答;提示词层面:要求只依据资料回答、资料中没有就说没有、每个结论标注引用编号;生成后:校验引用编号是否存在、关键数字是否出现在被引用的资料中;评测:用忠实度指标和人工抽查持续监控。 + +**追问**:模型给出的引用编号是对的,内容却不在资料里,怎么发现?→ 生成后做校验:引用编号必须存在;抽取答案中的关键数字 / 实体,检查是否出现在被引用的资料里;不通过时降级为“只返回相关资料”或提示人工核对,并记录为 Bad Case。 + +### 【进阶】怎么评测一个 RAG 系统? + +**参考答案**:分层评测。检索层:人工标注问题和应命中的文档,计算 Recall@k、MRR;生成层:忠实度(答案是否有资料支撑)、答案正确性和相关性,用人工标注 + LLM 评分并互相校准;端到端:拒答率、无权限问题的处理;线上:用户反馈、转人工率、差评样本分析。评测集要覆盖精确词、多轮、无答案、无权限等类型,改动前后都跑。 + +**追问**:评测集从哪里来?→ 初期人工根据文档编写(每类问题若干条),上线后从真实日志和差评中补充;可以用大模型辅助生成候选问题,但必须人工审核;评测集要版本化管理。 + +### 【进阶】重排(rerank)是什么?为什么不直接用重排模型检索全部文档? + +**参考答案**:重排模型(通常是交叉编码器)把问题和候选文本拼在一起计算相关度,比向量的双塔模型更准,但每一对都要单独计算,速度慢、成本高。所以先用向量 / BM25 快速召回几十个候选,再用重排模型精排取前几个。 + +**追问**:重排之后取几条给模型?→ 用评测集测试:太少可能漏掉答案,太多增加成本和干扰,常见 3~8 条;同时设置重排分数阈值,低于阈值的不放进提示词。 + +### 【进阶】什么是 Agentic RAG?和普通 RAG 有什么区别? + +**参考答案**:普通 RAG 是固定流程:检索一次、生成一次。Agentic RAG 让模型作为 Agent 决定是否检索、检索什么、检索结果不够时改写问题再检索、从多个知识源(文档、数据库、搜索)中选择,并在生成后自我检查。适合复杂多跳问题,代价是延迟和成本更高、流程更难控制,需要设置最大步数和评测。详见 [Agent 设计专题](../agent-design/index.md)。 + +**追问**:Agentic RAG 怎么防止无限检索?→ 设置最大步数 / 最大检索次数和总超时;每一步记录状态,达到上限后基于已有资料回答或拒答;用评测集对比它和普通 RAG 的效果、延迟和成本,确认值得。 + +## 场景题 + +### 【场景题】知识库里有不同部门的保密文档,怎么保证员工只能问到有权限的内容? + +**参考答案**:每个块在入库时带权限元数据(可见部门 / 角色);问答时先鉴权得到用户的部门和角色,在检索阶段作为过滤条件(先过滤再取 top-k),无权限内容不进入提示词;缓存要区分权限;日志脱敏;评测集包含越权问题做回归。不能只靠提示词约束模型。详见[带权限的知识库场景](scenarios/permission-aware-kb.md)。 + +**追问**:用户的部门变了,之前的缓存答案怎么办?→ 缓存 key 包含权限维度(部门 / 角色的哈希),或者敏感知识库不缓存答案;权限变更事件触发相关缓存失效。 + +### 【场景题】用户反馈“答案不对”,你怎么排查? + +**参考答案**:用请求 ID 找到这次的完整链路记录:改写后的问题、检索到的块和分数、重排结果、最终提示词、模型输出。判断问题在哪一层:正确资料没被召回(切分、嵌入、检索参数问题)→ 召回了但排序靠后(重排)→ 资料在提示词里但模型答错(提示词、模型能力)→ 资料本身过期或错误(数据问题)。把它加入评测集(Bad Case),修复后回归。 + +**追问**:怎么把这类问题变成可持续改进的流程?→ 建立 Bad Case 记录:每个问题标注失败环节(解析 / 切分 / 检索 / 排序 / 生成 / 数据),定期汇总找出占比最高的环节优先优化,修复后加入评测集防止回归。 + +### 【场景题】文档每天都在更新,怎么保持索引最新? + +**参考答案**:监听文档源的变更(事件、定时比较更新时间或内容哈希),只处理变化的文档;每个块带 `doc_id` 和版本号,更新时先删除该文档的旧块再写入新块(或写入新版本后原子切换);删除的文档同步删除;入库是异步任务,失败重试、幂等;监控入库延迟和失败数。跨栈的数据同步场景见 [scenarios/](../../../scenarios/index.md)。 + +**追问**:嵌入成本很高,大量文档频繁小改动怎么办?→ 按块计算内容哈希,只有内容变化的块才重新嵌入;元数据(如权限)变化只更新元数据,不重新嵌入。 diff --git a/topics/ai/rag/scenarios/permission-aware-kb.md b/topics/ai/rag/scenarios/permission-aware-kb.md new file mode 100644 index 0000000..f0cc560 --- /dev/null +++ b/topics/ai/rag/scenarios/permission-aware-kb.md @@ -0,0 +1,108 @@ +--- +id: ai.rag.scenario.permission-aware-kb +title: 带权限的企业知识库问答:不同部门看到不同内容 +type: scenario +domain: ai +stack: [rag, vector-db, elasticsearch, fastapi] +tags: [企业知识库, 权限过滤, 文档解析, 混合检索, 重排, 拒答, 增量更新] +level: advanced +prerequisites: [ai.rag.generation-eval] +est_hours: 3 +related: [ai.rag.interview, ai.vector-db.scenario.multi-tenant-index, backend.elasticsearch.index] +week: [5, 6] +--- + +# 带权限的企业知识库问答:不同部门看到不同内容 + +## 业务背景与问题 + +公司把员工手册、IT 指南、财务制度、各部门内部文档都导入知识库,做一个“制度问答助手”。试运行时发现: + +1. 研发同事问“A 级调薪幅度多少”,助手根据 HR 内部的薪酬文档给出了答案——**越权**; +2. PDF 里的表格(差旅标准)被解析成一串数字,问“一线城市住宿标准”答错; +3. 问“报错 0x80070005 怎么办”,检索不到包含这个错误码的文档; +4. 制度更新后,新旧两个版本都被检索到,答案自相矛盾; +5. 知识库里没有的问题(股票期权),助手也一本正经地回答。 + +## 约束 + +- 权限以现有的组织架构(部门、角色)为准,文档有“全员 / 指定部门 / 指定角色”三种可见范围。 +- 文档格式:PDF(含表格、扫描件)、Word、Confluence 类网页。 +- 答案必须给出引用来源,方便员工核对原文。 +- 回答延迟目标:首字 2 秒以内(流式)。 + +## 方案设计 + +```mermaid +flowchart LR + subgraph 入库(异步任务) + S[文档源] --> P[解析:PDF/Word/HTML → Markdown
表格转 Markdown 表格,扫描件 OCR] --> C[按标题切分 + 标题路径前缀] + C --> E[嵌入] --> V[(向量库
块 + acl + doc_id + version)] + C --> K[(Elasticsearch
中文分词 BM25)] + end + subgraph 问答 + U[用户] --> A[鉴权:取部门 / 角色] --> R1[向量检索 + acl 过滤] + A --> R2[ES 检索 + acl 过滤] + R1 & R2 --> F[RRF 融合 → 重排 top 5] --> G{最高分 ≥ 阈值?} + G -->|否| N[拒答 + 转人工入口] + G -->|是| L[带编号资料的提示词 → 流式生成] --> O[答案 + 引用] + end +``` + +1. **权限在检索阶段过滤**:每个块带 `acl`(可见的部门 / 角色列表),检索时作为过滤条件,**无权限的内容根本不进入提示词**。不要“先检索再让模型别说”。 +2. **文档解析**:PDF 先转结构化文本,表格转成 Markdown 表格整体作为一个块;扫描件走 OCR;去掉页眉页脚。解析结果抽样人工检查。 +3. **混合检索**:向量检索负责语义,Elasticsearch(IK 等中文分词)负责错误码、编号等精确词;RRF 融合后用重排模型取前 5。 +4. **版本和增量更新**:块带 `doc_id` 和 `version`;文档更新时按 `doc_id` 删除旧块再写入新块(或写入新版本后切换),避免新旧共存。 +5. **拒答**:重排后最高分低于阈值直接拒答,给出“提交工单 / 联系 HR”入口。 +6. **评测和回归**:评测集包含有权限 / 无权限 / 无答案 / 精确词 / 表格类问题,每次改动跑一遍。 + +## 关键代码 + +权限过滤和拒答的最小实现(完整代码 [examples/mini-rag/mini_rag.py](../examples/mini-rag/mini_rag.py)): + +```python +def allowed(self, user_depts: set[str]) -> np.ndarray: + return np.array([("all" in c.acl) or bool(c.acl & user_depts) for c in self.chunks]) + + +def vector_search(self, q: str, mask: np.ndarray, k: int): + scores = self.matrix @ embed(q) + scores[~mask] = -np.inf # 无权限的块直接排除,不会进入提示词 + ... +``` + +示例里验证了:HR 部门问调薪,命中薪酬制度;研发部门问同一个问题,薪酬制度不会出现在候选中(结果为空 → 拒答);问知识库中没有的“股票期权”→ 拒答。 + +真实向量库的过滤写法(以 Chroma 为例,`where` 条件;其他向量库语法不同,以官方文档为准): + +```python +results = collection.query( + query_embeddings=[q_vec], + n_results=20, + where={"$or": [{"acl_all": True}, {"dept": {"$in": list(user_depts)}}]}, +) +``` + +## 取舍 + +| 选择 | 优点 | 缺点 | +| --- | --- | --- | +| **检索时按 acl 过滤(本方案)** | 安全,无权限内容不会进入模型 | 权限变化时要更新块的 acl | +| 按部门分别建索引 | 隔离彻底 | 跨部门文档要重复存储;部门多时管理困难 | +| 检索后再过滤 | 实现简单 | top-k 被无权限内容占满后,有权限的内容反而召回不到 | +| 只靠提示词让模型不说 | —— | **不可接受**,模型可能泄露 | +| 向量库 + ES 两套存储 | 混合检索效果好 | 两边数据同步、一致性要处理 | +| 只用支持混合检索的单一引擎 | 运维简单 | 依赖具体产品的能力(ES、部分向量库已支持向量 + 关键词),以官方文档为准 | + +## 易错点 + +- 权限只在前端或网关判断,检索层不过滤。 +- 先取 top-5 再过滤权限:结果可能被过滤成空,实际上还有相关的有权限内容。先过滤再取 top-k。 +- 用户换部门后缓存的答案仍可见:缓存 key 要包含权限维度,或不缓存敏感问答。 +- 表格被拆成多块:一行数据和表头分离,数字失去含义。 +- 文档删除 / 下线后向量库里还有残留。 +- 日志里记录了完整的提示词(包含敏感文档内容),日志本身成为泄露渠道。 + +## 面试怎么讲 + +> “企业知识库最大的问题是权限。我在每个块上带了 acl 元数据,检索阶段就过滤,无权限的内容不会进入提示词,而不是靠提示词让模型别说;检索用向量加 ES 的混合检索再重排,解决错误码这类精确查询;文档解析时把表格转成 Markdown 整体入库;文档按 doc_id 和版本增量更新;最高分低于阈值就拒答。评测集专门包含无权限和无答案的问题,每次改动都跑回归。” diff --git a/topics/ai/vector-db/01-ann-metrics.md b/topics/ai/vector-db/01-ann-metrics.md new file mode 100644 index 0000000..770012c --- /dev/null +++ b/topics/ai/vector-db/01-ann-metrics.md @@ -0,0 +1,100 @@ +--- +id: ai.vector-db.ann-metrics +title: 向量检索原理:距离度量、暴力检索、HNSW / IVF 近似索引 +type: lesson +domain: ai +stack: [vector-db] +tags: [向量检索, 余弦相似度, 内积, L2, HNSW, IVF, PQ, 召回率] +level: basic +prerequisites: [ai.rag.pipeline-chunking] +est_hours: 2.5 +related: [ai.vector-db.selection-pgvector, ai.rag.hybrid-rerank, ai.vector-db.interview] +week: [5] +order: 1 +--- + +# 向量检索原理:距离度量、暴力检索、HNSW / IVF 近似索引 + +## 解释说明 + +**嵌入(embedding)** 把一段文本变成一个固定长度的浮点数组(例如 768、1024、1536 维),语义相近的文本向量也相近。向量数据库要解决的问题就是:给定一个查询向量,从几百万个向量中**快速**找出最相近的 k 个(k 近邻,kNN)。 + +### 距离度量 + +| 度量 | 含义 | 什么时候用 | +| --- | --- | --- | +| 余弦相似度(cosine) | 两个向量夹角的余弦,只看方向不看长度 | 文本嵌入最常用 | +| 内积(inner product, IP / dot) | 对应元素相乘求和 | 向量已归一化时**和余弦等价且更快** | +| 欧氏距离(L2) | 两点之间的直线距离 | 部分模型推荐;归一化向量的 L2 排序和余弦排序一致 | + +规则:**用嵌入模型文档推荐的度量**;如果向量已归一化(长度为 1),三种度量的排序结果一致。注意“距离”和“相似度”方向相反:余弦距离 = 1 − 余弦相似度,越小越相近。 + +### 暴力检索和近似检索 + +- **暴力检索(精确)**:查询向量和所有向量逐一计算,复杂度 O(N·d)。几万条以内完全够用(numpy 一次矩阵乘法),而且结果是精确的。 +- **近似最近邻(ANN)**:用索引结构牺牲一点准确度,换取数量级的速度提升。衡量指标是**召回率**:近似结果和精确结果的重合比例。 + +### 常见索引 + +| 索引 | 原理 | 关键参数 | 特点 | +| --- | --- | --- | --- | +| **HNSW** | 多层“小世界”图,从稀疏的上层快速定位,逐层向下精细搜索 | `M`(每个点的连接数)、`ef_construction`(建索引时的候选数)、`ef_search`(查询时的候选数) | 查询快、召回高,**最常用**;内存占用大,建索引慢 | +| **IVF**(倒排文件) | 先用聚类把向量分成 `nlist` 个桶,查询时只搜最近的 `nprobe` 个桶 | `nlist` / `lists`、`nprobe` / `probes` | 建索引快、省内存;要先有数据才能训练聚类中心,数据分布变化后要重建 | +| **PQ**(乘积量化) | 把向量切段并压缩成编码 | 段数、码本大小 | 大幅省内存,精度下降,常和 IVF 组合(IVF_PQ) | +| DiskANN 等磁盘索引 | 索引放在 SSD 上 | —— | 数据量超出内存时使用 | + +参数的规律:`ef_search` / `nprobe` 调大 → 召回率上升、延迟上升。**用自己的数据和评测集测出合适的值**,不要照抄。 + +### 过滤和近似索引的冲突 + +带条件的查询(只搜某租户 / 某部门)在近似索引上有一个经典问题:索引先返回 top-N 个近邻,再按条件过滤,如果满足条件的数据很少,过滤后可能不足 k 个甚至为空。解决办法:数据库内置的过滤遍历(例如 pgvector 0.8 的迭代索引扫描、Milvus 的过滤搜索)、增大候选数、按租户分区 / 分集合、过滤条件非常严格时直接走精确检索。 + +## 案例 + +制度问答知识库有 3 万个块:直接用 numpy 暴力检索,单次查询几毫秒,没有召回损失。业务扩展到 50 个租户、800 万个块后,暴力检索需要几百毫秒,改用 HNSW 索引;上线前用 200 个评测问题对比 HNSW 和暴力检索的结果,调整 `ef_search` 使召回率达到 0.95 以上,同时 P99 延迟满足要求。 + +## 代码示例 + +用 numpy 实现暴力检索,验证“归一化后余弦 = 内积,L2 排序一致”: + +```python +import numpy as np + +rng = np.random.default_rng(0) +docs = rng.normal(size=(1000, 64)).astype(np.float32) +q = rng.normal(size=64).astype(np.float32) + +docs_n = docs / np.linalg.norm(docs, axis=1, keepdims=True) +q_n = q / np.linalg.norm(q) + +cos = docs_n @ q_n # 余弦相似度(越大越近) +ip = docs_n @ q_n # 归一化后内积就是余弦 +l2 = np.linalg.norm(docs_n - q_n, axis=1) # 欧氏距离(越小越近) + +top_cos = np.argsort(-cos)[:5] +top_l2 = np.argsort(l2)[:5] +assert list(top_cos) == list(top_l2) # 归一化后排序一致 +print(top_cos, np.round(cos[top_cos], 3)) + +# 未归一化时内积会偏向长度大的向量,排序可能不同 +top_ip_raw = np.argsort(-(docs @ q))[:5] +print("未归一化内积 top5:", top_ip_raw) +``` + +近似索引和精确检索的召回率对比(Chroma HNSW、pgvector HNSW)见 [examples/vector-stores](examples/vector-stores/)。 + +## 易错点 + +- 嵌入模型和度量不匹配:模型输出未归一化却用内积,结果偏向长向量。 +- 入库和查询使用了**不同的嵌入模型**(或同一模型的不同版本):向量空间不一致,检索结果完全不对。换模型必须全量重新嵌入。 +- 把“距离”当“相似度”用:阈值方向写反。 +- 只看近似检索的结果,不和暴力检索对比,不知道召回率是多少。 +- 数据量很小也上复杂的向量数据库:几万条以内 numpy / pgvector 精确检索就够了。 +- IVF 索引在空表上建立:聚类中心没有意义,召回率很低。应在导入有代表性的数据之后再建。 + +## 面试常问 + +- 余弦相似度、内积、L2 的区别?什么时候结果一样? +- HNSW 的原理和关键参数?`ef_search` 调大会怎样? +- HNSW 和 IVF 怎么选? +- 带过滤条件的向量检索为什么可能召回不足?怎么解决? diff --git a/topics/ai/vector-db/02-selection-pgvector.md b/topics/ai/vector-db/02-selection-pgvector.md new file mode 100644 index 0000000..f6c85a8 --- /dev/null +++ b/topics/ai/vector-db/02-selection-pgvector.md @@ -0,0 +1,87 @@ +--- +id: ai.vector-db.selection-pgvector +title: 向量库选型和 pgvector 实战:Chroma / pgvector / Milvus / Elasticsearch +type: lesson +domain: ai +stack: [vector-db, postgresql] +tags: [向量库选型, pgvector, Chroma, Milvus, Elasticsearch, 元数据过滤] +level: intermediate +prerequisites: [ai.vector-db.ann-metrics] +est_hours: 3 +related: [ai.vector-db.scenario.multi-tenant-index, backend.postgresql.index, backend.elasticsearch.index] +week: [5, 6] +order: 2 +--- + +# 向量库选型和 pgvector 实战:Chroma / pgvector / Milvus / Elasticsearch + +## 解释说明 + +### 选型对比 + +| 方案 | 适合 | 优点 | 注意 | +| --- | --- | --- | --- | +| **Chroma** | 原型、学习、小规模 | 安装即用,Python 内嵌模式 | 大规模、高可用要评估;版本间 API 有变化 | +| **pgvector**(PostgreSQL 扩展) | 已有 PostgreSQL、百万级以内、需要和业务数据联查 | 一套数据库;SQL 过滤、事务、备份都复用 | 超大规模和高 QPS 时不如专用向量库;向量索引的内存占用要规划 | +| **Milvus** | 千万到亿级、需要分布式 | 多种索引、分区、标量过滤、水平扩展 | 组件多,运维成本高(也有托管版) | +| **Elasticsearch / OpenSearch** | 已经用 ES 做全文检索,想要关键词 + 向量混合检索 | 一个引擎做 BM25 + kNN | 向量功能的版本差异较大,以官方文档为准 | +| FAISS | 嵌入到程序里的向量检索库 | 快,索引类型全 | 是库不是数据库:没有持久化服务、过滤、权限,需要自己做 | + +**本计划的选择**:学习阶段用 Chroma 或 numpy 快速理解;项目使用 **pgvector**,因为第 3~4 周已经学了 PostgreSQL,业务数据、权限和向量可以放在同一个库里联查;面试时能讲清楚“为什么不用 Milvus”和“什么规模需要换”。 + +### pgvector 要点(以官方文档为准:https://github.com/pgvector/pgvector) + +- 类型:`vector(n)`;还有 `halfvec`(半精度,省一半空间)、`sparsevec`。 +- 运算符:`<->` L2 距离,`<#>` **负**内积,`<=>` 余弦距离(都是越小越近)。 +- 索引:`USING hnsw (embedding vector_cosine_ops)`,运算符类必须和查询用的运算符一致(`vector_l2_ops` / `vector_ip_ops` / `vector_cosine_ops`),否则不走索引。 +- 查询参数:`SET hnsw.ef_search = 100;`(默认 40);IVFFlat 用 `ivfflat.probes`。 +- 过滤:`WHERE tenant_id = $1 ORDER BY embedding <=> $2 LIMIT 10`。过滤条件很严格时,0.8 版本可以开启 `hnsw.iterative_scan` 让索引继续扫描直到凑够结果;或者按租户建部分索引 / 分区。 + +## 案例 + +一个 SaaS 客服系统,50 个租户共 200 万个知识块,平均每个租户 4 万,最大租户 60 万。业务表已经在 PostgreSQL 中。选择 pgvector:块表带 `tenant_id`,HNSW 索引 + `tenant_id` 普通索引;大租户检索结果不足时开启迭代扫描。评估结论写进文档:当总量超过数千万或 QPS 超过单机能力时,再评估 Milvus。 + +## 代码示例 + +pgvector 的表结构和查询(完整可运行版本 [examples/vector-stores/pgvector_demo.py](examples/vector-stores/pgvector_demo.py),已在 PostgreSQL 17 + pgvector 0.8.0 上运行): + +```sql +CREATE EXTENSION IF NOT EXISTS vector; + +CREATE TABLE chunks ( + id BIGSERIAL PRIMARY KEY, + tenant_id TEXT NOT NULL, + doc_id TEXT NOT NULL, + version INT NOT NULL, + content TEXT NOT NULL, + embedding vector(1024) NOT NULL +); +CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops); +CREATE INDEX ON chunks (tenant_id); +CREATE INDEX ON chunks (doc_id); + +-- 查询:$1 租户,$2 查询向量 +SET hnsw.ef_search = 100; +SELECT id, doc_id, content, 1 - (embedding <=> $2) AS similarity +FROM chunks +WHERE tenant_id = $1 +ORDER BY embedding <=> $2 +LIMIT 10; +``` + +Chroma 的写入、元数据过滤、更新删除见 [examples/vector-stores/chroma_demo.py](examples/vector-stores/chroma_demo.py)。 + +## 易错点 + +- 建索引用了 `vector_l2_ops`,查询用 `<=>`:不走索引,变成全表扫描。 +- `<#>` 返回的是负内积,排序时直接 `ORDER BY embedding <#> q` 就是内积从大到小;自己再加负号就反了。 +- 小表的执行计划显示顺序扫描:数据少时规划器认为顺序扫描更便宜,这是正常的,不是索引失效。 +- HNSW 索引默认不支持太高的维度(`vector` 类型的索引上限 2000 维,`halfvec` 4000 维,以官方文档为准),高维模型要选 `halfvec` 或降维。 +- 大批量导入时边写边维护 HNSW 索引很慢:先导入再建索引,并适当调大 `maintenance_work_mem`。 +- Chroma 不同版本的客户端 / 持久化格式有变化,升级前看迁移说明。 + +## 面试常问 + +- 为什么选 pgvector 而不是 Milvus?什么时候要换? +- pgvector 的三种距离运算符分别是什么?索引怎么建才能生效? +- 向量和业务数据放一个库有什么好处和风险? diff --git a/topics/ai/vector-db/examples/vector-stores/chroma_demo.py b/topics/ai/vector-db/examples/vector-stores/chroma_demo.py new file mode 100644 index 0000000..ffa3b32 --- /dev/null +++ b/topics/ai/vector-db/examples/vector-stores/chroma_demo.py @@ -0,0 +1,40 @@ +# requires: chromadb, numpy +"""Chroma(内存模式):写入自带向量、按元数据过滤、和暴力检索对比召回率。 + +运行:python chroma_demo.py(不需要任何外部服务,也不会下载嵌入模型,因为我们自己提供向量) +Chroma 的 API 在版本间有变化,以官方文档为准:https://docs.trychroma.com/ +""" +import chromadb +import numpy as np + +from common import brute_force, make_corpus, query_vec + +ids, vecs, metas, centers = make_corpus() +client = chromadb.EphemeralClient() +col = client.create_collection("docs_demo", metadata={"hnsw:space": "cosine"}) # 距离度量要和嵌入模型匹配 +col.add(ids=ids, embeddings=vecs.tolist(), metadatas=metas) + +q = query_vec(centers, "it") +k = 10 + +# 1. 无过滤:近似检索结果 vs 精确结果 +res = col.query(query_embeddings=[q.tolist()], n_results=k) +approx = res["ids"][0] +exact = [ids[i] for i in brute_force(vecs, q, k)] +recall = len(set(approx) & set(exact)) / k +print(f"HNSW 召回率(相对暴力检索)@{k} = {recall:.2f}") +print("距离(cosine distance = 1 - 相似度)前 3:", [round(d, 3) for d in res["distances"][0][:3]]) +assert all(i.startswith("it-") for i in approx[:5]) + +# 2. 元数据过滤:多租户 / 权限 +res = col.query(query_embeddings=[q.tolist()], n_results=k, + where={"$and": [{"tenant": "tenant-1"}, {"topic": {"$in": ["it", "hr"]}}]}) +got = res["ids"][0] +print("tenant-1 过滤后:", got[:5]) +assert got and all(metas[ids.index(i)]["tenant"] == "tenant-1" for i in got) + +# 3. 更新和删除:按 id 覆盖、删除 +col.upsert(ids=["it-0"], embeddings=[vecs[0].tolist()], metadatas=[{"topic": "hr", "tenant": "tenant-0"}]) +col.delete(where={"topic": "finance"}) +print("删除 finance 后剩余:", col.count()) +assert col.count() == 400 diff --git a/topics/ai/vector-db/examples/vector-stores/common.py b/topics/ai/vector-db/examples/vector-stores/common.py new file mode 100644 index 0000000..716d800 --- /dev/null +++ b/topics/ai/vector-db/examples/vector-stores/common.py @@ -0,0 +1,33 @@ +"""示例共用:确定性的“假嵌入”(随机向量 + 主题偏移),让示例不依赖嵌入模型 API。""" +import numpy as np + +DIM = 64 +TOPICS = ["hr", "it", "finance"] + + +def make_corpus(n_per_topic: int = 200, seed: int = 7): + """每个主题一个中心向量,文档向量 = 中心 + 噪声。返回 (ids, vectors, metadatas)。""" + rng = np.random.default_rng(seed) + centers = {t: rng.normal(size=DIM) for t in TOPICS} + ids, vecs, metas = [], [], [] + for t in TOPICS: + for i in range(n_per_topic): + v = centers[t] + rng.normal(scale=0.8, size=DIM) + ids.append(f"{t}-{i}") + vecs.append(v / np.linalg.norm(v)) + metas.append({"topic": t, "tenant": f"tenant-{i % 3}"}) + return ids, np.array(vecs, dtype=np.float32), metas, centers + + +def query_vec(centers, topic: str, seed: int = 1) -> np.ndarray: + rng = np.random.default_rng(seed) + v = centers[topic] + rng.normal(scale=0.8, size=DIM) + return (v / np.linalg.norm(v)).astype(np.float32) + + +def brute_force(vecs: np.ndarray, q: np.ndarray, k: int, mask=None) -> list[int]: + """精确的最近邻(余弦 = 归一化后的点积),作为近似检索的对照。""" + scores = vecs @ q + if mask is not None: + scores = np.where(mask, scores, -np.inf) + return list(np.argsort(-scores)[:k]) diff --git a/topics/ai/vector-db/examples/vector-stores/pgvector_demo.py b/topics/ai/vector-db/examples/vector-stores/pgvector_demo.py new file mode 100644 index 0000000..cbc7001 --- /dev/null +++ b/topics/ai/vector-db/examples/vector-stores/pgvector_demo.py @@ -0,0 +1,77 @@ +# requires: psycopg, numpy +"""pgvector:建表、HNSW 索引、带过滤的相似度检索、和暴力检索对比。 + +运行:TEST_DATABASE_URL=postgresql://user:pass@localhost:5432/db python pgvector_demo.py +需要数据库已安装 pgvector 扩展(CREATE EXTENSION vector)。没有设置环境变量时跳过。 +会在一个临时 schema 里建表,结束后删除。 +""" +import os +import sys +import uuid + +import numpy as np +import psycopg + +from common import DIM, brute_force, make_corpus, query_vec + + +def to_pg(v: np.ndarray) -> str: + return "[" + ",".join(f"{x:.6f}" for x in v) + "]" + + +def main() -> int: + url = os.environ.get("TEST_DATABASE_URL") + if not url: + print("未设置 TEST_DATABASE_URL,跳过") + return 0 + schema = f"vec_demo_{uuid.uuid4().hex[:8]}" + ids, vecs, metas, centers = make_corpus() + with psycopg.connect(url, autocommit=True) as conn: + try: + conn.execute("CREATE EXTENSION IF NOT EXISTS vector") + conn.execute(f"CREATE SCHEMA {schema}") + conn.execute(f"SET search_path TO {schema}, public") + conn.execute(f"""CREATE TABLE chunks ( + id TEXT PRIMARY KEY, + tenant TEXT NOT NULL, + topic TEXT NOT NULL, + embedding vector({DIM}) NOT NULL)""") + with conn.cursor().copy("COPY chunks (id, tenant, topic, embedding) FROM STDIN") as cp: + for i, v, m in zip(ids, vecs, metas): + cp.write_row((i, m["tenant"], m["topic"], to_pg(v))) + # HNSW 索引;vector_cosine_ops 对应 <=> 余弦距离运算符 + conn.execute("CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops)") + conn.execute("CREATE INDEX ON chunks (tenant)") + conn.execute("ANALYZE chunks") + + q = query_vec(centers, "finance") + k = 10 + rows = conn.execute("SELECT id, 1 - (embedding <=> %s::vector) AS sim FROM chunks " + "ORDER BY embedding <=> %s::vector LIMIT %s", (to_pg(q), to_pg(q), k)).fetchall() + exact = [ids[i] for i in brute_force(vecs, q, k)] + recall = len({r[0] for r in rows} & set(exact)) / k + print(f"pgvector HNSW 召回率@{k} = {recall:.2f},最相似:{rows[0][0]} {rows[0][1]:.3f}") + assert all(r[0].startswith("finance-") for r in rows[:5]) + + # 只有 600 行时规划器可能选择顺序扫描(更便宜),演示时临时关闭顺序扫描看索引计划 + conn.execute("SET enable_seqscan = off") + plan = "\n".join(r[0] for r in conn.execute( + "EXPLAIN SELECT id FROM chunks ORDER BY embedding <=> %s::vector LIMIT 10", (to_pg(q),)).fetchall()) + conn.execute("RESET enable_seqscan") + print("执行计划使用 HNSW 索引:", "chunks_embedding_idx" in plan) + assert "chunks_embedding_idx" in plan + + # 带过滤:SQL 的 WHERE 和向量排序写在一起,这是 pgvector 的便利之处 + rows = conn.execute("SELECT id, tenant FROM chunks WHERE tenant = %s " + "ORDER BY embedding <=> %s::vector LIMIT %s", ("tenant-2", to_pg(q), k)).fetchall() + assert rows and all(t == "tenant-2" for _, t in rows) + mask = np.array([m["tenant"] == "tenant-2" for m in metas]) + exact_f = {ids[i] for i in brute_force(vecs, q, k, mask)} + print(f"过滤后召回率@{k} = {len({r[0] for r in rows} & exact_f) / k:.2f}(过滤 + 近似索引可能降低召回,见课程)") + finally: + conn.execute(f"DROP SCHEMA IF EXISTS {schema} CASCADE") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/topics/ai/vector-db/index.md b/topics/ai/vector-db/index.md index 1c87a4c..48bb7f2 100644 --- a/topics/ai/vector-db/index.md +++ b/topics/ai/vector-db/index.md @@ -4,12 +4,42 @@ title: 向量数据库 type: index domain: ai stack: [vector-db] -tags: [] +tags: [向量数据库, HNSW, pgvector, Chroma, Milvus] week: [5, 6] --- # 向量数据库 -近似最近邻索引、距离度量、Chroma / Milvus / pgvector 选型。 +RAG 的检索基础设施。重点是讲清楚**距离度量、近似索引的召回和延迟取舍、带过滤的检索、选型理由**,而不是背某个产品的 API。项目里使用 pgvector(复用第 3~4 周学的 PostgreSQL)。 -> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。 +## 学习路线 + +| 顺序 | 课程 | 级别 | 预计 | +| --- | --- | --- | --- | +| 1 | [向量检索原理:距离度量、暴力检索、HNSW / IVF 近似索引](01-ann-metrics.md) | 基础 | 2.5 小时 | +| 2 | [向量库选型和 pgvector 实战:Chroma / pgvector / Milvus / Elasticsearch](02-selection-pgvector.md) | 进阶 | 3 小时 | + +## 企业场景 + +- [多租户知识库的向量索引:隔离、过滤召回和增量更新](scenarios/multi-tenant-index.md) + +## 面试题 + +- [向量数据库面试题](interview.md) + +## 示例代码 + +- [examples/vector-stores](examples/vector-stores/):`chroma_demo.py`(内存模式,自带向量,元数据过滤、更新删除,和暴力检索对比召回率);`pgvector_demo.py`(HNSW 索引、过滤查询、执行计划,需要 `TEST_DATABASE_URL` 和 pgvector 扩展,否则跳过) + +## 在 16 周计划中 + +- [第 5 周:RAG(一)](../../../plan/week05-rag-1.md)、[第 6 周:RAG(二)](../../../plan/week06-rag-2.md) + +## 官方资料 + +产品更新很快,接口和参数以官方文档为准: + +- pgvector:https://github.com/pgvector/pgvector +- Chroma:https://docs.trychroma.com/ +- Milvus:https://milvus.io/docs +- Elasticsearch 向量检索:https://www.elastic.co/docs/solutions/search/vector diff --git a/topics/ai/vector-db/interview.md b/topics/ai/vector-db/interview.md new file mode 100644 index 0000000..1f0a966 --- /dev/null +++ b/topics/ai/vector-db/interview.md @@ -0,0 +1,67 @@ +--- +id: ai.vector-db.interview +title: 向量数据库面试题 +type: interview +domain: ai +stack: [vector-db] +tags: [向量数据库, HNSW, pgvector, 面试] +level: intermediate +related: [ai.vector-db.ann-metrics, ai.vector-db.selection-pgvector, ai.rag.interview] +week: [5, 6, 16] +--- + +# 向量数据库面试题 + +## 基础 + +### 【基础】向量数据库解决什么问题?和传统数据库有什么区别? + +**参考答案**:解决“找出和查询向量最相似的 k 个向量”的问题。传统数据库按精确值或范围查询(B+ 树等),向量检索按相似度排序,需要专门的近似最近邻索引(HNSW、IVF)在速度和召回率之间取舍。现在很多传统数据库也通过扩展支持向量(pgvector、ES 的 kNN)。 + +**追问**:数据量只有几万条,需要向量数据库吗?——不需要专门的向量数据库,内存暴力检索或 pgvector 精确检索就够快,而且结果精确。 + +### 【基础】余弦相似度、内积、欧氏距离有什么区别? + +**参考答案**:余弦只看方向;内积同时受长度影响;欧氏距离是空间中两点的距离。向量归一化后三者的排序结果一致,此时内积计算最快。应该使用嵌入模型推荐的度量。 + +**追问**:pgvector 中 `<#>` 返回什么?——负内积,所以 `ORDER BY embedding <#> q` 是内积从大到小。 + +### 【基础】什么是 ANN?召回率怎么算? + +**参考答案**:近似最近邻,用索引结构换取速度,结果不保证精确。召回率 = 近似检索的 top-k 和精确检索的 top-k 的重合数 / k。上线前要用自己的数据测试。 + +**追问**:召回率 0.95 够不够?→ 取决于业务:RAG 后面还有重排和生成,0.95 通常足够;关键是要在自己的数据上测出召回率和延迟的关系,再按延迟预算选参数。 + +## 进阶 + +### 【进阶】HNSW 的原理和关键参数? + +**参考答案**:多层图结构,上层稀疏、下层稠密。查询从最上层的入口点开始,贪心地走向更近的邻居,逐层下降,在最底层用大小为 `ef_search` 的候选队列搜索。参数:`M` 每个节点的连接数(大 → 召回高、内存大),`ef_construction` 建索引时的候选数(大 → 索引质量高、建索引慢),`ef_search` 查询时的候选数(大 → 召回高、延迟高)。 + +**追问**:HNSW 和 IVF 怎么选?——HNSW 查询性能和召回通常更好、支持增量插入,但内存大;IVF 建索引快、省内存(配合 PQ 更省),需要先有数据训练,数据分布变化后要重建。 + +### 【进阶】带过滤条件的向量检索有什么问题? + +**参考答案**:近似索引先找全局近邻再过滤,满足条件的数据占比很小时,结果可能不足 k 个。解决:数据库的过滤遍历能力(pgvector 0.8 的迭代扫描、Milvus 的标量过滤)、增大候选数、按租户分区、小数据量直接精确检索。 + +**追问**:pgvector 0.8 的迭代扫描是什么?→ 开启 `hnsw.iterative_scan` 后,如果过滤后结果不足,索引会继续往下扫描更多候选,直到凑够结果或达到扫描上限(`hnsw.max_scan_tuples`),参数以官方文档为准。 + +### 【进阶】更换嵌入模型要怎么做? + +**参考答案**:新旧模型的向量空间不兼容,必须全量重新嵌入。做法:新增列 / 新集合存新向量,后台回填,用评测集对比效果,切换读路径,保留回滚能力,最后清理旧数据。记录每个块使用的模型版本。 + +**追问**:切换期间新文档写入怎么办?→ 双写:切换期间新文档同时生成新旧两种向量,回填只处理存量;切换读路径后再停止写旧向量。 + +## 场景题 + +### 【场景题】SaaS 产品多租户共用向量表,怎么防止数据串租户? + +**参考答案**:数据访问层统一封装检索函数,`tenant_id` 必填;数据库层启用行级安全(RLS)兜底,应用使用非表所有者的角色连接,每个事务设置租户变量(连接池下必须用事务级);测试用例覆盖跨租户访问;日志审计。详见[多租户向量索引场景](scenarios/multi-tenant-index.md)。 + +**追问**:大租户和小租户检索性能差别很大怎么办?——分层:大租户单独分区 / 索引,小租户精确检索或开启迭代扫描。 + +### 【场景题】线上向量检索延迟从 50ms 涨到 500ms,怎么排查? + +**参考答案**:先看是检索本身慢还是嵌入接口慢(分段计时);检索慢:执行计划是否走了向量索引(运算符类是否匹配)、索引是否能放进内存(`shared_buffers` / 内存不足导致大量磁盘读)、`ef_search` 是否被调大、过滤条件是否导致大量扫描、数据量是否增长、是否有大批量写入在重建索引。对照监控的时间点找变化。 + +**追问**:怎么提前发现这种问题?→ 监控检索 P95/P99 延迟、每次查询扫描的行数、索引大小和内存命中率;给慢查询设置日志阈值;数据量增长到一定程度前做容量评估。 diff --git a/topics/ai/vector-db/scenarios/multi-tenant-index.md b/topics/ai/vector-db/scenarios/multi-tenant-index.md new file mode 100644 index 0000000..b77b533 --- /dev/null +++ b/topics/ai/vector-db/scenarios/multi-tenant-index.md @@ -0,0 +1,89 @@ +--- +id: ai.vector-db.scenario.multi-tenant-index +title: 多租户知识库的向量索引:隔离、过滤召回和增量更新 +type: scenario +domain: ai +stack: [vector-db, postgresql] +tags: [多租户, 元数据过滤, 增量更新, 召回率, SaaS] +level: advanced +prerequisites: [ai.vector-db.selection-pgvector] +est_hours: 2.5 +related: [ai.rag.scenario.permission-aware-kb, ai.vector-db.interview] +week: [6] +--- + +# 多租户知识库的向量索引:隔离、过滤召回和增量更新 + +## 业务背景与问题 + +一个面向中小企业的 SaaS 客服产品,每个企业(租户)上传自己的知识库。上线三个月后出现: + +1. 一个小租户(2000 个块)提问,经常返回“没有找到相关资料”,但资料明明存在; +2. 一次代码改动漏了租户条件,A 公司的客服看到了 B 公司的产品价格; +3. 大租户每天更新上千篇文档,删除的文档还会被检索到; +4. 产品想换一个效果更好的嵌入模型,不知道怎么切换。 + +## 约束 + +- 租户数千个,大小差异很大(几千到几百万块)。 +- 数据存储在 PostgreSQL + pgvector,不想引入新的组件。 +- 租户之间的数据绝对不能串。 + +## 方案设计 + +1. **隔离方式按租户大小分层**: + - 普通租户共用一张表,每行带 `tenant_id`; + - 超大租户单独分区(PostgreSQL 声明式分区或单独的表),有自己的 HNSW 索引。 +2. **问题 1(小租户召回不足)的原因**:全表 HNSW 索引先找全局近邻,再过滤 `tenant_id`,小租户的数据很少出现在全局候选中。解决:开启 pgvector 0.8 的迭代索引扫描 `SET hnsw.iterative_scan = relaxed_order`;或者对小租户直接走精确检索(`tenant_id` 索引过滤后计算距离,几千条很快)。 +3. **问题 2(越权)**:租户条件不能依赖每个查询“记得加”。数据访问层封装统一的检索函数,`tenant_id` 是必填参数;数据库层用 **行级安全(RLS)** 兜底,连接时 `SET app.tenant_id`。 +4. **问题 3(增量更新)**:每个块带 `doc_id`、`version`、`content_hash`;文档更新在一个事务里删除旧块、插入新块;文档删除同步删除块;内容哈希不变的块跳过重新嵌入,节省成本。 +5. **问题 4(换嵌入模型)**:新增一列(或新表)存新模型的向量,后台批量回填,回填完成后对比评测集,再切换读路径,最后删除旧列。块表记录 `embed_model` 字段。 + +## 关键代码 + +```sql +-- 行级安全:即使查询漏了 WHERE tenant_id,也只能看到当前租户的数据 +ALTER TABLE chunks ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON chunks + USING (tenant_id = current_setting('app.tenant_id')); +-- 注意:表的所有者和超级用户默认绕过 RLS,应用要用单独的普通角色连接, +-- 或者对表执行 ALTER TABLE chunks FORCE ROW LEVEL SECURITY。 + +-- 每个请求(事务)开始时设置租户 +BEGIN; +SELECT set_config('app.tenant_id', 'tenant-42', true); -- true 表示只在当前事务有效 +SET LOCAL hnsw.iterative_scan = relaxed_order; -- pgvector 0.8+,过滤后结果不足时继续扫描 +SELECT id, content FROM chunks +ORDER BY embedding <=> $1 +LIMIT 10; +COMMIT; + +-- 文档更新:在一个事务里替换 +BEGIN; +DELETE FROM chunks WHERE tenant_id = $1 AND doc_id = $2; +INSERT INTO chunks (tenant_id, doc_id, version, content, embedding) VALUES (...); +COMMIT; +``` + +过滤查询和召回率对比的可运行示例:[examples/vector-stores/pgvector_demo.py](../examples/vector-stores/pgvector_demo.py)。 + +## 取舍 + +| 方案 | 优点 | 缺点 | +| --- | --- | --- | +| 共享表 + `tenant_id` 过滤 | 简单,租户多时管理成本低 | 过滤召回问题;越权风险靠 RLS 兜底 | +| 每租户一张表 / 一个集合 | 隔离彻底,索引小 | 租户上千时表和索引数量爆炸 | +| **分层(本方案)** | 兼顾 | 有两套路径,代码复杂一点 | +| 独立数据库(大客户私有化) | 合规要求最高时 | 成本最高 | + +## 易错点 + +- 用表所有者账号连接,RLS 不生效却以为生效了。 +- `set_config(..., false)` 设置的是会话级变量,连接池复用连接时会串到下一个请求;应该用事务级(`true`)或每次请求都重新设置。 +- 删除文档只删了数据库里的记录,没删向量库 / ES 里的块(多存储时)。 +- 换嵌入模型时新旧向量混在一起检索。 +- 只用大租户做测试,没发现小租户的召回问题。评测集要按租户规模分组。 + +## 面试怎么讲 + +> “多租户向量检索我遇到过两个问题:一是小租户召回不足,原因是全局 HNSW 先找近邻再过滤,小租户的数据很少进入候选,我用 pgvector 0.8 的迭代扫描和小租户走精确检索解决;二是租户隔离,我在数据访问层强制要求 tenant_id,数据库层用行级安全兜底,并且注意连接池下要用事务级变量。增量更新按 doc_id 在事务里替换,内容哈希没变的块不重新嵌入。” diff --git a/topics/backend/data-pipeline/index.md b/topics/backend/data-pipeline/index.md new file mode 100644 index 0000000..bfb4993 --- /dev/null +++ b/topics/backend/data-pipeline/index.md @@ -0,0 +1,15 @@ +--- +id: backend.data-pipeline.index +title: 数据管道与第三方接口对接 +type: index +domain: backend +stack: [data-pipeline] +tags: [] +week: [14] +--- + +# 数据管道与第三方接口对接 + +批处理与流处理、失败重试、幂等、数据质量告警、第三方 API 限流与字段变化。 + +> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。 diff --git a/topics/backend/elasticsearch/index.md b/topics/backend/elasticsearch/index.md new file mode 100644 index 0000000..752dfd8 --- /dev/null +++ b/topics/backend/elasticsearch/index.md @@ -0,0 +1,15 @@ +--- +id: backend.elasticsearch.index +title: Elasticsearch +type: index +domain: backend +stack: [elasticsearch] +tags: [] +week: [6, 14] +--- + +# Elasticsearch + +倒排索引、中文分词、BM25、与向量的混合检索、数据同步。 + +> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。 diff --git a/topics/backend/gin/interview.md b/topics/backend/gin/interview.md index 0a3e90e..1276946 100644 --- a/topics/backend/gin/interview.md +++ b/topics/backend/gin/interview.md @@ -20,6 +20,8 @@ week: [1, 2, 3, 16] **参考答案**:Gin 的 `Engine` 实现了 `http.Handler` 接口,底层仍然由 `net/http` 负责监听和连接管理。Gin 增加了基于前缀树的高性能路由、`gin.Context`(封装请求和响应、参数获取、绑定、中间件间传值)、中间件链和常用工具(JSON 渲染、校验)。所以 Gin 可以和任何标准库中间件、`httptest` 一起使用。 +**追问**:Gin 的 `*gin.Context` 和标准库的 `context.Context` 是什么关系?→ `gin.Context` 是 Gin 自己的请求上下文(参数、响应、中间件链),它也实现了 `context.Context` 接口;向下游传递取消信号时,用 `c.Request.Context()` 更明确。 + ### 【基础】Gin 怎么获取路径参数、查询参数和请求体? **参考答案**:路径参数 `c.Param("id")`;查询参数 `c.Query("k")` / `c.DefaultQuery("k", "默认")`;请求体用绑定 `c.ShouldBindJSON(&req)`,查询参数也可以 `c.ShouldBindQuery(&q)` 绑定到结构体。绑定时根据结构体的 `binding` 标签做校验。 @@ -30,10 +32,14 @@ week: [1, 2, 3, 16] **参考答案**:按注册顺序依次执行,形成洋葱模型:`c.Next()` 之前的代码按顺序执行,之后的代码按相反顺序执行。`c.Next()` 立即执行后续的处理函数;`c.Abort()` 阻止后续处理函数执行,但当前函数会继续执行完,所以通常 `Abort` 后紧跟 `return`。 +**追问**:在中间件里 `c.Abort()` 之后还需要 `return` 吗?→ 需要。`Abort` 只是阻止后续处理函数执行,当前函数后面的代码仍会继续执行。 + ### 【基础】RESTful 接口的状态码怎么选? **参考答案**:查询成功 200、创建成功 201、删除成功 204、参数错误 400、未认证 401、已认证但无权限 403、不存在 404、状态冲突(如重复创建)409、请求过多 429、服务器错误 500、下游不可用 502/503。 +**追问**:参数校验失败用 400 还是 422?→ 两种都常见,团队统一即可;关键是错误响应格式统一、带上具体的字段错误信息。 + ## 进阶 ### 【进阶】JWT 和 Session 有什么区别?JWT 怎么实现退出登录? @@ -46,24 +52,36 @@ week: [1, 2, 3, 16] **参考答案**:Gin 用对象池复用 `Context`,请求处理结束后它会被重置给下一个请求使用。如果后台 goroutine 还持有原来的 `c`,读到的可能是别的请求的数据。`c.Copy()` 生成只读副本。更好的做法是只把需要的值(用户 ID、请求 ID、`c.Request.Context()` 派生的 context)传给后台任务。 +**追问**:goroutine 里需要请求的取消信号怎么办?→ 后台任务通常不应该跟随请求取消(请求结束就被取消),用 `context.WithoutCancel(c.Request.Context())` 或新的 context 并设置独立超时。 + ### 【进阶】如何设计统一的错误处理? **参考答案**:定义业务错误类型(HTTP 状态 + 业务码 + 用户提示),处理函数通过 `c.Error(err)` 记录错误并返回,由一个错误中间件统一转换成标准响应;未知错误和 panic 统一返回通用提示,详细信息带请求 ID 写日志。详见本技术栈的场景文档。 +**追问**:错误码和 HTTP 状态码是什么关系?→ HTTP 状态码表达大类(客户端错误 / 服务端错误),业务错误码表达具体原因,方便前端展示和排查;不要所有错误都返回 200。 + ### 【进阶】Gin 的路由为什么快? **参考答案**:Gin 使用 httprouter 风格的前缀树(radix tree),每个 HTTP 方法一棵树,按路径公共前缀压缩存储,匹配时间与路径长度相关,基本不受路由数量影响;同时 `Context` 用对象池减少内存分配。 +**追问**:路由冲突(例如 `/users/:id` 和 `/users/new`)会怎样?→ 新版本的 Gin 已经支持静态路由和参数路由共存,静态路由优先;旧版本会在注册时 panic。以使用的版本为准,写测试验证。 + ## 场景题 ### 【场景题】接口被恶意刷,怎么在 Gin 里做限流? **参考答案**:单实例用令牌桶(`golang.org/x/time/rate`),按用户 ID 或 IP 维护一个限流器 map(注意定期清理);多实例部署需要全局限流,用 Redis(固定窗口 `INCR+EXPIRE` 或滑动窗口 ZSet、Lua 脚本保证原子)。超限返回 429 并带 `Retry-After`。更外层可以在 Nginx 或网关做 IP 级限流,应用层做用户级、接口级限流。本计划第 3 周项目一实现过。 +**追问**:多实例部署时限流怎么做?→ 单机限流(`x/time/rate`)只能限制单个实例;全局限流要用 Redis(滑动窗口 / 令牌桶 + Lua 原子执行)或在网关层统一做。 + ### 【场景题】一个上传接口偶尔导致服务内存暴涨,怎么处理? **参考答案**:限制请求体大小(`http.MaxBytesReader` 或 Gin 的 `MaxMultipartMemory`,Nginx 的 `client_max_body_size`);大文件流式处理而不是一次读入内存;校验文件类型和大小;上传改成前端直传对象存储(预签名 URL),后端只接收回调。 +**追问**:大文件上传怎么做更好?→ 前端直传对象存储(服务端签发预签名 URL),服务端只处理元数据;需要服务端处理时用流式读取,不要整个读进内存。 + ### 【场景题】怎么给 Gin 服务写接口测试? **参考答案**:把路由组装放在一个函数里(如 `New(deps) *gin.Engine`),测试时注入内存版依赖;用 `httptest.NewRecorder()` 和 `r.ServeHTTP(w, req)` 直接调用,不启动端口;用表格驱动覆盖成功、参数错误、未登录、无权限、不存在、内部错误等情况;数据库相关的用集成测试单独跑。示例见 [examples/unified-api](examples/unified-api/)。 + +**追问**:测试需要数据库怎么办?→ 业务逻辑通过接口依赖存储层,单元测试用假实现;集成测试用真实数据库(测试库或容器),每个测试使用独立的数据并清理。 diff --git a/topics/backend/go/interview.md b/topics/backend/go/interview.md index 3dddd6c..49647e9 100644 --- a/topics/backend/go/interview.md +++ b/topics/backend/go/interview.md @@ -32,6 +32,8 @@ week: [1, 3, 16] **参考答案**:需要修改接收者、或者结构体很大(避免拷贝)时用指针接收者;小的、不可变的值类型可以用值接收者。同一个类型的方法风格保持一致。注意:指针接收者的方法只属于 `*T` 的方法集,所以只有 `*T` 实现了相关接口。 +**追问**:同一个类型的方法接收者可以混用吗?→ 语法上可以,但一般建议统一:只要有一个方法需要指针接收者,就全部用指针接收者,避免实现接口时出现困惑。 + ### 【基础】Go 为什么没有异常?错误怎么处理? **参考答案**:Go 把错误当成普通返回值,强制调用方显式处理,控制流清楚。用 `fmt.Errorf("...: %w", err)` 包装错误加上下文,用 `errors.Is` / `errors.As` 判断。`panic` 只用于不可恢复的错误,HTTP 服务用中间件 `recover` 兜底。 @@ -62,10 +64,14 @@ week: [1, 3, 16] **参考答案**:在调用链上传递取消信号、截止时间和请求范围的值。HTTP 请求结束或超时,所有带这个 context 的数据库查询、下游调用都会及时停止,避免资源浪费。规范:作为第一个参数传递、不要存进结构体、不要传 nil(用 `context.TODO()`)、`WithValue` 只放请求范围的元数据(如请求 ID),不放业务参数。 +**追问**:context 里适合存什么值?→ 只存请求范围的元数据(请求 ID、用户身份、追踪信息),不要用它传递业务参数;key 用自定义的未导出类型,避免冲突。 + ### 【进阶】什么是数据竞争?怎么发现和避免? **参考答案**:多个 goroutine 同时访问同一变量且至少一个是写,又没有同步。用 `go test -race` / `go run -race` 发现(只能发现实际执行到的路径)。避免:用 channel 传递所有权、`sync.Mutex` / `RWMutex` 加锁、`sync/atomic` 原子操作;并发读写 map 会直接 fatal。 +**追问**:`-race` 能在生产环境开吗?→ 一般不开:会显著增加 CPU 和内存开销。在测试和预发环境开启,CI 中跑 `go test -race`。 + ## 场景题 ### 【场景题】一个接口要并发调用 3 个下游服务,整体 2 秒超时,任一失败就返回错误,怎么写? @@ -83,3 +89,5 @@ week: [1, 3, 16] ### 【场景题】服务发布时如何做到不丢请求? **参考答案**:优雅关机:监听 SIGTERM,调用 `http.Server.Shutdown(ctx)` 停止接收新连接并等待处理中的请求完成(带超时);后台 worker 通过 context 收到取消信号,处理完当前任务再退出;最后关闭数据库连接池。部署侧配合负载均衡的健康检查,先摘流量再停进程。在本计划第 1 周第 7 天实现过。 + +**追问**:Kubernetes 环境下还要注意什么?→ 收到 SIGTERM 后先让就绪探针失败,等待负载均衡摘掉流量(或配置 preStop 等待),再关闭服务;`terminationGracePeriodSeconds` 要大于程序的优雅退出超时。 diff --git a/topics/backend/grpc-microservices/interview.md b/topics/backend/grpc-microservices/interview.md index ffaca3e..d57fa03 100644 --- a/topics/backend/grpc-microservices/interview.md +++ b/topics/backend/grpc-microservices/interview.md @@ -18,46 +18,68 @@ week: [14, 16] **参考答案**:gRPC 基于 HTTP/2,用 Protobuf 二进制序列化,有 IDL 和代码生成,强类型契约,支持流式调用;REST 通常基于 HTTP/1.1 + JSON,可读性好、浏览器原生支持、生态通用。内部服务之间、追求性能和契约时用 gRPC;对外开放、前端直接调用用 REST。 +**追问**:浏览器能直接调用 gRPC 吗?→ 不能直接调用(浏览器无法完全控制 HTTP/2 帧),需要 gRPC-Web + 代理,或者网关把 gRPC 转成 HTTP/JSON(如 grpc-gateway)。 + ### 【基础】gRPC 有哪几种调用方式? **参考答案**:一元调用(一请求一响应)、服务端流(一请求多响应,如推送进度)、客户端流(多请求一响应,如分片上传)、双向流(如聊天)。 +**追问**:流式调用适合什么场景?→ 服务端流适合推送进度、大模型逐字输出;客户端流适合上传大量数据;双向流适合实时交互。流式调用的负载均衡和重试比一元调用复杂。 + ### 【基础】Protobuf 字段编号有什么规则? **参考答案**:编号决定序列化后的字段标识,发布后不能修改、不能复用;删除字段用 `reserved` 保留编号和名字;新增字段使用新编号,对老客户端兼容(老客户端会忽略未知字段)。1~15 编码只占一个字节,留给高频字段。 +**追问**:删除一个字段要怎么做?→ 不要复用它的编号和名称,用 `reserved` 声明保留,防止以后被误用导致新旧版本解析错乱。 + ### 【基础】微服务相比单体有什么优缺点? **参考答案**:优点是独立部署、独立扩容、技术栈可以不同、故障隔离、团队可以独立开发。缺点是网络调用带来的延迟和失败、分布式事务和数据一致性、运维和监控复杂度、调试和排查困难。团队小、业务早期通常模块化单体更合适。 +**追问**:团队只有几个人,适合微服务吗?→ 一般不适合。先做模块化单体(模块边界清晰、独立的数据访问层),等团队和业务规模需要独立部署和扩展时再拆。 + ## 进阶 ### 【进阶】什么是服务雪崩?怎么防止? **参考答案**:一个下游服务变慢,调用方的线程 / 连接被占住,进而调用方也变慢,最终一层层传导导致整个系统不可用。防止:所有调用设超时;熔断故障的下游;限流保护自己;降级非核心功能;隔离资源(不同下游用不同的连接池 / 并发限制);重试要有限次数并退避。 +**追问**:限流和熔断的区别?→ 限流保护自己,限制进入的请求量;熔断保护调用方,下游故障时快速失败,不再持续调用。 + ### 【进阶】讲一下熔断器的状态机。 **参考答案**:关闭状态正常放行并统计失败;失败达到阈值(次数或比例)后打开,直接返回失败;冷却时间后进入半开,放少量请求试探;试探成功则关闭,失败则重新打开。只把下游故障(超时、不可用)计为失败,业务错误不算。 +**追问**:半开状态放行多少请求?→ 少量探测请求(一个或几个),成功率达标才关闭熔断器,否则重新打开;具体阈值根据业务配置。 + ### 【进阶】重试要注意什么? **参考答案**:只重试可重试的错误(如 `Unavailable`)和幂等操作;指数退避加随机抖动;限制次数,并受整体截止时间约束;只在一层做重试,避免逐层放大;熔断打开时不重试;非幂等操作需要幂等键。 +**追问**:什么样的请求可以重试?→ 幂等的请求,以及明确可重试的错误(超时、连接失败、`UNAVAILABLE`);非幂等请求要配合幂等键才能重试。 + ### 【进阶】分布式事务有哪些方案?怎么选? **参考答案**:2PC 强一致但性能差、协调者单点,很少用于互联网业务;TCC 通过 Try/Confirm/Cancel 预留资源,一致性较好但业务侵入大;Saga 把长事务拆成本地事务 + 补偿,适合流程长的业务;可靠消息(发件箱 + MQ + 幂等)实现最终一致,最常用。一般优先“可靠消息 + 对账”,对资金等强一致场景再考虑 TCC。 +**追问**:Saga 的补偿失败怎么办?→ 补偿操作本身要幂等、可重试;多次失败后进入人工处理队列并告警,所有步骤记录状态便于对账。 + ## 场景题 ### 【场景题】订单服务调用库存服务,库存服务偶尔超时,应该怎么处理? **参考答案**:设置合理的超时(小于订单接口自身的超时);扣库存接口要求幂等(带订单号作为幂等键),这样超时后可以安全重试一两次;超时结果不确定时,不能直接认为失败——通过查询接口或后续对账确认;持续超时时熔断,下单接口返回“系统繁忙”;排查库存服务本身的慢查询或资源问题。 +**追问**:超时时间怎么设置?→ 根据下游的 P99 延迟设定,并且上游总超时要大于下游超时之和(或通过 deadline 传递剩余时间),避免上游已经放弃,下游还在执行。 + ### 【场景题】团队想把单体系统改成微服务,你怎么推进? **参考答案**:先明确要解决的问题,能用模块化、读写分离、缓存解决的不急于拆;选边界清晰、需求明确的模块先拆;用绞杀者模式:先在单体内抽接口,再实现远程服务,用开关灰度切流,随时可回退;数据分库通过双写或 CDC 迁移并校验;同时建设超时熔断、链路追踪、集中日志等基础设施。详见[单体拆分场景](scenarios/monolith-split.md)。 +**追问**:拆分时数据怎么迁移?→ 先拆代码不拆库,再拆库:新服务通过接口访问数据;数据迁移用双写或 CDC 同步,校验一致后切换读,再切换写。 + ### 【场景题】一个请求经过 5 个服务,用户反馈偶尔很慢,怎么定位? **参考答案**:靠链路追踪:入口生成 trace ID,各服务通过 OpenTelemetry 记录 span 并传递上下文,在追踪系统里按耗时筛选慢请求,看是哪一个 span 慢;再结合该服务的日志(带 trace ID)和指标(CPU、GC、连接池、数据库慢查询)找原因。没有追踪系统时,至少要在日志里统一打印请求 ID。 + +**追问**:没有链路追踪系统怎么办?→ 至少在入口生成请求 ID,所有服务透传并写进日志,用日志平台按请求 ID 搜索,并在每个服务记录调用耗时。 diff --git a/topics/backend/java-spring/index.md b/topics/backend/java-spring/index.md new file mode 100644 index 0000000..2604f6e --- /dev/null +++ b/topics/backend/java-spring/index.md @@ -0,0 +1,15 @@ +--- +id: backend.java-spring.index +title: Java / Spring Boot(了解级) +type: index +domain: backend +stack: [java-spring] +tags: [] +week: [14] +--- + +# Java / Spring Boot(了解级) + +了解级:全栈岗位常见要求,能读懂代码、知道分层和常用注解。 + +> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。 diff --git a/topics/backend/mongodb/index.md b/topics/backend/mongodb/index.md new file mode 100644 index 0000000..c7c736f --- /dev/null +++ b/topics/backend/mongodb/index.md @@ -0,0 +1,15 @@ +--- +id: backend.mongodb.index +title: MongoDB +type: index +domain: backend +stack: [mongodb] +tags: [] +week: [14] +--- + +# MongoDB + +文档模型、查询与聚合、索引、何时选 MongoDB 而不是关系库。 + +> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。 diff --git a/topics/backend/mq/interview.md b/topics/backend/mq/interview.md index 0c6f72b..baa6843 100644 --- a/topics/backend/mq/interview.md +++ b/topics/backend/mq/interview.md @@ -18,46 +18,68 @@ week: [14, 16] **参考答案**:异步(非核心步骤不阻塞主流程,接口更快)、解耦(新增下游不用改上游)、削峰(突发流量先进队列,按能力消费)。问题:系统更复杂,多了一个要维护的组件;消息可能重复、丢失、乱序,要处理;数据变成最终一致;排查链路变长。 +**追问**:什么情况下不该用消息队列?→ 调用方需要立即拿到结果、链路简单、数据量小的场景,用同步调用更简单;引入 MQ 的复杂度要和收益匹配。 + ### 【基础】RabbitMQ 有哪几种交换机? **参考答案**:direct(routing key 完全匹配)、topic(通配符匹配,`*` 匹配一个词、`#` 匹配零个或多个词)、fanout(广播给所有绑定队列)、headers(按消息头匹配)。 +**追问**:topic 交换机中 `order.*` 和 `order.#` 有什么区别?→ `*` 只匹配一个词,`order.*` 匹配 `order.created`,不匹配 `order.paid.notify`;`#` 匹配零个或多个词,两者都匹配。 + ### 【基础】什么是死信队列? **参考答案**:消息被拒绝且不重新入队、TTL 过期、队列达到长度上限时,会被转发到队列配置的死信交换机,再路由到死信队列。用于集中处理失败消息,也可以和 TTL 组合实现延迟消息。 +**追问**:死信队列里的消息怎么处理?→ 告警 + 人工分析原因;修复后由运维工具重新投递到原队列;记录处理结果,避免无限循环投递。 + ### 【基础】Kafka 的分区和消费组是什么关系? **参考答案**:主题分成多个分区;同一个消费组里,一个分区同一时间只能被一个消费者消费,一个消费者可以消费多个分区;不同消费组互不影响、各自消费全部消息。所以组内并行度上限是分区数。 +**追问**:消费者数量多于分区数会怎样?→ 多出来的消费者空闲,不会分到分区;想提高并行度要增加分区数(Kafka 分区只能增加不能减少,且会影响按 key 的顺序)。 + ## 进阶 ### 【进阶】怎么保证消息不丢失? **参考答案**:分三段看。生产端:开启确认(RabbitMQ publisher confirm、Kafka `acks=all`),失败重试,必要时用发件箱模式保证和数据库事务一致。broker:持久化 + 多副本(RabbitMQ quorum 队列;Kafka 副本数 >=3、`min.insync.replicas>=2`)。消费端:处理完成后再手动 ack / 提交位移。这样是“至少一次”,消费端还要幂等。 +**追问**:发件箱模式的轮询会不会重复投递?→ 会,所以是“至少一次”;消费端必须幂等。多实例轮询时用 `FOR UPDATE SKIP LOCKED` 避免重复取同一批。 + ### 【进阶】怎么保证消费幂等? **参考答案**:利用业务状态的条件更新(如 `WHERE status='pending'`);或者用消息 ID 去重表,在同一个事务里插入去重记录(唯一约束)并执行业务;或者在业务表上建唯一约束(如同一订单只发一次积分)。Redis 去重可以作为前置过滤,但关键业务要以数据库约束为准。 +**追问**:唯一约束和 Redis 去重怎么选?→ 数据库唯一约束(和业务操作同一个事务)最可靠;Redis 去重性能高但和业务操作不在一个事务里,要处理“记录了去重但业务失败”的情况。 + ### 【进阶】怎么保证消息顺序? **参考答案**:Kafka 只保证分区内有序,把需要有序的消息用同一个 key(如订单 ID)发到同一分区,消费端单线程处理该分区或按 key 分发到固定线程。RabbitMQ 同一个队列单个消费者时有序,多个消费者并发时无序,可按 key 拆到多个队列。另外也可以在消息里带版本号 / 状态,消费端丢弃过期的消息,降低对顺序的依赖。 +**追问**:全局严格有序需要怎么做?→ 只能用单分区、单消费者,吞吐会受限;绝大多数业务只需要“同一订单 / 同一用户”内有序,按 key 分区即可。 + ### 【进阶】Kafka 为什么吞吐量高? **参考答案**:顺序写磁盘(追加日志);利用操作系统页缓存;零拷贝(sendfile)发送数据给消费者;生产和消费都支持批量和压缩;分区让读写可以分布到多台 broker 并行。 +**追问**:Kafka 的消息会丢吗?→ 配置不当会:`acks=1` 时 leader 宕机可能丢;消费端先提交位移再处理,处理失败会丢。可靠配置见“怎么保证消息不丢失”。 + ## 场景题 ### 【场景题】下单后要扣库存、发积分、发通知,怎么保证订单和消息的一致性? **参考答案**:用发件箱模式:订单和待发消息在同一事务里写入数据库,后台程序(或 CDC)把消息投递到 MQ,成功后标记;下游消费者幂等。这样订单提交了消息一定会发出(至少一次)。如果是强一致的跨服务操作(比如扣库存必须和下单同时成功),可以用 TCC 或 Saga 补偿,但复杂度高,大部分业务用“可靠消息 + 最终一致 + 对账”。 +**追问**:为什么不直接在业务事务里发消息?→ 发消息是网络调用,事务提交和消息发送无法原子化:先发后提交可能发了消息事务却回滚,先提交后发可能发送失败。发件箱表把消息和业务数据放在同一个事务里。 + ### 【场景题】线上消息积压了几百万条,怎么处理? **参考答案**:先看消费者是否正常(是否卡在某条毒消息、下游是否变慢、是否频繁再均衡)。修复问题后扩容消费者(Kafka 要看分区数是否够),提高消费并发、批量处理。积压特别大时,写一个临时程序把消息快速转存到分区更多的新主题,用大量消费者并行处理。同时评估:过期没有意义的消息(如过时的通知)可以跳过。事后加上积压监控告警。 +**追问**:临时扩容消费者没用是为什么?→ 消费者数量已达到分区数上限,或者瓶颈在下游(数据库、第三方接口)。要先找到真正的瓶颈。 + ### 【场景题】订单 30 分钟未支付自动取消,怎么设计? **参考答案**:RabbitMQ 用 TTL + 死信队列(或延迟插件)发送延迟消息;消费者用条件更新 `WHERE status='pending'` 取消并在同一事务回补库存,和支付回调天然互斥、重复消息幂等;保留低频的兜底扫描任务防止消息丢失。详见[延迟取消场景](scenarios/delayed-order-cancel.md)。 + +**追问**:订单在取消前被支付了怎么办?→ 取消时用条件更新(`WHERE status = '待支付'`),状态已变则什么都不做;支付回调同样用条件更新,两者竞争时只有一个成功。 diff --git a/topics/backend/mysql/interview.md b/topics/backend/mysql/interview.md index 88972b8..386daa0 100644 --- a/topics/backend/mysql/interview.md +++ b/topics/backend/mysql/interview.md @@ -20,6 +20,8 @@ week: [2, 14, 16] **参考答案**:B+ 树的非叶子节点只存键,一个页能放更多键,树更矮,磁盘 IO 次数少;所有数据在叶子节点且叶子之间用链表相连,范围查询和排序高效。红黑树是二叉树,数据多时太高;哈希只支持等值查询,不支持范围和排序。 +**追问**:一棵 3 层的 B+ 树大约能存多少行?→ 粗略估算:16KB 页,非叶子节点每条约十几字节,可放一千多个指针;叶子页按每行 1KB 放 16 行,约 1000 × 1000 × 16 ≈ 两千万行。这是数量级估算,实际取决于行大小。 + ### 【基础】什么是聚簇索引、回表和覆盖索引? **参考答案**:InnoDB 的主键索引叶子节点存整行数据,叫聚簇索引;二级索引叶子节点存索引列和主键值。通过二级索引找到主键再去主键索引取整行叫回表。如果查询需要的列都在二级索引里,就不用回表,叫覆盖索引(EXPLAIN 的 Extra 显示 Using index)。 @@ -30,42 +32,62 @@ week: [2, 14, 16] **参考答案**:联合索引 `(a, b, c)` 先按 a 排序,a 相同再按 b,依此类推。查询条件要从 a 开始连续使用才能利用索引定位;只用 b 或 c 用不上;中间遇到范围条件(如 `a > 1`)后,后面的列一般不能再用于定位(可以靠索引下推过滤)。 +**追问**:`WHERE b = 1 AND a = 2` 能用上 (a, b) 索引吗?→ 能,优化器会调整条件顺序;最左前缀看的是条件中是否包含最左列,不看书写顺序。 + ### 【基础】有哪些常见的索引失效情况? **参考答案**:索引列上用函数或运算;隐式类型转换(字符串列用数字比较);`LIKE` 以 `%` 开头;不满足最左前缀;`OR` 连接的列有的没索引;优化器判断走索引不如全表扫描(区分度低、要扫的行太多)。 +**追问**:怎么确认索引有没有用上?→ 用 `EXPLAIN` 看 `type`、`key`、`rows`、`Extra`;MySQL 8.0.18+ 可以用 `EXPLAIN ANALYZE` 看实际执行情况。 + ### 【基础】redo log、undo log、binlog 分别是什么? **参考答案**:redo log 是 InnoDB 的物理日志,记录“某页做了什么修改”,保证崩溃后已提交的事务不丢(持久性);undo log 记录修改前的值,用于回滚和 MVCC 读旧版本(原子性、隔离性);binlog 是 Server 层的逻辑日志,用于主从复制和按时间点恢复。redo log 和 binlog 用两阶段提交保证一致。 +**追问**:为什么需要两阶段提交?→ redo log 和 binlog 是两份日志,必须保证一致:否则崩溃恢复后主库数据和从库(通过 binlog 复制)不一致。 + ## 进阶 ### 【进阶】InnoDB 的可重复读是怎么实现的?能解决幻读吗? **参考答案**:普通 SELECT 是快照读,事务第一次读时生成 Read View,之后一直用它判断数据版本是否可见,所以多次读结果一致,也看不到别人新插入的行。当前读(`FOR UPDATE`、`UPDATE`)读最新数据,通过临键锁(记录锁 + 间隙锁)阻止其他事务在范围内插入,从而避免幻读。但如果事务里先快照读、再当前读,仍可能看到不一致,所以不能说“完全解决”。 +**追问**:间隙锁会带来什么问题?→ 锁住的范围比实际修改的行大,并发插入会被阻塞,也更容易死锁;如果业务能接受,可以使用读已提交隔离级别减少间隙锁。 + ### 【进阶】什么情况下会死锁?怎么排查和避免? **参考答案**:两个事务以不同顺序锁定相同的资源,互相等待。InnoDB 会检测并回滚代价小的那个事务(错误 1213)。排查:`SHOW ENGINE INNODB STATUS` 的 LATEST DETECTED DEADLOCK 段,看两个事务各自持有和等待的锁。避免:固定加锁顺序(如按 id 排序)、缩小事务、更新条件走索引减少锁范围、必要时降低到读已提交减少间隙锁;应用层捕获死锁并重试。 +**追问**:死锁发生后 MySQL 怎么处理?→ InnoDB 检测到死锁后回滚代价较小的事务,另一个继续执行;应用层要捕获死锁错误并重试整个事务。 + ### 【进阶】主从延迟的原因和处理方式? **参考答案**:原因:大事务、从库重放并行度低、从库负载高或配置差、网络。处理:拆分大事务、开启并行复制、关键读走主库(写后一段时间内读主库)、监控延迟超过阈值时把读流量切回主库。 +**追问**:刚写入就读不到,业务上怎么处理?→ 写后立即读的请求走主库(或一段时间内该用户的读走主库);或者前端直接使用写接口的返回结果。 + ### 【进阶】什么时候需要分库分表?分片键怎么选? **参考答案**:单表数据量大到索引优化后仍然慢、或单库写入成为瓶颈,并且归档、缓存、读写分离都用过之后再考虑。分片键选最常用的查询维度(如用户 ID),让大部分请求只访问一个分片,并且数据分布均匀。代价是跨分片查询、分页、事务、全局 ID 和扩容迁移都变复杂。 +**追问**:分库分表后跨分片查询怎么办?→ 尽量通过分片键路由;后台统计类查询同步到 ES 或数仓;避免跨分片 JOIN 和分布式事务。 + ## 场景题 ### 【场景题】“先查库存再扣减”在高并发下超卖了,怎么改? **参考答案**:查和扣两步之间有并发窗口。改成一条条件更新:`UPDATE stock SET num = num - 1 WHERE sku_id = ? AND num >= 1`,判断影响行数;或者 `SELECT ... FOR UPDATE` 加锁后再扣。并发非常高时(秒杀),数据库行锁会成为瓶颈,在 Redis 里预扣库存、消息队列异步落库(见 Redis 专题的库存场景和跨栈场景“秒杀”)。 +**追问**:用乐观锁还是 `UPDATE ... WHERE stock >= 1`?→ 扣库存这种只需要一个条件的场景,直接条件更新最简单;需要读出数据做复杂计算后再写入的,用版本号乐观锁。 + ### 【场景题】一张几千万行的表,分页越往后越慢,怎么优化? **参考答案**:`LIMIT offset` 需要扫描并丢弃前面的行。改为游标分页(记住上一页最后一条的排序键和 id);需要跳页时用延迟关联并限制最大页数;导出走异步任务;历史数据归档。详见[深分页场景](scenarios/deep-pagination.md)。 +**追问**:用户要求可以跳转到任意页,怎么办?→ 深分页限制最大页数;或者用延迟关联(先在覆盖索引上查主键,再回表);超深分页的需求通常可以引导为搜索 / 筛选。 + ### 【场景题】线上一个接口突然变慢,数据库 CPU 很高,你怎么排查? **参考答案**:先 `SHOW PROCESSLIST` 看正在执行的慢语句和状态(是否大量 Waiting for lock);查慢查询日志按总耗时排序;对可疑 SQL 做 EXPLAIN,看是不是最近上线的新语句、索引被删、统计信息不准导致走错索引;有锁等待时查 `information_schema` / `performance_schema` 中的锁信息找到阻塞源。处理上可以先限流或下线问题功能止血,再加索引或改 SQL,最后复盘加监控。 + +**追问**:怎么防止慢查询再次发生?→ 上线前审查 SQL 和 `EXPLAIN`;开启慢查询日志并告警;对大表的变更进行评审。 diff --git a/topics/backend/nodejs/index.md b/topics/backend/nodejs/index.md new file mode 100644 index 0000000..6363077 --- /dev/null +++ b/topics/backend/nodejs/index.md @@ -0,0 +1,15 @@ +--- +id: backend.nodejs.index +title: Node.js 后端(Express / NestJS) +type: index +domain: backend +stack: [nodejs] +tags: [] +week: [7, 14] +--- + +# Node.js 后端(Express / NestJS) + +了解级:前端出身的后端选项,Express 中间件、NestJS 模块与依赖注入。 + +> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。 diff --git a/topics/backend/postgresql/interview.md b/topics/backend/postgresql/interview.md index d791d84..6e1e6d0 100644 --- a/topics/backend/postgresql/interview.md +++ b/topics/backend/postgresql/interview.md @@ -20,22 +20,32 @@ week: [2, 16] **参考答案**:`WHERE` 在分组前过滤行,不能使用聚合函数;`HAVING` 在 `GROUP BY` 之后过滤分组,可以使用聚合函数。能用 `WHERE` 过滤的尽量用 `WHERE`,减少参与分组的数据量。 +**追问**:`WHERE` 中能使用 SELECT 里定义的别名吗?→ 不能,因为 `WHERE` 在 `SELECT` 之前执行;`ORDER BY` 可以使用别名。 + ### 【基础】`INNER JOIN` 和 `LEFT JOIN` 的区别?LEFT JOIN 的条件写在 ON 和 WHERE 里有什么不同? **参考答案**:内连接只保留两边都匹配的行;左连接保留左表所有行,右表没匹配的列为 NULL。右表的过滤条件写在 `ON` 里时,不匹配的左表行仍然保留;写在 `WHERE` 里会把右表为 NULL 的行过滤掉,效果等同内连接。 +**追问**:怎么查询“没有下过订单的用户”?→ `LEFT JOIN orders ... WHERE orders.id IS NULL`,或者 `NOT EXISTS (SELECT 1 FROM orders WHERE ...)`。 + ### 【基础】为什么 `NOT IN` 遇到 NULL 会出问题? **参考答案**:`x NOT IN (a, b, NULL)` 等价于 `x <> a AND x <> b AND x <> NULL`,而 `x <> NULL` 的结果是 UNKNOWN,整个条件永远不为真,查询返回空。用 `NOT EXISTS` 或先过滤掉 NULL。 +**追问**:`NOT EXISTS` 为什么没有这个问题?→ `NOT EXISTS` 只判断子查询是否有行,不对值做三值逻辑比较,NULL 不会影响结果。 + ### 【基础】金额应该用什么类型?时间呢? **参考答案**:金额用 `NUMERIC(p, s)` 或以“分”为单位的整数,不用浮点数(有精度误差)。时间用 `TIMESTAMPTZ`,统一存 UTC,展示时按用户时区转换。 +**追问**:`timestamptz` 存储的是什么?→ 存储的是 UTC 时间点,显示时按会话时区转换;`timestamp`(不带时区)只是一个“墙上时间”,不知道是哪个时区。 + ### 【基础】什么是 UPSERT?为什么比“先查再插”安全? **参考答案**:`INSERT ... ON CONFLICT (唯一键) DO UPDATE / DO NOTHING`,一条语句完成“有则更新、无则插入”。它依赖唯一约束在数据库内部原子判断;“先查再插”在并发下两个请求都可能查不到然后都插入。 +**追问**:`ON CONFLICT DO NOTHING` 怎么知道是否插入了?→ 加 `RETURNING id`,没有返回行就说明冲突了。 + ## 进阶 ### 【进阶】PostgreSQL 的 MVCC 是怎么实现的?VACUUM 是干什么的? @@ -48,24 +58,36 @@ week: [2, 16] **参考答案**:默认读已提交。可重复读下,事务看到的是开始时的快照;如果要更新的行在快照之后被其他事务修改并提交了,会报 `could not serialize access due to concurrent update`(SQLSTATE 40001),应用需要重试整个事务。 +**追问**:可重复读下序列化失败怎么处理?→ 应用层捕获错误码 `40001`,重试整个事务;事务尽量短,减少冲突。 + ### 【进阶】一条 SQL 很慢,你怎么排查? **参考答案**:先用 `EXPLAIN (ANALYZE, BUFFERS)` 看执行计划:是不是全表扫描、预估行数和实际行数是否差距大(统计信息过时就 `ANALYZE`)、哪个节点耗时最多。常见优化:按查询条件建联合索引(注意最左前缀和排序方向)、避免在索引列上用函数、用覆盖索引、改写子查询、分页改成游标(按上一页最后一个 ID 继续)。线上用 `pg_stat_statements` 找出总耗时最高的语句。 +**追问**:`EXPLAIN` 和 `EXPLAIN ANALYZE` 的区别?→ 前者只显示计划和估算;后者真正执行 SQL 并显示实际行数和耗时。对写操作使用 `EXPLAIN ANALYZE` 要放在事务里回滚。 + ### 【进阶】`FOR UPDATE SKIP LOCKED` 有什么用? **参考答案**:选中并锁定行,遇到已被其他事务锁住的行直接跳过。适合“多个 worker 并发从任务表取任务”:每个 worker 拿到不同的任务,互不阻塞,不需要额外的消息队列。 +**追问**:除了任务队列还能用在哪?→ 多个工作进程分配待处理的数据(如待发送通知、待同步记录),每个进程取到不同的行,互不阻塞。 + ## 场景题 ### 【场景题】订单表有几千万行,按用户查最近订单的分页越往后越慢,怎么优化? **参考答案**:`OFFSET` 越大,数据库要扫描并丢弃的行越多。改成游标分页(keyset):`WHERE user_id = ? AND (created_at, id) < (上一页最后一条的值) ORDER BY created_at DESC, id DESC LIMIT 20`,配合 `(user_id, created_at DESC, id DESC)` 联合索引,每页都是索引范围扫描。代价是不能直接跳到第 N 页,通常用“加载更多”交互。更大规模时按时间分区,历史数据归档。 +**追问**:游标分页怎么支持“上一页”?→ 用当前页第一条的排序值反向查询(条件和排序方向都反过来),再把结果倒序。 + ### 【场景题】线上要给一张大表加索引和加字段,怎么做才不影响业务? **参考答案**:索引用 `CREATE INDEX CONCURRENTLY`(不阻塞写,不能在事务里执行,失败要删掉无效索引重建)。加字段:PostgreSQL 11 起加带常量默认值的列不需要重写表,很快;但要注意 DDL 需要短暂的排他锁,如果前面有长事务会排队并阻塞后续查询,所以设置 `lock_timeout`,在低峰期执行。迁移脚本要向后兼容:先加字段、发新代码,再清理旧字段。 +**追问**:`CREATE INDEX CONCURRENTLY` 失败了怎么办?→ 会留下一个标记为 INVALID 的索引,要先删除再重新创建;它不能在事务块里执行。 + ### 【场景题】用户和支付回调同时修改订单状态,怎么保证状态正确? **参考答案**:状态流转规则集中定义,修改用带版本号(或旧状态)的条件 UPDATE 做乐观锁,影响行数为 0 时视为冲突,重新读取最新状态再决定;审计日志与状态修改放在同一事务。冲突频繁的场景可以用 `SELECT ... FOR UPDATE` 排队。详见本技术栈的[订单状态机场景](scenarios/order-state-audit.md)。 + +**追问**:为什么不用 `SELECT ... FOR UPDATE` 悲观锁?→ 也可以,适合冲突很多的场景;冲突少时乐观锁或条件更新不需要持有锁,并发更好。关键是状态迁移要有条件判断。 diff --git a/topics/backend/redis/interview.md b/topics/backend/redis/interview.md index da8c344..44d0c0b 100644 --- a/topics/backend/redis/interview.md +++ b/topics/backend/redis/interview.md @@ -18,24 +18,34 @@ week: [3, 14, 16] **参考答案**:数据在内存里;核心命令执行是单线程的,没有锁竞争和线程切换;使用 IO 多路复用(epoll)同时处理大量连接;数据结构针对场景做了优化(如压缩列表、跳表)。Redis 6 起网络读写可以多线程,命令执行仍是单线程。 +**追问**:Redis 6 的多线程是怎么回事?→ 多线程只用于网络读写(解析请求、写回响应),命令执行仍然是单线程,所以命令的原子性不变。 + ### 【基础】Redis 有哪些数据类型?各举一个使用场景。 **参考答案**:String(缓存、计数器、验证码)、Hash(对象、购物车)、List(最新消息列表)、Set(标签、去重、共同关注)、ZSet(排行榜、延迟队列)、Stream(轻量消息队列);另有 Bitmap(签到)、HyperLogLog(UV 统计)、Geo(附近的人)。 +**追问**:排行榜为什么用 ZSet?→ 有序集合按分数自动排序,插入和更新都是 O(log N),可以直接按排名范围查询。 + ### 【基础】过期的 key 是怎么删除的?内存满了怎么办? **参考答案**:惰性删除(访问时发现过期就删)+ 定期删除(周期性抽样检查过期 key)。内存达到 `maxmemory` 时按淘汰策略处理:`noeviction`(拒绝写入)、`allkeys-lru`、`allkeys-lfu`、`volatile-lru`(只淘汰设置了过期的 key)等,缓存场景常用 `allkeys-lru` / `allkeys-lfu`。 +**追问**:大 key 有什么危害?→ 读写和删除都会阻塞主线程、占用网络带宽、集群中导致数据倾斜。删除大 key 用 `UNLINK`(异步删除),并拆分数据结构。 + ### 【基础】RDB 和 AOF 的区别? **参考答案**:RDB 定时生成内存快照,文件小、恢复快,但可能丢失最后一次快照之后的数据;AOF 追加记录写命令,可配置每秒刷盘,丢数据更少,但文件更大,需要重写压缩。生产常两者都开,Redis 4 起支持混合持久化。 +**追问**:生产环境一般怎么配置持久化?→ 常见是开启 AOF(`everysec`)+ RDB 备份,Redis 4.0+ 可以使用混合持久化;只做缓存的场景可以不开持久化。 + ## 进阶 ### 【进阶】缓存穿透、击穿、雪崩分别是什么?怎么解决? **参考答案**:穿透是查询不存在的数据,每次都打到数据库——缓存空值、参数校验、布隆过滤器。击穿是热点 key 过期瞬间大量请求同时回源——互斥锁 / singleflight 只让一个请求回源,或逻辑过期后台刷新。雪崩是大量 key 同时过期或 Redis 不可用——过期时间加随机值、Redis 高可用、服务限流降级。 +**追问**:布隆过滤器有什么缺点?→ 存在误判(判断存在时可能不存在),不能删除元素(普通布隆过滤器);数据变化时需要重建。 + ### 【进阶】怎么保证缓存和数据库的一致性? **参考答案**:常用旁路缓存:先更新数据库,再删除缓存。删除而不是更新,是为了避免并发写导致缓存写入旧值。仍有极小概率不一致,可以用延迟双删、订阅 binlog 异步删除、短 TTL 兜底。缓存只能做到最终一致,要求强一致的数据(余额、库存最终值)以数据库为准。 @@ -46,20 +56,30 @@ week: [3, 14, 16] **参考答案**:`SET key 随机令牌 NX PX 过期时间` 加锁;用 Lua 脚本比较令牌后删除来解锁,防止误删别人的锁。问题:业务执行时间超过过期时间会导致两个持有者同时存在(续期 / 看门狗);主从异步复制,主节点宕机时锁可能丢失(Redlock 有争议);所以关键业务还要用数据库唯一约束、乐观锁等做最终保证。 +**追问**:锁过期了业务还没执行完怎么办?→ 自动续期(看门狗);业务本身做好幂等和数据库层面的条件更新,不能完全依赖锁的正确性。 + ### 【进阶】Redis 的主从、哨兵、集群分别解决什么问题? **参考答案**:主从复制解决读扩展和数据冗余;哨兵(Sentinel)监控主节点,故障时自动选新主,解决高可用;Cluster 把数据按 16384 个槽分到多个主节点,解决容量和写扩展,每个主节点也可以有从节点。Cluster 下多 key 操作(包括 Lua)要求 key 在同一个槽,可以用哈希标签 `{}`。 +**追问**:集群模式下多个 key 的操作有什么限制?→ 多 key 命令和 Lua 脚本要求 key 在同一个槽,用哈希标签 `{order}:1` 让相关 key 落在同一个槽。 + ## 场景题 ### 【场景题】秒杀场景怎么防止超卖和重复购买? **参考答案**:库存提前加载到 Redis;Lua 脚本原子完成“是否已购买 → 库存是否充足 → 扣减 → 记录用户”;成功后发 MQ 异步创建订单,数据库唯一约束兜底;失败或超时未支付回补库存;前端、网关层层限流。详见[库存预扣场景](scenarios/stock-reservation.md)。 +**追问**:Redis 扣了库存但创建订单失败怎么办?→ 回补库存(补偿);或者 Redis 只做预扣,以数据库为准,异步对账修正。 + ### 【场景题】线上 Redis 突然变慢,怎么排查? **参考答案**:`SLOWLOG GET` 看慢命令(`KEYS *`、大 key 的 `HGETALL` / `DEL`、复杂 Lua);`INFO` 看内存、连接数、命中率、是否在做 RDB / AOF 重写(fork 导致的卡顿);`--bigkeys` / `MEMORY USAGE` 找大 key;检查是否有热点 key 集中在一个节点。处理:改用 `SCAN`、`UNLINK`,拆分大 key,热点 key 加本地缓存。 +**追问**:怎么找出慢命令?→ `SLOWLOG GET` 查看慢日志;`--bigkeys` / `MEMORY USAGE` 找大 key;`INFO` 看内存、客户端数、持久化状态。 + ### 【场景题】一个接口要按用户限流,每分钟最多 60 次,多实例部署,怎么做? **参考答案**:用 Redis 共享计数。简单做法是固定窗口 `INCR` + 过期时间;要求精确时用 ZSET 滑动窗口(Lua 脚本里删除过期记录、计数、写入);允许突发用令牌桶。返回 429 并带上重试时间。要考虑 Redis 故障时的策略(放行还是拒绝)。详见[分布式锁和限流](03-lock-ratelimit.md)。 + +**追问**:固定窗口有什么问题?→ 窗口边界处可能出现 2 倍的突发请求(上一个窗口末尾和下一个窗口开头各 60 次),需要平滑时用滑动窗口或令牌桶。 diff --git a/topics/cs/algorithms/index.md b/topics/cs/algorithms/index.md new file mode 100644 index 0000000..2446298 --- /dev/null +++ b/topics/cs/algorithms/index.md @@ -0,0 +1,15 @@ +--- +id: cs.algorithms.index +title: 数据结构与算法(面试) +type: index +domain: cs +stack: [algorithms] +tags: [] +week: [16] +--- + +# 数据结构与算法(面试) + +常考数据结构、复杂度、双指针、哈希、二分、BFS/DFS、动态规划入门。 + +> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。 diff --git a/topics/cs/network-os/index.md b/topics/cs/network-os/index.md new file mode 100644 index 0000000..9a41246 --- /dev/null +++ b/topics/cs/network-os/index.md @@ -0,0 +1,15 @@ +--- +id: cs.network-os.index +title: 网络与操作系统基础 +type: index +domain: cs +stack: [network-os] +tags: [] +week: [14, 16] +--- + +# 网络与操作系统基础 + +HTTP/HTTPS、TCP/IP、DNS、进程线程、内存、IO 模型。 + +> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。 diff --git a/topics/cs/web-security/index.md b/topics/cs/web-security/index.md new file mode 100644 index 0000000..35f4b0b --- /dev/null +++ b/topics/cs/web-security/index.md @@ -0,0 +1,15 @@ +--- +id: cs.web-security.index +title: Web 安全 +type: index +domain: cs +stack: [web-security] +tags: [] +week: [7, 14, 16] +--- + +# Web 安全 + +XSS、CSRF、SQL 注入、CORS、鉴权与会话安全、大模型应用的提示词注入。 + +> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。 diff --git a/topics/devops/cicd/interview.md b/topics/devops/cicd/interview.md index 70e9dbd..aeb8121 100644 --- a/topics/devops/cicd/interview.md +++ b/topics/devops/cicd/interview.md @@ -18,38 +18,56 @@ week: [14, 16] **参考答案**:CI(持续集成)是开发者频繁合并代码,每次合并自动执行检查、测试和构建,尽早发现集成问题;CD 包括持续交付(每次变更都自动产出可发布的产物,发布由人触发)和持续部署(自动发布到生产)。 +**追问**:持续交付和持续部署有什么区别?→ 持续交付:每次合并后都能随时发布,但上线需要人工确认;持续部署:通过所有检查后自动上线。 + ### 【基础】一条典型的流水线包括哪些阶段? **参考答案**:代码检查(格式、lint、类型检查)→ 单元测试和集成测试 → 构建(编译、打包、构建镜像)→ 安全扫描(依赖、镜像、密钥)→ 部署到测试环境 → (审批)→ 部署生产 → 部署后验证(健康检查、冒烟测试)。 +**追问**:单元测试和集成测试放在流水线的哪个位置?→ 快的检查放前面(格式、静态检查、单元测试),失败尽早停止;集成测试和镜像扫描放在后面,可以并行。 + ### 【基础】流水线里的密钥怎么管理? **参考答案**:存放在 CI 平台的 Secrets 或专门的密钥管理系统中,以环境变量注入,不写进仓库和镜像;按环境和最小权限划分;不要在日志里打印;来自外部 fork 的合并请求默认拿不到密钥;泄露后立即轮换。 +**追问**:第三方 Action / 插件有什么安全风险?→ 可能读取到流水线的密钥。固定到具体的提交哈希或可信版本,最小化令牌权限,优先使用 OIDC 短期凭证。 + ## 进阶 ### 【进阶】蓝绿发布、滚动发布、金丝雀发布有什么区别? **参考答案**:滚动发布逐台替换实例,资源占用少,但新旧版本会同时在线,回滚也要逐台;蓝绿发布准备一整套新环境,验证后一次性切换流量,回滚即切回,但需要双倍资源;金丝雀发布先把小比例流量导到新版本,观察错误率等指标后逐步扩大,风险最小,需要流量控制和监控能力。 +**追问**:金丝雀发布怎么判断能否继续放量?→ 对比新旧版本的错误率、延迟、关键业务指标,设定阈值;异常时自动或人工回滚。 + ### 【进阶】怎么让数据库变更也能回滚? **参考答案**:迁移脚本版本化,由工具执行;每次变更保持向后兼容,使新旧代码都能在新表结构上运行;破坏性变更拆成多次发布:先扩展(加字段 / 表),再迁移数据和切换读写,最后确认不会回滚后再收缩(删旧字段)。大表变更用在线 DDL 工具,并提前在预发环境演练。 +**追问**:删除字段为什么要分多次发布?→ 旧版本代码还在读这个字段,回滚时也需要它;先让代码不再使用,确认稳定后再删除字段(扩展 - 收缩模式)。 + ### 【进阶】流水线太慢,怎么优化? **参考答案**:缓存依赖(按锁文件哈希作为 key);任务并行;按改动路径只运行相关部分;Docker 构建利用层缓存;把慢的端到端测试放到合并后或定时执行;拆分测试并行运行;用更快的执行机器。 +**追问**:依赖缓存的 key 怎么设计?→ 用锁文件的哈希(`go.sum`、`uv.lock`、`package-lock.json`),依赖变化时缓存自动失效。 + ## 场景题 ### 【场景题】新版本上线后错误率升高,你怎么处理? **参考答案**:先止血:确认是否与本次发布相关(时间点吻合),是的话立即回滚到上一个镜像标签,或关闭对应的特性开关;同时保留日志和错误信息。恢复后再定位原因,补充测试,复盘为什么没有在测试阶段发现。前提是平时就有一键回滚的能力,并且数据库变更向后兼容。 +**追问**:回滚之后还要做什么?→ 确认指标恢复;保留现场(日志、版本、变更内容);复盘根因,补充测试或告警,防止再次发生。 + ### 【场景题】你们团队的发布流程是怎样的?(如果之前是前端,可以怎么回答) **参考答案**:按真实情况讲流程:合并请求触发 lint / 测试 / 构建,评审通过且检查通过后合并;合并后自动构建产物并部署到测试环境;生产发布由负责人触发,发布后观察监控,有问题回滚。前端可以讲:构建产物上传 CDN、`index.html` 不缓存、带哈希的资源长缓存、通过切换入口文件回滚。再补充你在转岗项目里实现的后端流水线(本专题的示例)。不要编造没做过的经历。 +**追问**:没有参与过发布流程怎么办?→ 如实说明前端的发布方式(构建、上传静态资源、CDN 刷新、回滚),再讲在学习项目中搭建的后端流水线和自己的设计考虑。 + ### 【场景题】线上运行的是哪个版本的代码?怎么确认? **参考答案**:镜像标签使用提交哈希或语义化版本,部署记录里保存每次部署的标签;服务在健康检查或版本接口里返回构建时注入的版本号和提交哈希(Go 用 `-ldflags "-X main.version=..."`);日志和错误上报里带上版本号。这样出问题时能准确定位到代码。 + +**追问**:镜像标签为什么不用 `latest`?→ `latest` 会变化,无法确定线上具体是哪个版本,回滚也不可靠;用 Git 提交哈希或版本号做标签。 diff --git a/topics/devops/docker/interview.md b/topics/devops/docker/interview.md index 855558d..b3be0a9 100644 --- a/topics/devops/docker/interview.md +++ b/topics/devops/docker/interview.md @@ -18,42 +18,62 @@ week: [3, 14, 16] **参考答案**:虚拟机虚拟化硬件,每个虚拟机有自己完整的操作系统内核,隔离性强但启动慢、占用大;容器共享宿主机内核,靠 Linux 的 namespace 做隔离(进程、网络、文件系统等)、cgroups 限制资源,启动快、占用小,但隔离性比虚拟机弱。 +**追问**:容器的隔离靠什么实现?→ Linux 的 namespace(隔离进程、网络、文件系统等视图)和 cgroups(限制 CPU、内存等资源);容器共享宿主机内核,隔离程度弱于虚拟机。 + ### 【基础】镜像和容器是什么关系?镜像为什么要分层? **参考答案**:镜像是只读模板,容器是镜像运行起来的实例,在镜像之上加了一个可写层。分层让多个镜像可以共享相同的底层(节省磁盘和下载),构建时没有变化的层可以复用缓存。 +**追问**:修改 Dockerfile 中间的一行,为什么后面的层都要重新构建?→ 每一层的缓存依赖它之前的所有层,前面的层变了,后面的缓存全部失效。所以把变化少的步骤(安装依赖)放在前面。 + ### 【基础】`CMD` 和 `ENTRYPOINT` 的区别?`COPY` 和 `ADD` 呢? **参考答案**:`ENTRYPOINT` 指定容器启动时固定执行的程序;`CMD` 提供默认参数或默认命令,`docker run` 后面的参数会覆盖 `CMD`。两者都推荐使用 exec 形式(JSON 数组),程序作为 PID 1 才能收到信号。`COPY` 只复制本地文件;`ADD` 还能自动解压 tar 和下载 URL,行为不直观,一般用 `COPY`。 +**追问**:`ENTRYPOINT` 用 shell 形式会有什么问题?→ shell 形式由 `/bin/sh -c` 启动,进程不是 PID 1,收不到 SIGTERM,无法优雅退出;应该使用 exec 形式(JSON 数组)。 + ### 【基础】容器里的数据怎么持久化? **参考答案**:容器可写层的数据随容器删除而消失。持久化用数据卷(Docker 管理的命名卷,适合数据库)或绑定挂载(把宿主机目录挂进容器,适合开发时挂源码、挂配置文件)。 +**追问**:命名卷和绑定挂载怎么选?→ 数据库数据用命名卷(由 Docker 管理,可移植);开发时挂载源代码用绑定挂载。 + ## 进阶 ### 【进阶】怎么减小镜像体积、加快构建? **参考答案**:多阶段构建,运行阶段只保留产物;选小的基础镜像(slim、alpine、distroless);把变化少的步骤(安装依赖)放前面,充分利用层缓存;合并 RUN 并在同一层清理缓存(如 `pip install --no-cache-dir`);用 `.dockerignore` 排除无关文件。 +**追问**:多阶段构建为什么能减小体积?→ 编译工具和中间产物留在构建阶段,最终镜像只复制编译结果,基础镜像也可以用更小的 distroless / alpine。 + ### 【进阶】`docker stop` 时发生了什么?怎么做优雅退出? **参考答案**:Docker 向容器的 PID 1 发送 SIGTERM,等待超时(默认 10 秒)后发送 SIGKILL。程序要监听 SIGTERM,停止接受新请求、处理完进行中的请求、关闭连接后退出。前提是程序本身是 PID 1(exec 形式启动),否则信号被 shell 吞掉。 +**追问**:程序没有处理 SIGTERM 会怎样?→ 等待超时(默认 10 秒)后被 SIGKILL 强制杀掉,正在处理的请求会中断。 + ### 【进阶】Compose 中服务之间怎么通信?`depends_on` 能保证依赖服务已就绪吗? **参考答案**:同一个 Compose 项目的服务在同一个网络里,用服务名作为主机名访问。`depends_on` 默认只保证启动顺序,不保证就绪;要配合 `healthcheck` 和 `condition: service_healthy`,应用本身也要有连接重试。 +**追问**:怎么让应用等数据库真正就绪?→ 数据库配置 `healthcheck`,应用的 `depends_on` 使用 `condition: service_healthy`;应用本身也要有连接重试。 + ## 场景题 ### 【场景题】容器启动后马上退出,怎么排查? **参考答案**:`docker ps -a` 看退出码,`docker logs <容器>` 看输出。常见原因:主进程是后台进程(容器以前台进程为生命周期)、配置或环境变量缺失导致程序报错退出、二进制和基础镜像不兼容(CGO 程序放进 scratch)、权限问题(非 root 用户无权读文件)。可以用 `docker run -it --entrypoint sh 镜像` 进入容器手动执行排查。 +**追问**:容器反复重启,日志一闪而过怎么办?→ 已退出的容器日志仍然可以用 `docker logs 容器ID`(`docker ps -a` 找到 ID)或 `docker compose logs 服务名` 查看;`docker inspect` 看退出码;也可以临时把启动命令改成 `sleep infinity` 进入容器手动启动程序排查。 + ### 【场景题】应用容器连不上数据库容器,怎么排查? **参考答案**:确认连接地址用的是服务名而不是 localhost;两个容器是否在同一网络(`docker network inspect`);数据库是否真的就绪(健康检查、日志);端口是否是容器内端口而不是宿主机映射端口;用户名密码和数据库名是否和初始化时一致(数据卷已存在时环境变量不会重新生效)。 +**追问**:容器里的 `localhost` 指的是什么?→ 指容器自己,不是宿主机也不是其他容器;连接其他容器要用服务名,连接宿主机的方式随平台不同(如 `host.docker.internal`)。 + ### 【场景题】怎么避免把密钥打包进镜像? **参考答案**:不在 Dockerfile 里写密钥,不把 `.env` 等文件复制进镜像(`.dockerignore` 排除);运行时通过环境变量或密钥管理系统(Kubernetes Secret、云厂商的密钥服务)注入;构建时确实需要的密钥(如私有依赖仓库的 token)用 BuildKit 的 `--secret` 挂载,不会留在镜像层里;镜像推送前用扫描工具检查。 + +**追问**:构建时需要私有仓库的令牌怎么办?→ 使用 BuildKit 的 `--secret` 挂载,令牌只在构建步骤中可见,不会写入镜像层。 diff --git a/topics/devops/git/interview.md b/topics/devops/git/interview.md index 56c1a16..5872aac 100644 --- a/topics/devops/git/interview.md +++ b/topics/devops/git/interview.md @@ -18,42 +18,62 @@ week: [15, 16] **参考答案**:merge 把两条分支的历史合并,可能产生合并提交,保留真实的并行开发历史;rebase 把当前分支的提交在目标分支最新提交之后重新应用,历史变成一条直线,但会生成新的提交哈希。公共分支不要 rebase,个人功能分支可以用 rebase 同步主干、整理提交。 +**追问**:什么时候不能用 rebase?→ 已经推送到共享分支、别人基于它开发的提交不要 rebase,否则会改写别人依赖的历史。 + ### 【基础】`git revert` 和 `git reset` 有什么区别? **参考答案**:revert 生成一个新提交来抵消指定提交的改动,不改写历史,适合撤销已推送的提交;reset 移动分支指针到指定提交,`--soft` 保留改动在暂存区、`--mixed` 保留在工作区、`--hard` 丢弃改动,会改写历史,只适合未推送的本地提交。 +**追问**:已经推送的提交想撤销用哪个?→ 用 `git revert` 生成一个反向提交,不改写历史;`reset` 后强推会影响其他人。 + ### 【基础】`git fetch` 和 `git pull` 的区别? **参考答案**:fetch 只下载远程的最新提交,更新远程跟踪分支(如 `origin/main`),不改动本地分支;pull = fetch + merge(或配置为 rebase),会把远程改动合并到当前分支。习惯先 fetch 看清楚差异再决定合并方式。 +**追问**:`git pull --rebase` 有什么好处?→ 本地提交会接在远程最新提交之后,避免产生多余的合并提交,历史更清晰。 + ### 【基础】你们用什么分支模型? **参考答案**:按真实情况回答,并说明原因。例如 GitHub Flow:main 始终可发布,功能分支通过合并请求、CI 和评审后合并,合并后自动部署测试环境。有固定版本周期的(App、私有化交付)用 Git Flow 的 release / hotfix 分支。持续部署成熟的团队用主干开发 + 特性开关。 +**追问**:分支模型怎么选?→ 持续部署的团队适合主干开发(短期功能分支 + 快速合并);有固定版本发布周期的适合 Git Flow 类的发布分支模型。 + ## 进阶 ### 【进阶】误执行了 `git reset --hard`,提交还能找回吗? **参考答案**:只要曾经提交过就能找回。`git reflog` 记录了 HEAD 的每次移动,找到 reset 之前的提交哈希,`git reset --hard 哈希` 或 `git branch 新分支 哈希` 恢复。删除的分支同理。但从未提交、也没有 stash 的工作区改动无法通过 reflog 找回。 +**追问**:reflog 会一直保留吗?→ 不会,默认未引用的记录大约 30 天、可达的 90 天后被清理(可配置),而且 reflog 只在本地。 + ### 【进阶】`--force` 和 `--force-with-lease` 有什么区别? **参考答案**:`--force` 无条件用本地覆盖远程分支;`--force-with-lease` 只有当远程分支仍然是你上次获取时的状态才覆盖,如果别人在这期间推送了新提交就会拒绝,避免误覆盖同事的工作。改写过历史的个人分支推送时应该用后者。 +**追问**:强推前还应该做什么?→ 先 `git fetch` 并确认远程没有别人的新提交;重要分支应该设置保护,禁止强推。 + ### 【进阶】什么时候用 cherry-pick?有什么注意事项? **参考答案**:需要把某个提交单独应用到另一个分支时,例如热修复同时带到 release 和 main、把某个修复回移到旧版本。注意 cherry-pick 会生成新的提交(不同哈希),如果之后两个分支合并,可能出现重复改动或冲突;大量 cherry-pick 说明分支策略可能有问题。 +**追问**:cherry-pick 后两个分支有相同修改的不同提交,合并时会冲突吗?→ 内容相同通常能自动合并,但历史上是两个不同的提交;修改有差异时可能冲突。 + ## 场景题 ### 【场景题】有人把密钥提交到了远程仓库,怎么处理? **参考答案**:第一步立即作废并轮换密钥、检查是否被滥用;然后删除文件并加入 `.gitignore`;视情况用 `git filter-repo` 清理历史并强制推送,通知所有人重新克隆,联系平台清理缓存;最后加上提交前和 CI 中的密钥扫描。清理历史不能代替轮换。详见[热修复和密钥误提交场景](scenarios/hotfix-and-leaked-secret.md)。 +**追问**:从历史中删除了密钥,还需要做什么?→ 必须吊销(轮换)这个密钥,因为它可能已经被克隆或抓取;然后检查是否有异常使用记录。 + ### 【场景题】线上版本有紧急 bug,但 main 上已经有很多未发布的功能,怎么修? **参考答案**:从线上版本的标签或 release 分支拉热修复分支,修复并补测试,走 CI 后合入 release 分支发布补丁版本;再把修复 cherry-pick 或合并回 main,保证下个版本包含修复。不要从 main 拉分支修复,否则会把未测试的功能带上线。 +**追问**:修复之后 main 上怎么办?→ 把修复合并回 main(或 cherry-pick),避免下一次发布时问题重新出现。 + ### 【场景题】合并时遇到大量冲突,怎么处理? **参考答案**:先理解双方各自改了什么(`git log`、`git diff` 看两边的提交),逐个文件解决,不要一键全选某一方;解决后运行测试;冲突太多时可以和对方一起解决。根本上要避免长期不合并的分支:功能分支保持短小、经常同步主干。 + +**追问**:怎么减少冲突?→ 小步提交、频繁合并主干;避免同时大规模格式化;冲突较多的文件按模块拆分。 diff --git a/topics/devops/k8s/index.md b/topics/devops/k8s/index.md new file mode 100644 index 0000000..eefe3ad --- /dev/null +++ b/topics/devops/k8s/index.md @@ -0,0 +1,15 @@ +--- +id: devops.k8s.index +title: Kubernetes 基础 +type: index +domain: devops +stack: [k8s] +tags: [] +week: [14] +--- + +# Kubernetes 基础 + +Pod、Deployment、Service、Ingress、配置与密钥、探针、滚动发布。 + +> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。 diff --git a/topics/devops/linux/interview.md b/topics/devops/linux/interview.md index 35bd6f1..fbadafa 100644 --- a/topics/devops/linux/interview.md +++ b/topics/devops/linux/interview.md @@ -18,42 +18,62 @@ week: [14, 16] **参考答案**:`ss -lntp | grep 8080`(或旧命令 `netstat -lntp`、`lsof -i :8080`),输出里有进程名和 PID。没有 root 权限时可能看不到其他用户进程的信息。 +**追问**:`ss` 和 `netstat` 的区别?→ `ss` 更新、更快,直接从内核获取信息;`netstat` 在很多新系统上默认不再安装。 + ### 【基础】怎么实时查看日志?怎么查找包含某个关键字的行? **参考答案**:`tail -f app.log` 实时跟踪;`grep -n "ERROR" app.log` 查找并显示行号,`grep -C 5` 显示上下文,`grep -r` 递归目录;systemd 服务用 `journalctl -u 服务名 -f`。 +**追问**:日志文件很大,怎么只看某个时间段?→ 用 `sed -n '/开始时间/,/结束时间/p'` 或 `awk` 按时间字段过滤;systemd 服务用 `journalctl --since --until`。 + ### 【基础】`kill`、`kill -9`、`kill -15` 有什么区别? **参考答案**:`kill` 默认发送 SIGTERM(15),进程可以捕获它做清理后退出;`kill -9` 发送 SIGKILL,不能被捕获,进程立即被杀死,来不及释放资源。应该先 SIGTERM,等待超时后再 SIGKILL。 +**追问**:为什么不建议直接使用 `kill -9`?→ 程序无法清理资源(关闭连接、刷新缓冲、删除临时文件),可能导致数据不完整;先发 SIGTERM,等待超时后再用 -9。 + ### 【基础】软链接和硬链接的区别? **参考答案**:硬链接是同一个 inode 的另一个文件名,删除原文件后硬链接仍能访问,不能跨文件系统、不能链接目录;软链接是一个单独的文件,内容是目标路径,原文件删除后软链接失效,可以跨文件系统、可以链接目录。部署中常用软链接切换版本(`current -> releases/v2`)。 +**追问**:删除源文件后,软链接和硬链接会怎样?→ 软链接失效(指向的路径不存在);硬链接仍然可以访问数据,因为数据要等到所有硬链接都删除后才释放。 + ## 进阶 ### 【进阶】服务器负载很高,你怎么排查? **参考答案**:`uptime` 看 load 和核数对比;`top` 看 CPU 的 `us`(用户态计算)、`sy`(内核态)、`wa`(IO 等待);`wa` 高查磁盘(`iostat -x`、`iotop`),`us` 高找占用最高的进程和线程(`top -H -p`),再用语言工具(Go pprof、py-spy)定位到函数;同时看内存是否在用 swap。 +**追问**:负载高但 CPU 使用率低是什么情况?→ 负载包含等待 I/O(D 状态)的进程,可能是磁盘 I/O 瓶颈或 NFS 等存储问题,用 `iostat`、`vmstat` 查看 `wa`。 + ### 【进阶】TIME_WAIT 和 CLOSE_WAIT 过多分别说明什么? **参考答案**:TIME_WAIT 出现在主动关闭连接的一方,大量出现通常是频繁创建短连接,应该复用连接(keep-alive、连接池)。CLOSE_WAIT 表示对方已关闭、本方还没关闭,大量出现基本是程序没有关闭连接(例如 Go 没有 `resp.Body.Close()`),属于代码 bug。 +**追问**:CLOSE_WAIT 一般是谁的问题?→ 通常是自己程序的问题:对方已经关闭连接,自己没有调用 close(例如 HTTP 响应体没有关闭)。 + ### 【进阶】磁盘满了怎么处理? **参考答案**:`df -h` 确认哪个分区满,`df -i` 检查 inode 是否用尽(大量小文件);`du -sh /* | sort -h` 逐层找大目录;常见是日志、临时文件、Docker 镜像和容器日志。删除正在被进程写的文件不会释放空间,用 `lsof +L1` 查找,清空文件内容或重启进程。长期方案:logrotate、Docker 日志大小限制、磁盘使用率告警。 +**追问**:删除了大文件但磁盘空间没有释放,为什么?→ 文件还被进程打开着,用 `lsof +L1` 或 `lsof | grep deleted` 找到进程,重启进程或清空文件。 + ## 场景题 ### 【场景题】Go 服务内存持续上涨,最后被系统杀掉,怎么定位? **参考答案**:先在内核日志确认是 OOM(`journalctl -k | grep -i oom`);服务开启 pprof,在内存上涨过程中多次采集 heap,对比哪些对象在增长(`go tool pprof -inuse_space`、`-base` 对比);同时看 goroutine 数量是否持续增长(goroutine 泄漏也会占内存)。常见原因:无上限的缓存或 map、goroutine 阻塞泄漏、读取大文件 / 大响应到内存。修复后加内存告警和 `MemoryMax` 限制。 +**追问**:怎么确认是被 OOM Killer 杀掉的?→ `dmesg -T | grep -i oom` 或 `journalctl -k` 查看内核日志;容器中看退出码 137 和 OOMKilled 状态。 + ### 【场景题】用户反馈接口偶尔很慢,但你登录服务器时一切正常,怎么办? **参考答案**:需要事后可查的数据:Nginx 日志记录 `$request_time` 和 `$upstream_response_time`,用 awk 找出慢请求的时间点和接口;应用日志带请求 ID 和每一步耗时;数据库开慢查询日志;看云监控在对应时间点的 CPU、IO、网络曲线。根据时间点关联定时任务、备份、其他服务的批处理。长期方案是接入指标监控和链路追踪。 +**追问**:怎么让偶发问题留下证据?→ 增加带请求 ID 的结构化日志和慢请求日志;监控历史指标(CPU、GC、连接数);在问题出现时自动保存现场(如 pprof 数据)。 + ### 【场景题】怎么把一个 Go 程序部署成开机自启、崩溃自动重启的服务? **参考答案**:编写 systemd 单元文件:指定运行用户、工作目录、环境变量文件、`ExecStart`、`Restart=on-failure`、`RestartSec`、停止超时;`systemctl daemon-reload`、`enable --now`;日志用 `journalctl -u` 查看。程序本身要处理 SIGTERM 优雅退出。也可以用 Docker 的 `restart: unless-stopped` 实现类似效果。 + +**追问**:日志写到哪里?→ systemd 服务输出到标准输出,由 journald 收集,用 `journalctl -u 服务名` 查看;需要长期保存时配置日志轮转或采集到日志平台。 diff --git a/topics/devops/nginx/interview.md b/topics/devops/nginx/interview.md index e212dd9..a63885c 100644 --- a/topics/devops/nginx/interview.md +++ b/topics/devops/nginx/interview.md @@ -18,42 +18,62 @@ week: [7, 14, 16] **参考答案**:正向代理代表客户端访问外部服务器,服务器看到的是代理(例如公司上网代理);反向代理代表服务器接收客户端请求,客户端看到的是代理,不知道后端有哪些服务器。Nginx 常用作反向代理,做负载均衡、TLS 终止、静态资源、限流。 +**追问**:反向代理除了负载均衡还能做什么?→ HTTPS 终止、静态资源服务、缓存、限流、隐藏后端地址、统一的访问日志。 + ### 【基础】Nginx 的负载均衡策略有哪些? **参考答案**:轮询(默认)、加权轮询(`weight`)、最少连接(`least_conn`)、IP 哈希(`ip_hash`,同一客户端固定到同一后端)、通用哈希(`hash $key`,可配合一致性哈希)。通过 `max_fails`、`fail_timeout` 做被动健康检查。 +**追问**:怎么让同一个用户总是访问同一台后端?→ `ip_hash` 或一致性哈希(`hash $cookie_xxx consistent`);更好的做法是让服务无状态,会话存到 Redis。 + ### 【基础】location 的匹配优先级是怎样的? **参考答案**:先看 `=` 精确匹配;再找最长前缀匹配,如果它带 `^~` 就直接使用;否则按顺序匹配正则(`~`、`~*`),第一个匹配的生效;没有正则匹配就用最长前缀匹配。 +**追问**:`location /api` 和 `location /api/` 有什么区别?→ 前者也会匹配 `/apixyz`;配合 `proxy_pass` 时,结尾斜杠还会影响路径是否被替换。 + ### 【基础】前端单页应用部署到 Nginx 后刷新页面 404,为什么? **参考答案**:前端路由(history 模式)的路径在服务器上没有对应文件。用 `try_files $uri $uri/ /index.html;` 让找不到的路径都返回 index.html,交给前端路由处理。 +**追问**:`try_files` 会影响 API 请求吗?→ 如果 API 也在同一个 server 中,要给 `/api/` 单独写 location,否则不存在的 API 路径会返回 index.html 而不是 404。 + ## 进阶 ### 【进阶】502 和 504 有什么区别?分别怎么排查? **参考答案**:502 Bad Gateway 是 Nginx 连接后端失败或后端返回了无效响应——后端没启动、端口错、进程崩溃、连接被拒绝,看 Nginx 错误日志里的 `connect() failed` 等信息。504 Gateway Timeout 是后端在超时时间内没有响应——后端处理慢(慢查询、调用外部服务卡住),或者 `proxy_read_timeout` 设置太短(例如大模型长回答)。 +**追问**:怎么区分是 Nginx 的问题还是后端的问题?→ 看 Nginx 的 `error.log`(connect refused / upstream timed out)和访问日志中的 `$upstream_response_time`、`$upstream_status`。 + ### 【进阶】代理 SSE 和 WebSocket 分别要注意什么? **参考答案**:SSE 要关闭代理缓冲(`proxy_buffering off` 或后端返回 `X-Accel-Buffering: no`),否则流式内容被攒成一大段;读超时要放宽或者后端发心跳。WebSocket 需要 `proxy_http_version 1.1` 并传递 `Upgrade` 和 `Connection` 头完成协议升级,读超时也要调大。 +**追问**:WebSocket 连接一段时间后断开是什么原因?→ `proxy_read_timeout` 默认 60 秒,没有数据传输就会断开;调大超时或在应用层发送心跳。 + ### 【进阶】Nginx 怎么限流? **参考答案**:`limit_req_zone` 定义按某个 key(如 `$binary_remote_addr`)的请求速率,基于漏桶算法;`limit_req` 应用到 location,`burst` 允许排队的突发量,`nodelay` 让突发请求不排队立即处理;超出返回 `limit_req_status`(默认 503,常改为 429)。`limit_conn` 限制并发连接数。前面还有负载均衡时要用 `real_ip` 模块取真实客户端 IP。 +**追问**:`burst` 和 `nodelay` 是什么意思?→ `burst` 允许突发的排队数量;`nodelay` 让突发的请求立即处理而不是按速率排队,超过 burst 的请求直接被拒绝。 + ## 场景题 ### 【场景题】AI 问答的流式输出经过 Nginx 后变成一次性返回,怎么解决? **参考答案**:Nginx 默认对代理响应做缓冲。给流式接口单独 location,设置 `proxy_buffering off`、`proxy_cache off`,HTTP/1.1 长连接,`proxy_read_timeout` 调大;后端响应头加 `X-Accel-Buffering: no`;确认 gzip 没有压缩 `text/event-stream`;如果前面还有 CDN 或云负载均衡,也要确认它们支持流式。详见[AI 应用网关场景](scenarios/ai-gateway-sse-canary.md)。 +**追问**:除了 Nginx,还有哪些地方可能缓冲?→ CDN、云厂商的负载均衡、应用框架的中间件(如压缩中间件)都可能缓冲,要逐层检查;后端可以设置 `X-Accel-Buffering: no` 响应头。 + ### 【场景题】要让 10% 的用户先用新版本,Nginx 怎么做? **参考答案**:用 `split_clients` 根据用户标识(如用户 ID Cookie 或 IP)哈希出 10% 映射到新版本 upstream;或者 `upstream` 中用 `weight` 按比例分配;指定用户体验用 `map $cookie_xxx`。注意同一用户要稳定落在同一版本(按用户标识哈希,而不是随机)、新旧版本接口兼容、能一键回滚。 +**追问**:按百分比随机分流和按用户分流哪个好?→ 按用户(如用户 ID 的哈希)更好,同一个用户的体验一致,也便于排查问题;用 `split_clients` 对用户标识做分流。 + ### 【场景题】怎么做到发版时用户无感知(不中断请求)? **参考答案**:后端多实例滚动发布:先把一台实例从 upstream 摘除(或标记 `down`)并 reload Nginx,等它处理完存量请求后升级,再加回去,逐台进行;后端程序要支持优雅退出。Nginx 自身修改配置用 `nginx -s reload`,旧 worker 处理完已有连接再退出,不会中断请求。 + +**追问**:`nginx -s reload` 会中断连接吗?→ 不会:新 worker 使用新配置处理新请求,旧 worker 处理完已有请求后退出。但长连接(WebSocket)会在旧 worker 退出时断开(`worker_shutdown_timeout` 控制)。 diff --git a/topics/index.md b/topics/index.md index 3e592ee..199c52d 100644 --- a/topics/index.md +++ b/topics/index.md @@ -23,6 +23,11 @@ stack: [] | [Redis](backend/redis/index.md) | 数据结构、缓存模式、分布式锁、限流、Streams | 3、6、14 | | [消息队列(RabbitMQ / Kafka)](backend/mq/index.md) | 异步解耦、可靠投递、幂等消费、顺序、延迟消息 | 3、14 | | [gRPC 与微服务](backend/grpc-microservices/index.md) | proto、四种调用方式、超时重试、服务治理、分布式事务 | 14 | +| [MongoDB](backend/mongodb/index.md) | 文档模型、查询与聚合、索引、何时选 MongoDB 而不是关系库 | 14 | +| [Elasticsearch](backend/elasticsearch/index.md) | 倒排索引、中文分词、BM25、与向量的混合检索、数据同步 | 6、14 | +| [数据管道与第三方接口对接](backend/data-pipeline/index.md) | 批处理与流处理、失败重试、幂等、数据质量告警、第三方 API 限流与字段变化 | 14 | +| [Node.js 后端(Express / NestJS)](backend/nodejs/index.md) | 了解级:前端出身的后端选项,Express 中间件、NestJS 模块与依赖注入 | 7、14 | +| [Java / Spring Boot(了解级)](backend/java-spring/index.md) | 了解级:全栈岗位常见要求,能读懂代码、知道分层和常用注解 | 14 | ## 运维与工程(`devops`) @@ -33,6 +38,7 @@ stack: [] | [Linux 运维基础](devops/linux/index.md) | 常用命令、进程与端口、日志、磁盘、权限、线上排查 | 14 | | [CI/CD](devops/cicd/index.md) | 流水线设计、测试与构建、镜像发布、部署与回滚 | 3、7、14 | | [Git](devops/git/index.md) | 分支模型、提交规范、合并与变基、误操作恢复、协作流程 | 1、8、15 | +| [Kubernetes 基础](devops/k8s/index.md) | Pod、Deployment、Service、Ingress、配置与密钥、探针、滚动发布 | 14 | ## Python(`python`) @@ -55,6 +61,9 @@ stack: [] | [提示词与上下文工程](ai/prompt-engineering/index.md) | 提示词结构、结构化输出、上下文工程、提示词注入防护 | 5、11、13 | | [Agent 记忆、评测与可观测性](ai/agent-ops/index.md) | 短期 / 长期记忆、评测集、LLM 评分、追踪、成本 | 12、13 | | [Dify / Coze 低代码平台](ai/lowcode/index.md) | 知识库、工作流、插件,和写代码的取舍 | 13 | +| [Agent 设计:规划、上下文工程与多 Agent](ai/agent-design/index.md) | ReAct、Planning、Context Engineering、Skills、多 Agent 与 A2A、Agentic RAG | 10、11、13 | +| [模型接入与部署(了解级)](ai/llm-serving/index.md) | 多模型 API 路由与降级、vLLM 部署基础、LoRA / QLoRA 概念 | 10、13 | +| [AI 编程工具与产出验证](ai/ai-coding/index.md) | Claude Code / Cursor / Codex 工作流、Skills 与规则、评审和测试验证、会话记录作为作品证据 | 1、8、10、15 | ## 前端(`frontend`) @@ -67,3 +76,11 @@ stack: [] | [Webpack](frontend/webpack/index.md) | 核心概念、Loader 与 Plugin、拆包、构建优化 | 7 | | [SSR(Nuxt / Next.js)](frontend/ssr/index.md) | CSR / SSR / SSG、水合、Nuxt 与 Next.js 基础 | 7 | | [前端测试与性能](frontend/fe-testing-perf/index.md) | Vitest、组件测试、Web Vitals、性能优化 | 7 | + +## 计算机基础(`cs`) + +| 技术栈 | 内容 | 用在第几周 | +| --- | --- | --- | +| [数据结构与算法(面试)](cs/algorithms/index.md) | 常考数据结构、复杂度、双指针、哈希、二分、BFS/DFS、动态规划入门 | 16 | +| [网络与操作系统基础](cs/network-os/index.md) | HTTP/HTTPS、TCP/IP、DNS、进程线程、内存、IO 模型 | 14、16 | +| [Web 安全](cs/web-security/index.md) | XSS、CSRF、SQL 注入、CORS、鉴权与会话安全、大模型应用的提示词注入 | 7、14、16 | diff --git a/topics/python/asyncio/interview.md b/topics/python/asyncio/interview.md index ac217cd..5e74396 100644 --- a/topics/python/asyncio/interview.md +++ b/topics/python/asyncio/interview.md @@ -18,42 +18,62 @@ week: [4, 16] **参考答案**:进程有独立的内存空间,切换和通信开销大,能利用多核;线程共享进程内存,由操作系统抢占式调度,在 CPython 中受 GIL 限制不能并行执行 Python 代码;协程是用户态的、协作式调度的执行单元,在 `await` 处主动让出控制权,切换开销极小,单线程即可同时处理大量 IO 等待。 +**追问**:Python 多线程适合什么场景?→ I/O 密集且必须使用同步库的场景;CPU 密集任务因为 GIL 用多进程。 + ### 【基础】asyncio 适合什么场景?不适合什么场景? **参考答案**:适合 IO 密集、并发连接多的场景:调用大模型 API、HTTP 请求、数据库查询、WebSocket / SSE 长连接。不适合 CPU 密集计算(单线程,无法加速),这类任务用多进程或交给 C 扩展库。 +**追问**:CPU 密集的任务混在异步服务里怎么办?→ 用 `loop.run_in_executor` 放到进程池,或者交给单独的任务队列 / 服务处理,避免阻塞事件循环。 + ### 【基础】调用 `async def` 函数但没有 `await` 会怎样? **参考答案**:只会创建协程对象,函数体不会执行,Python 会给出 “coroutine was never awaited” 警告。这一点和 JS 不同——JS 的 async 函数调用后立即开始执行。 +**追问**:怎么发现忘记 await 的问题?→ 运行时会出现 `coroutine was never awaited` 的警告;类型检查工具(mypy / pyright)和 linter 也能检查出来。 + ### 【基础】为什么在协程里不能使用 `time.sleep` 或 `requests`? **参考答案**:事件循环是单线程的,阻塞调用会让整个线程停住,所有其他协程都无法运行,服务的并发能力退化为 1。应该用 `asyncio.sleep`、`httpx.AsyncClient` 等异步库;只能用同步库时用 `asyncio.to_thread` 放到线程池执行。 +**追问**:必须使用同步库怎么办?→ 用 `asyncio.to_thread()` 放到线程池执行,或者换成异步库(`httpx.AsyncClient`、`asyncpg` 等)。 + ## 进阶 ### 【进阶】`asyncio.gather` 和 `TaskGroup` 有什么区别? **参考答案**:`gather` 在某个任务抛异常时立即把异常抛给调用方,但其余任务继续运行;加 `return_exceptions=True` 则把异常作为结果返回。`TaskGroup`(3.11+)是结构化并发:一个任务失败时取消组内其他任务,等它们全部结束后,以 `ExceptionGroup` 抛出所有异常,不会留下“孤儿任务”。 +**追问**:`gather` 的 `return_exceptions=True` 有什么用?→ 一个任务异常时不会立即抛出,而是把异常作为结果返回,适合“部分失败可以接受”的批量调用。 + ### 【进阶】怎么限制并发数? **参考答案**:用 `asyncio.Semaphore(n)`,在每个任务的实际调用外层 `async with sem:`;或者用固定数量的 worker 从 `asyncio.Queue` 取任务。注意多个请求要共享同一个 Semaphore;多进程 / 多实例时需要 Redis 等全局限流。 +**追问**:信号量和限速有什么区别?→ 信号量限制同时进行的请求数;限速限制单位时间内的请求数(如每分钟 600 次),服务商通常两种都有限制,需要同时控制。 + ### 【进阶】asyncio 的取消机制是怎样的? **参考答案**:`task.cancel()` 请求取消,任务在下一个 `await` 点收到 `CancelledError`。任务可以捕获它做清理(关闭连接、记录状态),但应该重新抛出,否则取消失效。`asyncio.timeout` / `wait_for` 超时也是通过取消内部任务实现的。`CancelledError` 继承自 `BaseException`,所以 `except Exception` 不会误吞它。 +**追问**:捕获了 `CancelledError` 后应该怎么做?→ 做完清理后重新抛出;吞掉它会导致取消失效、`TaskGroup` / 超时无法正常工作。 + ## 场景题 ### 【场景题】需要对 1 万条文本调用大模型生成摘要,服务商限制每分钟 600 次请求,怎么设计? **参考答案**:用 Semaphore 或 worker 池限制并发,再加速率控制(令牌桶,每秒约 10 次);每次调用设超时;对 429、超时、5xx 做带抖动的指数退避重试,429 响应如果带 `Retry-After` 就按它等待;单条失败记录下来,最后汇总重跑;记录进度(已完成的 ID)以便中断后续跑;结果写入数据库而不是全放内存。规模更大时用消息队列分发给多个 worker。 +**追问**:中途失败的任务怎么办?→ 每条记录保存处理状态,失败的记录按错误类型重试(限流错误退避重试,内容错误记录跳过),支持断点续跑。 + ### 【场景题】FastAPI 接口里调用了一个同步的 SDK,发现服务在压测时吞吐很低,为什么?怎么改? **参考答案**:在 `async def` 路由里直接调用同步 SDK,阻塞了事件循环,所有请求排队。改法:换成 SDK 的异步版本;或者 `await asyncio.to_thread(sdk.call, ...)`;或者把路由定义为普通 `def`,FastAPI 会把它放到线程池执行。然后重新压测确认。 +**追问**:`def` 路由和 `async def` 路由在这里有什么区别?→ `def` 路由会被 FastAPI 放到线程池执行,不会阻塞事件循环;`async def` 中直接调用同步 SDK 会阻塞整个事件循环。 + ### 【场景题】一个异步任务在服务关闭时总是卡住,可能是什么原因? **参考答案**:任务里捕获了 `CancelledError` 却没有重新抛出(或者用了裸 `except:`),导致取消无效;或者任务在执行不响应取消的阻塞调用(同步 IO、`to_thread` 中的线程无法被强制停止);或者有后台任务没有被追踪和取消。修复:正确传播取消、给阻塞调用设超时、在应用的生命周期关闭阶段统一取消并等待后台任务。 + +**追问**:怎么给关闭过程设置上限?→ 用 `asyncio.timeout()` 或 `asyncio.wait_for()` 给关闭等待设置超时,超时后取消剩余任务并记录日志。 diff --git a/topics/python/fastapi/interview.md b/topics/python/fastapi/interview.md index 1fb877b..775294e 100644 --- a/topics/python/fastapi/interview.md +++ b/topics/python/fastapi/interview.md @@ -18,42 +18,62 @@ week: [4, 5, 16] **参考答案**:基于 Starlette(ASGI)和 pydantic,原生支持 async,适合 IO 密集的模型调用;通过类型标注自动完成参数解析和校验、生成 OpenAPI 文档;依赖注入方便组织鉴权、数据库会话和测试替换;支持流式响应和 WebSocket。AI 生态(模型 SDK、LangChain、向量库客户端)以 Python 为主,用 FastAPI 封装服务很自然。 +**追问**:FastAPI 和 Flask、Django 怎么选?→ AI 服务、异步 I/O、需要自动接口文档时选 FastAPI;需要完整后台管理和 ORM 的传统业务系统可以选 Django。 + ### 【基础】FastAPI 怎么区分路径参数、查询参数和请求体? **参考答案**:在路径模板中出现的参数是路径参数;简单类型(int、str、bool 等)的其他参数是查询参数;pydantic 模型类型的参数是请求体;请求头、Cookie 用 `Header()`、`Cookie()` 显式声明。也可以用 `Path()`、`Query()`、`Body()` 显式指定并添加校验规则。 +**追问**:请求体可以是多个 Pydantic 模型吗?→ 可以,多个模型参数会按参数名嵌套在 JSON 中;单个模型参数想嵌套时用 `Body(embed=True)`。 + ### 【基础】路由函数用 `async def` 和 `def` 有什么区别? **参考答案**:`async def` 在事件循环中直接执行,里面只能调用非阻塞的异步代码;普通 `def` 会被放到线程池中执行,适合调用同步阻塞库。在 `async def` 中调用阻塞函数会卡住整个事件循环,是最常见的性能问题。 +**追问**:`def` 路由的线程池大小有限制吗?→ 有,默认由 AnyIO 的线程池限制(默认 40 个线程,以官方文档为准),大量阻塞调用会排队。 + ### 【基础】响应模型有什么作用? **参考答案**:声明接口返回的数据结构:自动过滤掉未声明的字段(防止泄露内部字段),校验和序列化返回数据,并体现在 OpenAPI 文档中,前端可以据此生成类型。 +**追问**:怎么避免把密码等字段返回给前端?→ 定义单独的输出模型(不包含敏感字段)并设置为 `response_model`,而不是直接返回数据库对象。 + ## 进阶 ### 【进阶】`Depends` 的原理和常见用途? **参考答案**:FastAPI 在处理请求时解析路由函数签名,递归解析依赖树,按顺序调用依赖函数并把返回值注入参数;同一请求内相同依赖默认只调用一次;带 `yield` 的依赖可以在请求结束后执行清理。用途:鉴权和获取当前用户 / 租户、数据库会话、分页参数、共享客户端;测试时通过 `dependency_overrides` 替换。 +**追问**:依赖中的 `yield` 有什么用?→ `yield` 之前创建资源(数据库会话),之后清理资源;测试时可以用 `app.dependency_overrides` 替换依赖。 + ### 【进阶】中间件和依赖都能做鉴权,怎么选? **参考答案**:中间件作用于所有请求,适合与业务无关的横切逻辑:请求 ID、日志、耗时、CORS。依赖按路由或路由器声明,能拿到类型化的结果(当前用户)并注入处理函数,能在测试中替换,适合鉴权、权限、租户。通常鉴权用依赖。 +**追问**:中间件能访问路由的参数吗?→ 中间件在路由匹配之前执行,拿不到解析后的参数;需要按路由做权限控制的,用依赖。 + ### 【进阶】怎么实现大模型的流式输出?要注意什么? **参考答案**:用 `StreamingResponse` 包装异步生成器,`media_type="text/event-stream"`,每个事件 `data: ...\n\n`。注意:响应头发出后不能改状态码,错误要以事件形式发送;检查客户端断开及时停止生成;内容用 JSON 编码避免换行破坏格式;经过 Nginx 时关闭缓冲;前端 POST 请求需要用 fetch 读取流。 +**追问**:客户端中途断开,服务端还在调用大模型怎么办?→ 在生成器中检查 `await request.is_disconnected()`,或者依赖取消机制,及时停止上游调用以节省成本。 + ## 场景题 ### 【场景题】压测发现 FastAPI 服务 QPS 很低,CPU 也不高,可能是什么原因? **参考答案**:最常见的是在 `async def` 路由里调用了阻塞操作(同步数据库驱动、`requests`、`time.sleep`、同步 SDK),事件循环被阻塞。其他原因:每个请求新建 HTTP 客户端或数据库连接、连接池太小、下游(模型、数据库)本身慢、只启动了一个 worker。排查:看慢请求的耗时分布,用 py-spy 看事件循环线程在做什么。 +**追问**:怎么验证是不是事件循环被阻塞了?→ 开启 asyncio 的调试模式(会记录执行过慢的回调),或者在压测时观察一个简单的 `/health` 接口延迟是否也升高。 + ### 【场景题】一个接口依赖大模型和向量数据库,怎么写单元测试? **参考答案**:把模型客户端和检索器抽象成接口(Protocol),通过依赖注入获取;测试中用 `app.dependency_overrides` 替换为假实现,返回固定内容或模拟异常;用 `TestClient` 调用接口,断言状态码、响应格式、流式事件顺序、错误处理。真实模型的效果评估另外做(评测集),不放在单元测试里。 +**追问**:测试流式接口要注意什么?→ 用 `TestClient` 的 `stream` 方法或 httpx 异步客户端逐块读取,断言事件格式和结束标记,而不只是最终文本。 + ### 【场景题】给多个业务系统提供 AI 能力,怎么防止某个系统把额度用光? **参考答案**:每个调用方一个 API Key,鉴权依赖解析出租户;在服务端按租户做限流(Redis 滑动窗口 / 令牌桶)和配额(按日 / 月的 token 数);记录每次调用的 token 用量写入数据库;超过配额返回 429 并给出提示;对异常增长告警。Nginx 层可以再加一层按 IP 的粗粒度限流。 + +**追问**:额度用完时返回什么?→ 返回 429 和明确的错误码,带上 `Retry-After` 头;同时通知对应业务方,提供额度查询接口。 diff --git a/topics/python/python/interview.md b/topics/python/python/interview.md index 7b1d8c4..825e887 100644 --- a/topics/python/python/interview.md +++ b/topics/python/python/interview.md @@ -18,28 +18,40 @@ week: [4, 16] **参考答案**:不可变:int、float、str、tuple、frozenset、bytes;可变:list、dict、set、自定义对象(默认)。影响:函数参数传的是对象引用,函数里修改可变对象会影响调用方;只有不可变(可哈希)的对象才能做 dict 的 key 和 set 的元素。 +**追问**:元组里包含列表,这个元组可以作为字典的 key 吗?→ 不可以。元组本身不可变,但是包含可变元素时不可哈希。 + ### 【基础】为什么不要用可变对象作为函数默认参数? **参考答案**:默认值在函数定义时只创建一次,之后每次调用共享同一个对象。`def f(xs=[])` 多次调用会在同一个列表上累加。改为默认 `None`,在函数内部创建新列表。 +**追问**:dataclass 的可变默认值怎么写?→ 用 `field(default_factory=list)`,直接写 `= []` 会报错。 + ### 【基础】`is` 和 `==` 的区别? **参考答案**:`==` 比较值是否相等(调用 `__eq__`),`is` 比较是否是同一个对象(身份)。判断 `None` 用 `is None`;比较值永远用 `==`,不要依赖小整数缓存之类的实现细节。 +**追问**:为什么小整数 `a is b` 有时候为 True?→ CPython 缓存了 -5 到 256 的小整数和部分字符串,这是实现细节,不能依赖;比较值一律用 `==`。 + ### 【基础】浅拷贝和深拷贝的区别? **参考答案**:浅拷贝(`list(x)`、`x.copy()`、`copy.copy`)只复制最外层容器,内部元素仍是同一引用;深拷贝(`copy.deepcopy`)递归复制所有层级。嵌套结构修改内层元素时,浅拷贝会影响原对象。 +**追问**:`copy.deepcopy` 有什么代价?→ 递归复制所有对象,大数据结构时很慢、占内存;很多情况下用不可变数据或只复制需要修改的部分更好。 + ### 【基础】列表推导式和生成器表达式有什么区别? **参考答案**:`[x for x in xs]` 立即生成完整列表,占用内存;`(x for x in xs)` 生成器表达式按需逐个产生,内存占用恒定,但只能遍历一次。传给 `sum`、`max` 等函数时用生成器表达式即可。 +**追问**:生成器只能遍历一次,怎么办?→ 需要多次遍历时转成列表,或者重新创建生成器;注意不要在调试时提前把它消耗掉。 + ## 进阶 ### 【进阶】什么是 GIL?对 AI 应用开发有什么影响? **参考答案**:GIL(全局解释器锁)使 CPython 同一时刻只有一个线程执行 Python 字节码,所以多线程无法利用多核做 CPU 密集计算;但 IO 等待时会释放 GIL,所以多线程 / asyncio 对 IO 密集任务有效。AI 应用大多是 IO 密集(调用模型 API、查数据库、检索),用 asyncio 很合适;CPU 密集(本地向量计算、文本解析)用多进程,或者依赖 numpy 等在 C 层释放 GIL 的库。Python 3.13 起提供了实验性的无 GIL 构建,以官方文档为准。 +**追问**:Python 3.13 的无 GIL 版本是怎么回事?→ 3.13 开始提供实验性的自由线程(free-threaded)构建,可以关闭 GIL,3.14 起成为官方支持的可选构建;生态兼容还在推进中,生产使用以官方文档和依赖库的支持情况为准。 + ### 【进阶】装饰器是什么?写一个统计耗时的装饰器。 **参考答案**:装饰器是接收函数并返回新函数的函数,用来在不修改原函数的情况下增加功能(日志、重试、缓存、鉴权)。 @@ -74,20 +86,30 @@ work(1_000_000) **参考答案**:内部数据结构用 dataclass(轻量、无运行时校验);外部输入(接口请求体、配置、大模型返回的 JSON)用 pydantic,它会在运行时校验和转换类型并给出清晰的错误;只是给已有的字典加类型提示(例如 JSON 数据直接传来传去)用 TypedDict。 +**追问**:pydantic 的性能怎么样?→ v2 的核心用 Rust 实现,校验很快;但在内部的热点循环中,dataclass 或普通对象开销更小。边界用 pydantic,内部用 dataclass。 + ### 【进阶】上下文管理器的原理是什么? **参考答案**:实现了 `__enter__` 和 `__exit__` 方法的对象。`with` 进入时调用 `__enter__`,离开代码块(无论正常还是异常)时调用 `__exit__`,保证资源释放;`__exit__` 返回 True 会吞掉异常。也可以用 `contextlib.contextmanager` 装饰生成器函数实现,异步版本是 `async with` 和 `__aenter__` / `__aexit__`。 +**追问**:`contextlib.contextmanager` 中,如果 `with` 块内抛出异常会怎样?→ 异常会在 `yield` 处被抛出,用 `try/finally` 包住 `yield` 才能保证清理代码执行。 + ## 场景题 ### 【场景题】要处理一个 10GB 的日志文件,统计每个接口的请求数,怎么写? **参考答案**:不能一次读入内存。用 `with open(...)` 逐行迭代(文件对象本身就是惰性的),解析出接口路径后用 `Counter` 计数;正则预编译;如果需要更快,可以按字节范围切分文件用多进程并行统计后合并。输出前 N 个用 `most_common`。 +**追问**:统计结果也很大,放不进内存怎么办?→ 分片处理(按接口哈希写到多个中间文件再分别统计),或者导入数据库 / 使用 DuckDB 这类工具处理。 + ### 【场景题】一段 Python 代码在本机能跑,在服务器上报 `ModuleNotFoundError`,怎么排查? **参考答案**:确认服务器上是否激活了正确的虚拟环境(`which python`、`python -c "import sys; print(sys.executable)"`);依赖是否按锁文件安装;运行目录和方式是否不同(直接运行子目录的文件 vs 从根目录 `python -m`);是否有本地文件和包同名遮蔽了真正的包;Python 版本是否一致。 +**追问**:怎么保证服务器和本机的依赖一致?→ 使用锁文件(`uv.lock` / `requirements.txt` 固定版本),部署时用锁文件安装,最好用 Docker 镜像固定运行环境。 + ### 【场景题】你从前端转 Python,怎么保证写出来的 Python 代码质量? **参考答案**:用 ruff 做 lint 和格式化,用 mypy / pyright 做类型检查,pytest 写测试并在 CI 中运行;遵循 PEP 8 和项目约定;数据边界用 pydantic 校验;对照 JS 的经验注意 Python 特有的坑(可变默认参数、浮点金额、导入路径)。可以举一个自己写过的、带测试的小工具为例。 + +**追问**:AI 帮你写的 Python 代码怎么验证?→ 运行测试和类型检查(pytest、mypy / pyright、ruff),阅读并理解每一行,补充边界情况的测试,不理解的代码不提交。