
GitHubhttps://github.com/Lanqingsong/rag-from-scratch技术栈LangChain · DeepSeek · FAISS · 阿里云 DashScope · Flask · rank-bm25 · SearXNG前言一直以来我想创建一个属于自己的私有知识库和一般知识库不同我还希望能够连接互联网让AI能判断我的知识库内容并且提供有效补充之前也尝试了不少开源知识库感觉从灵活性来说还是自己搭建下来更方便因为现在编写代码借助AI也很方便所以就自己搭了一个基于PYTHON的完整知识库通过webui调用最后的效果是这样的1. 为什么要自己搭而不用 Dify / Coze市面上已经有很多现成的 RAG 工具Dify、Coze、FastGPT 都能快速搭出一个知识库问答。如果你的需求很简单这些工具完全够用。但有几类场景它们满足不了数据不能出服务器。企业内网的技术手册、工艺参数、设备档案涉及内部数据的文档通常不适合上传到任何第三方平台。本项目完全本地运行唯一的出站请求是 LLM API 调用向量库和文档文件都在本地。文档结构需要精准切分。Dify 的文档处理是固定逻辑切出来的 chunk 不一定尊重文档的语义边界。对于技术手册、参数表、QA 文档如果切割点落错了型号和它的规格参数就在两个 chunk 里检索必然失败。自己写切分策略完全可控。检索逻辑需要干预。向量检索对专有名词天生不敏感BM25 关键词检索可以补这个缺但 Dify 没有暴露这层控制。如果召回效果不好你看不到内部发生了什么也改不了。想真正理解 RAG 怎么工作。点按钮搭出来的系统你只知道RAG 是这么用的。从代码层面搭一遍你才能说清楚为什么要用 RRF 而不是直接取 top-K、为什么 Reranker 比 Embedding 精度高、为什么流式输出要缓冲 40 个字符再推给前端。这些是面试和实际工作中真正有价值的东西。项目同时附带 19 篇教学文档lessons/目录每篇对应一个核心模块讲原理、讲代码、讲设计动机适合想系统学习 RAG 工程实现的人。2. 系统整体架构在讲具体的技术问题之前先把系统的全貌交代清楚后面的内容会更容易理解。2.1 系统做什么用户上传自己的文档.md/.txt/.pdf系统自动切分、向量化、建索引。之后用户用自然语言提问系统检索相关内容交给 LLM 生成回答支持流式输出和多轮对话。除了本地知识库系统还可以接入 SearXNG 自托管搜索在本地知识库无法回答时自动联网补充两路结果合并后一起送给 LLM。2.2 技术选型组件选型原因LLMDeepSeek中文理解好兼容 OpenAI SDK价格合理Embedding千问 text-embedding-v2中文语料训练DashScope 统一调用Reranker千问 qwen3-rerank与 Embedding 同一生态向量库FAISS纯文件无需启动服务C 实现速度快关键词检索rank-bm25轻量纯 Python无外部依赖LLM 框架LangChainLCEL 管道语法MultiQueryRetrieverWeb 框架FlaskSSE 流式输出简洁现有代码同步友好网络搜索SearXNG自托管不依赖第三方 API 限额2.3 一次问答的完整流程用户提问 ↓ [对话引用检测] ── 是你刚才说的类问题 → 跳过检索走对话历史 ↓ 并行检索 ├── FAISS 语义检索 MultiQueryRetriever 查询改写 ├── BM25 关键词检索 └── SearXNG 网络搜索可选 ↓ RRF 融合 FAISS BM25 两路结果 ↓ Qianwen Reranker 精排 ↓ LangChain LCEL 链Prompt 注入 → DeepSeek → StrOutputParser ↓ SSE 流式推送给前端每一层都有它存在的具体原因后面逐一展开。3. 文档切分决定召回质量的第一步3.1 为什么切分比 chunk_size 更重要很多教程把注意力放在 chunk_size 设多大上。这个参数有影响但对于结构化程度高的技术文档在哪里切比切多大重要得多。举个例子。一份相机技术规格文档结构是这样的## GigE Vision 接口规格 - 传输带宽1 GbpsGigE/ 10 Gbps10GigE - 触发延迟 1 μs - 支持协议GigE Vision 2.0如果按固定字符数切割标题和它下面的参数可能被切成两个 chunk。用户搜GigE 触发延迟是多少召回的 chunk 里没有数值LLM 只能回答未找到相关信息。切错了后面再怎么优化也是在填坑。3.2 五种切分策略项目实现了五种策略每个知识库子目录可以通过kb_config.json独立配置3.2.1 Markdown 标题切割按##/###/####标题行切割每个标题和它下面的内容作为一个完整 chunk。超长 chunk 自动按下一级标题二次切割最终回退到递归字符切割。适用于有良好 Markdown 结构的技术手册和教程。{strategy:markdown,heading_level:3,chunk_size:1000}3.2.2 自定义正则边界用户提供一个正则表达式匹配每个 chunk 的起始行。适用于1.1 焦距计算、Q12: 如何标定相机这类有明确逻辑边界但不是 Markdown 标题的文档。{strategy:regex,pattern:\\d\\.\\d\\s[^\\n]}3.2.3 语义切割计算相邻句子的 Embedding 余弦相似度在相似度骤降处话题切换点切割。适合完全无结构的自由文本。调用次数多、成本高代码里有异常保底失败时自动回退到递归切割。{strategy:semantic,threshold_type:percentile,threshold_value:95}3.2.4 递归字符切割LangChain 的RecursiveCharacterTextSplitter按\n\n→\n→。→→ 空格 层级递归切割任何文档的最终保底策略。3.2.5 Auto 自动探测不想手写配置时auto策略会检测每份文档的结构并自动选策略有 3 个及以上###标题 →markdown(heading_level3)有编号格式行 →regex否则 →recursive。默认配置就是auto。3.3 per-directory 配置不同目录的文档格式往往不同可以分别配置knowledge_base/ ├── cameras/ │ ├── kb_config.json ← {strategy: markdown, heading_level: 3} │ └── basler_ace2.md └── faq/ ├── kb_config.json ← {strategy: regex, pattern: Q\\d[.:]} └── common_questions.md4. 混合检索为什么四层管道比单路向量检索强4.1 向量检索的盲区Embedding 模型的训练目标是语义相似度它擅长把相机分辨率和图像像素数量映射到相近的向量位置。但它有一个硬伤对训练语料里低频的专有名词不敏感。型号编码CS-040GX、参数代码GigE-X01-A这些词在训练数据里极少出现模型对它们几乎没有语义记忆向量表示接近随机方向。用户搜这个型号余弦相似度低于阈值FAISS 返回 0 条结果。这不是 Bug是 Embedding 的设计特性。它擅长语义理解不擅长精确字符匹配。4.2 BM25 补刀关键词盲区BM25 是基于词频统计的检索算法它的逻辑和 Embedding 完全不同一个词在文档里出现就得分越罕见的词权重越高文档越长归一化惩罚越重。score ( D , Q ) ∑ i IDF ( q i ) ⋅ f ( q i , D ) ⋅ ( k 1 1 ) f ( q i , D ) k 1 ⋅ ( 1 − b b ⋅ ∣ D ∣ avgdl ) \text{score}(D, Q) \sum_i \text{IDF}(q_i) \cdot \frac{f(q_i, D) \cdot (k_1 1)}{f(q_i, D) k_1 \cdot (1 - b b \cdot \frac{|D|}{\text{avgdl}})}score(D,Q)i∑IDF(qi)⋅f(qi,D)k1⋅(1−bb⋅avgdl∣D∣)f(qi,D)⋅(k11)对CS-040GX这个查询BM25 直接字符匹配精确找到包含这个型号的文档跟语义理解没有任何关系。两路并行检索覆盖的场景互补语义模糊的问题靠 FAISS精确名词靠 BM25。4.3 RRF 把两路结果合并FAISS 和 BM25 各自返回一个排序列表但两者的打分量纲完全不同不能直接合并。RRFReciprocal Rank Fusion的思路是只用排名不用分数def_rrf_merge(self,faiss_docs,bm25_docs,k,rrf_k60):scores{}doc_map{}forrank,docinenumerate(faiss_docs):keydoc.page_content[:80]scores[key]scores.get(key,0)1.0/(rrf_krank1)doc_map[key]docforrank,docinenumerate(bm25_docs):keydoc.page_content[:80]scores[key]scores.get(key,0)1.0/(rrf_krank1)doc_map[key]doc sorted_keyssorted(scores,keylambdax:scores[x],reverseTrue)return[doc_map[key]forkeyinsorted_keys[:k]]rrf_k60来自 Cormack 等人 2009 年的论文实验发现这个值在多种任务上稳健。两路都命中的文档得分叠加天然排到前面只有一路命中的也能参与排名。4.4 Reranker 解决Lost in the Middle问题即使召回准了还有一个问题把 10 条文档都塞给 LLMLLM 对开头和结尾的注意力更高中间的内容容易被忽略。这是斯坦福 2023 年 Lost in the Middle 论文里验证的现象——正确答案在第 6~9 条时LLM 的准确率比放在第 1 条低约 30%。解法是在送给 LLM 之前加一层精排把 10 条压缩到 3 条让 3 条里都是高相关的内容。Reranker 用的是交叉编码器把查询和文档拼在一起送进同一个模型每一层 attention 都能看到两者的交互直接输出一个精确的相关性分数。Embedding 检索用的是双编码器查询和文档各自独立编码精度受限于两者编码时互不可见这个约束。代价是慢每个候选文档都要重新推理一次不能预计算。所以 Reranker 放在最后一步候选集已经缩小到 10~20 条时再精排这样开销可控ifself.rerankerandlen(results)1:try:resultsself.reranker.rerank_documents(query,results)exceptExceptionase:print(fRerank 失败使用原始结果:{e})失败时降级到原始 RRF 结果不影响主流程。4.5 MultiQueryRetriever一个问题变三个角度用户表述问题的方式多种多样GigE 接口最大带宽和千兆以太网相机传输速率上限在语义上等价但向量空间里距离未必很近。MultiQueryRetriever 让 LLM 把原始问题改写成 3 个不同角度的查询三路并行检索取并集命中率对多义性问题提升明显。5. 多轮对话的两个工程问题5.1 对话引用识别用户问完镜头焦距怎么计算接着问那工作距离呢。如果拿那工作距离呢去检索知识库什么都找不到——这个问题的语义锚点在对话历史里不在知识库里。在进入检索之前先做一个轻量判断_DIALOGUE_REFre.compile(r(上一个|上一条|刚才|之前(说|问|讲|提到?)?|你说|你提到|r你刚|继续|你上面|你前面|解释一下刚|你说的),re.IGNORECASE)def_is_dialogue_ref(query:str,history:list)-bool:ifnothistory:returnFalseif_DIALOGUE_REF.search(query):returnTrueiflen(query)15andre.search(r(这个|那个|它|这些|那些),query):returnTrue# 短问题 指代词 → 大概率引用历史returnFalse命中时直接跳过检索带完整对话历史发给 LLM。这个正则方案有误检和漏检更精确的做法是用小模型做意图分类但那意味着每次请求多一次 LLM 调用。对于当前体量接受少量错误、在日志里记录后续根据频率再决定是否升级。5.2 Token 预算管理对话历史不能无限累积。截断策略是从最新一轮往前截超出预算就停itemslist(reversed(history_dataor[]))total_tokens0selected[]foriteminitems:t_count_tokens(item.get(content,))iftotal_tokenstConfig.MAX_HISTORY_TOKENS:breakselected.insert(0,(item[role],item[content]))total_tokenst为什么从最新往前截而不是从最早往后保留距离当前问题最近的对话轮次对理解当前问题的价值最高。当历史过长时优先丢弃早期的轮次。Token 计数用 tiktoken不可用时降级到字符数 ÷ 2中文约 2 字/token。6. 网络搜索与熔断器6.1 并行检索不串行等待知识库检索和网络搜索并行发起互不阻塞withThreadPoolExecutor(max_workers2)asex:kb_fex.submit(kb.search,query,Config.TOP_K_RESULTS,llm.llm)web_fex.submit(web_searcher.search,query)returnkb_f.result(timeout20),web_f.result(timeout15)网络搜索用 SearXNG 自托管实例不依赖任何第三方 API 限额。6.2 熔断器防止搜索服务拖慢主流程SearXNG 不稳定时如果每次都等到超时整个响应会被拖慢十几秒。熔断器在连续失败 3 次后自动断路后续请求直接跳过网络搜索60 秒后自动尝试恢复class_CircuitBreaker:def__init__(self,failure_threshold3,recovery_timeout60):self._failures0self._thresholdfailure_threshold self._timeoutrecovery_timeout self._opened_atNonepropertydefis_open(self):ifself._opened_atisNone:returnFalseiftime.time()-self._opened_atself._timeout:self._opened_atNoneself._failures0returnFalsereturnTrue搜索失败时还有指数退避重试1s、2s、4s三次都失败才触发熔断器计数。这是 Martin Fowler 的 Circuit Breaker 模式在 RAG 场景里的应用。7. 流式输出的两个细节7.1 过滤开头套话LLM 经过 RLHF 训练后有参考资料时习惯以根据知识库内容…开头。这是训练强化的习惯改 Prompt 能缓解但不能根治——不同模型、不同温度下这个行为强度不同。SSE 是逐 token 推送的第一个 token 到就直接出去了。要拦截开头需要在服务端设缓冲区等攒够 40 个字符再决定要不要清洗BUF_SIZE40# 足够覆盖最长的根据...开头bufbuf_flushedFalseforchunkinllm.stream(...):ifnotchunk:continueifnotbuf_flushed:bufchunkiflen(buf)BUF_SIZE:buf_BAD_OPENER.sub(,buf,count1).lstrip()buf_flushedTrueyield_sse({type:token,content:buf})else:yield_sse({type:token,content:chunk})40 个字符是经验值中文根据您提供的知识库内容约 15 个字留 40 个缓冲足够判断、又不会让用户感知到延迟。7.2 参考资料先推再流式输出SSE 事件有type字段refs是参考来源token是 LLM 输出的每个 chunkdone是最终 Markdown HTML。检索一完成就立刻推refs不等 LLM 开始生成。用户在 LLM 还在生成答案时就已经能看到参考来源是哪些文档。这个时序设计让响应体感更流畅。8. Prompt 版本管理8.1 为什么 Prompt 需要版本控制Prompt 工程是反复迭代的过程改动频率比代码高。某次为了改善参数比较类问题修改了system.txt几天后发现另一类问题质量下降了想回到之前的版本——如果只有一个被反复覆盖的文件什么记录都没有。8.2 三层方案存档与回滚每次修改前手动存档文件名带时间戳可随时激活历史版本立即生效。POST /api/prompts/save_version# 存档当前版本POST /api/prompts/activate/system_20260528_143022.txt# 激活历史版本变量插值system.txt里用{{domain}}、{{user_type}}占位variables.json里存实际值。调整定制内容不需要改主文件减少版本分叉。热重载POST /api/prompts/reload重建 LLM 链不需要重启 Flask 进程对正在进行的会话无影响。9. 几个架构决策的取舍9.1 FAISS vs ChromaDBChromaDB 有 Web UI、更方便的持久化和元数据过滤。选 FAISS 的原因向量库是本地两个文件.faiss.pkl无需启动任何服务整个知识库打包即可迁移C 实现几千个向量的检索延迟在毫秒级。代价是没有在线增量更新修改文档需要重建索引。对当前场景这个代价可以接受。9.2 Flask vs FastAPISSE 流式输出在 Flask 里只需要Response(generator, mimetypetext/event-stream)极简。FastAPI 需要装sse-starlette并且项目里大量模块是同步的FAISS 检索、BM25 检索用异步框架需要run_in_executor包装得不偿失。如果未来要支持高并发迁移到 FastAPI 是合理的升级路径。9.3 LangChain 的争议与取舍LangChain 接口不稳定、版本间 breaking change 多——这个批评是真实的项目里就踩过langchain_classic的导入问题。但它在这个项目里提供了三个真实价值LCEL 管道语法切换 LLM 只改一个对象、MultiQueryRetriever查询改写现成实现、SemanticChunker语义切分现成实现。接受它的前提是只用具体功能核心检索逻辑FAISS BM25 RRF Reranker自己控制不把整个应用架在 LangChain 的高层抽象上。10. 总结这套系统的每一层都对应一个具体问题向量检索找不到型号编码 → 加 BM25BM25 和 FAISS 打分量纲不同 → 用 RRF 融合排名LLM 忽视中间文档 → 加 Reranker 精排压缩候选集对话引用触发无效检索 → 进检索前判断意图历史记录超出上下文窗口 → 从最新往前按 Token 数截取流式输出开头有废话 → 服务端缓冲 40 字符过滤SearXNG 不稳定拖慢响应 → 熔断器 指数退避重试Prompt 改了改不回来 → 存档 热重载。如果你在搭类似系统并且遇到了其中某个问题希望这里的思路对你有帮助。完整代码和 19 篇教学文档在 GitHubhttps://github.com/Lanqingsong/rag-from-scratch本项目由 lanqingsong874953727outlook.com 与 AI 助手协作开发。编写时间2026 年 5 月 28 日