1. 为什么我要自己搭一套知识工作台
先说结论:市面上现成的笔记软件、云文档、AI 问答工具我几乎试了个遍,最后发现没有一个能同时满足我三个硬需求——本地文件不搬家、PDF 和 Markdown 混着管、能对着自己的资料持续追问。于是花了大概两个周末,搭了一套自己用着顺手的知识工作台,核心思路就是 RAG(检索增强生成)加一层文件管理层。
这套东西解决什么问题?简单讲,我手头有一堆技术 PDF(比如各种从入门到精通的电子书)、几百篇 Markdown 笔记、还有零散的项目文档。以前想查个东西,要么靠记忆翻文件夹,要么用全文搜索但搜出来的是一堆关键词匹配,根本理解不了我的问题。现在我可以直接问"上次那个关于 RAG 瓶颈的分析在哪篇笔记里",它能定位到具体段落并给出答案。
适合谁来参考?如果你符合下面任意一条,这套方案对你就有用:
- 手头有大量 PDF 和 Markdown 资料,想统一管理又能智能检索
- 试过在线知识库工具,但担心数据隐私或者文件格式支持不全
- 想入门 RAG 但不知道从哪下手,需要一个能跑起来的完整例子
- 已经在用 Obsidian 之类的本地笔记,想加一层 AI 问答能力
不适合谁?如果你只是想要个简单的笔记同步工具,或者对本地部署完全没兴趣、只想用现成 SaaS,那这套方案可能偏重了。它需要你愿意折腾一下命令行和配置文件。
我搭这套东西的出发点很朴素:知识库的核心不是"存",而是"取"。存得再多,取不出来等于零。而"取"这件事,传统全文搜索只能做到字面匹配,RAG 能做到语义匹配——你问"怎么优化检索效果",它能找到写着"提升召回率的方法"的段落,哪怕字面上没有一个词重合。这就是我非要自己搭的原因。
2. 整体架构设计与技术选型思路
2.1 三层架构:文件层、索引层、问答层
我把整套系统拆成三层,每层职责单一,方便单独替换和调试。
文件层负责原始资料的存放和格式解析。PDF 用解析库提取文本,Markdown 直接读源文件。这一层的关键是保持原始文件不动,所有处理都是读取后生成中间产物,绝不修改你的源文件。这一点很重要,我见过有人用工具处理完 PDF 后原文件被覆盖,哭都来不及。
索引层负责把文本切块、向量化、存进向量数据库。切块策略直接决定检索质量,后面会详细讲。向量化用嵌入模型,可以选择本地跑的也可以调 API。
问答层负责接收问题、检索相关片段、拼装上下文、调用大模型生成回答。这一层还要处理多轮对话,让追问能基于上一轮的上下文。
三层之间通过明确的接口通信,比如文件层输出统一格式的文本块列表,索引层消费这个列表。这样我想换嵌入模型或者换向量库,只需要改对应层的配置,不影响其他部分。
2.2 为什么选 RAG 而不是微调
很多人一上来就想微调模型,觉得那样才"高级"。我的经验是:个人知识库场景下,RAG 的性价比远高于微调。
微调的问题在于:第一,你需要大量高质量的问答对,个人资料根本凑不出来;第二,微调后模型的知识是"死"的,你新增一篇文档就得重新训练;第三,微调成本高,普通显卡跑不动,租算力又是一笔开销。而 RAG 是"活"的,你往文件夹里丢一篇新 PDF,重新索引一下就能被检索到,模型本身不用动。
打个比方:微调像是把知识背进脑子里,背错了很难改;RAG 像是把知识放在书架上,随时可以抽出来看,换书也方便。对于个人知识库这种资料频繁更新的场景,RAG 明显更合适。
当然 RAG 也有它的瓶颈,比如检索不准、上下文超长、多跳推理弱。这些后面会讲怎么缓解。
2.3 工具选型对比:我试过的几套方案
在定稿之前我试了好几种组合,列个表对比一下,方便你按自己的情况选。
| 方案组合 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| LangChain + Chroma | 生态成熟,文档多 | 抽象层厚,调试麻烦 | 想快速出原型 |
| LlamaIndex + 本地向量库 | 检索策略丰富 | 学习曲线陡 | 需要精细控制检索 |
| 纯手写 + 向量库 API | 完全可控,代码透明 | 啥都得自己写 | 想彻底搞懂原理 |
| 现成知识库软件 | 开箱即用 | 格式支持有限,难定制 | 不想写代码 |
我最后选的是手写核心逻辑 + 成熟向量库的组合。原因是我踩过 LangChain 的坑——它的抽象层有时候会隐藏关键细节,出了问题你不知道是检索的问题还是拼装的问题。手写虽然累点,但每一行代码在干什么都清清楚楚,调试起来快得多。
向量库我用的 Chroma,因为它支持本地持久化,不需要额外起服务,对个人使用足够。如果你资料量特别大(比如几十万块),可以考虑 Milvus 或者 Qdrant,但个人知识库一般到不了那个量级。
2.4 嵌入模型的选择:本地还是 API
嵌入模型负责把文本转成向量。这里有个关键决策:用本地模型还是调 API。
本地模型的好处是数据不出门、没有调用成本、断网也能用。我用的是一个中等规模的中文嵌入模型,跑在消费级显卡上完全够用。缺点是首次加载慢,而且效果比顶级 API 模型略差。
API 模型的好处是效果好、不用管硬件。缺点是按量计费,资料多了成本会上来,而且你的文档内容要发到别人服务器上。
我的建议是:如果资料涉及隐私或者量很大,用本地模型;如果只是公开资料且追求效果,用 API。我自己是本地为主,因为知识库里有些项目文档不方便外传。
3. 核心细节解析与实操要点
3.1 PDF 解析:别小看这一步
PDF 解析是整条流水线最容易出问题的地方。很多人以为 PDF 就是文本,直接读就行,结果发现读出来一堆乱码或者顺序错乱。原因在于 PDF 本质是"打印描述",它记录的是"在某个坐标画某个字符",而不是"这是一段话"。
我踩过的坑包括:双栏排版的 PDF 读出来左右栏交错、表格读出来变成一坨、扫描版 PDF 根本没有文本层。针对这些情况,我的处理策略是:
- 优先用能识别版面的解析库,它会根据坐标判断阅读顺序,双栏也能正确处理
- 表格单独处理,能提取成结构化数据最好,提取不了就转成 Markdown 表格文本
- 扫描版走 OCR,但 OCR 结果要人工抽查,错字率不低
- 解析完做一次清洗,去掉页眉页脚、页码、多余空行
提示:解析 PDF 时一定要保留页码信息。后面检索到某个片段时,你能知道它在原 PDF 的第几页,方便回去核对原文。这个细节很多人忽略,等想核对时才发现找不到了。
3.2 文本切块:检索质量的分水岭
切块(chunking)是 RAG 里最被低估的环节。切得不好,检索出来的片段要么缺上下文,要么混入无关内容。
我试过三种切法:
固定长度切块最简单,比如每 500 字切一块。问题是它会在句子中间切断,导致语义不完整。我早期用这个,经常检索到半句话,模型只能瞎猜。
按段落切块好一些,因为段落本身就是语义单元。但技术文档里经常有超长段落,一块塞太多内容,检索精度下降。
递归切块是我现在用的。它先按段落切,如果某段太长就按句子切,句子还长就按字符切,同时保留一定的重叠(overlap)。重叠的作用是防止关键信息正好落在切块边界上被割裂。
具体参数我调了很久,最后定的是:块大小 500 字左右,重叠 80 字。这个组合在我的资料上召回效果最好。块太大检索不准,块太小上下文不足,500 字是个平衡点。重叠 80 字大概是一两句话的长度,能覆盖大部分边界情况。
还有个技巧:给每个块加上来源元数据,比如文件名、页码、所属章节标题。检索时把这些元数据一起返回,模型就能知道这段内容出自哪里,回答时能引用来源。
3.3 Markdown 处理:比 PDF 简单但别大意
Markdown 是纯文本,解析起来比 PDF 简单得多。但有几个细节要注意。
代码块要单独处理。技术笔记里经常有大段代码,如果按普通文本切块,代码会被切得七零八落。我的做法是把代码块整体作为一个块,不参与常规切分。
表格要保留结构。Markdown 表格转成纯文本后,行列关系就丢了。我保留表格的 Markdown 原文,这样模型能看到完整的表格结构。
标题层级要利用起来。Markdown 的#标题天然就是章节划分,我按标题层级切块,每个块带上它的标题路径(比如"第三章 > 3.2 节 > 具体小节")。这样检索时能知道内容在文档结构中的位置。
换行和空格要规范化。Markdown 里换行有特殊含义(两个空格加换行才是硬换行),处理时要注意别把有意义的换行弄丢了。
3.4 向量化与存储:一次说清楚
向量化就是把文本块转成一组数字(向量),语义相近的文本向量也相近。检索时把你的问题也转成向量,然后找最相近的文本块。
这里有个容易混淆的点:嵌入模型和生成模型是两回事。嵌入模型负责转向量,生成模型负责写回答。它们可以是不同的模型,甚至来自不同的厂商。我见过新手以为用一个模型就全搞定了,结果发现那个模型根本不支持嵌入。
存储方面,我用 Chroma 的持久化模式,数据存在本地文件夹里。每次新增文档,只索引新增部分,不用全部重建。这个增量索引的能力很重要,否则资料一多,每次全量重建要等很久。
注意:向量库的持久化文件不要放在云同步文件夹里。我试过放在同步盘,结果多设备同时写入导致索引损坏,重建花了大半天。现在固定放在本地非同步目录。
3.5 检索策略:从单路到多路
最简单的检索是"向量相似度 top-k",就是找最相近的 k 个块。但这样有个问题:纯向量检索对关键词不敏感。比如你搜一个专有名词,向量检索可能返回语义相近但没这个词的段落,反而漏掉了真正包含这个词的段落。
我的解决方案是混合检索:一路走向量相似度,一路走关键词匹配(比如 BM25),然后把两路结果融合排序。这样既能抓住语义,又不漏关键词。
融合排序我用的是倒数排名融合(RRF),它不需要两路分数可比,只看排名,简单有效。具体就是每个结果按它在各路中的排名算一个分数,排名越靠前分数越高,最后按总分排序。
还有个进阶技巧叫重排序:先粗召回一批(比如 20 个),再用一个专门的重排模型精排,选出最相关的几个(比如 5 个)给生成模型。重排模型比嵌入模型更准但更慢,用在精排阶段正好。我实测下来,加了重排之后回答准确率明显提升。
4. 完整实操流程与关键环节实现
4.1 环境准备与依赖安装
先把基础环境搭起来。我用的是 Python,建议 3.10 以上版本,因为有些库对低版本支持不好。
python -m venv kb-env source kb-env/bin/activate # Windows 用 kb-env\Scripts\activate pip install chromadb sentence-transformers pypdf markdown beautifulsoup4 rank-bm25如果你要用 API 模型,再装对应的 SDK。本地模型的话,sentence-transformers 就够了。
目录结构我这样组织:
knowledge-base/ ├── raw/ # 原始文件,PDF 和 Markdown 放这 │ ├── pdfs/ │ └── notes/ ├── parsed/ # 解析后的中间产物 ├── index/ # 向量库持久化目录 ├── config.yaml # 配置文件 └── app.py # 主程序raw目录是你的资料仓库,往里丢文件就行。parsed和index是自动生成的,不用手动管。
4.2 PDF 解析代码实现
解析 PDF 的核心逻辑:
from pypdf import PdfReader def parse_pdf(filepath): reader = PdfReader(filepath) blocks = [] for page_num, page in enumerate(reader.pages, start=1): text = page.extract_text() if not text.strip(): continue # 按段落切分 paragraphs = [p.strip() for p in text.split('\n\n') if p.strip()] for para in paragraphs: blocks.append({ 'text': para, 'source': filepath, 'page': page_num, 'type': 'pdf' }) return blocks这段代码做了几件事:逐页读取、跳过空白页、按空行切段落、给每个段落打上来源和页码标签。页码标签后面检索时能派上大用场。
实际用的时候你会发现,extract_text()对某些 PDF 效果不好。这时候可以换更专业的解析库,它们对版面识别更强。但换库之后要重新测试,因为不同库的输出格式不一样。
4.3 Markdown 解析与切块
Markdown 解析我按标题层级来切:
import re def parse_markdown(filepath): with open(filepath, 'r', encoding='utf-8') as f: content = f.read() blocks = [] current_heading = '' current_lines = [] for line in content.split('\n'): if re.match(r'^#{1,6}\s', line): # 遇到新标题,先把之前的内容存起来 if current_lines: blocks.append({ 'text': '\n'.join(current_lines), 'heading': current_heading, 'source': filepath, 'type': 'markdown' }) current_lines = [] current_heading = line.strip('# ').strip() else: current_lines.append(line) # 处理最后一段 if current_lines: blocks.append({ 'text': '\n'.join(current_lines), 'heading': current_heading, 'source': filepath, 'type': 'markdown' }) return blocks这样每个块都带着它所属的标题,检索时能知道内容在文档的哪个章节。对于超长的块,再套一层递归切分。
4.4 向量化与索引构建
把解析出来的块转成向量存进库:
import chromadb from sentence_transformers import SentenceTransformer model = SentenceTransformer('your-embedding-model') client = chromadb.PersistentClient(path='./index') collection = client.get_or_create_collection('knowledge') def index_blocks(blocks): texts = [b['text'] for b in blocks] embeddings = model.encode(texts, show_progress_bar=True) collection.add( embeddings=embeddings.tolist(), documents=texts, metadatas=[{ 'source': b['source'], 'page': b.get('page', 0), 'heading': b.get('heading', ''), 'type': b['type'] } for b in blocks], ids=[f"{b['source']}_{i}" for i, b in enumerate(blocks)] )show_progress_bar=True这个参数建议开着,资料多的时候你能看到进度,不然会以为卡死了。我第一次索引几百个 PDF 时没开进度条,等了十分钟以为程序挂了,其实是在正常跑。
4.5 混合检索实现
检索部分我把向量检索和关键词检索结合起来:
from rank_bm25 import BM25Okapi def hybrid_search(query, top_k=5): # 向量检索 query_embedding = model.encode([query])[0] vector_results = collection.query( query_embeddings=[query_embedding.tolist()], n_results=20 ) # 关键词检索 all_docs = collection.get()['documents'] tokenized = [doc.split() for doc in all_docs] bm25 = BM25Okapi(tokenized) bm25_scores = bm25.get_scores(query.split()) bm25_top = sorted(range(len(bm25_scores)), key=lambda i: bm25_scores[i], reverse=True)[:20] # RRF 融合 scores = {} for rank, idx in enumerate(vector_results['ids'][0]): scores[idx] = scores.get(idx, 0) + 1 / (60 + rank) for rank, idx in enumerate(bm25_top): doc_id = collection.get()['ids'][idx] scores[doc_id] = scores.get(doc_id, 0) + 1 / (60 + rank) # 取 top_k sorted_ids = sorted(scores, key=scores.get, reverse=True)[:top_k] return collection.get(ids=sorted_ids)那个 60 是 RRF 的平滑常数,经验值,不用改。融合之后取前 5 个给生成模型。
4.6 问答与多轮追问
最后一步是把检索结果拼成提示词,调生成模型:
def ask(query, history=None): results = hybrid_search(query) context = '\n\n'.join([ f"[来源: {m['source']} 第{m['page']}页]\n{doc}" for doc, m in zip(results['documents'], results['metadatas']) ]) prompt = f"""基于以下资料回答问题。如果资料中没有相关信息,直接说不知道,不要编造。 资料: {context} 问题:{query} """ # 调用生成模型 answer = call_llm(prompt, history) return answer, results['metadatas']多轮追问的关键是把历史对话也传进去,让模型知道上下文。但要注意历史不能太长,否则会挤占资料的空间。我的做法是只保留最近三轮对话,更早的丢弃。
提示:提示词里明确写"资料中没有就说不知道"非常重要。不加这句,模型会倾向于编造答案,这在知识库场景下是致命的。我早期没加,问了个资料里没有的问题,它一本正经地编了一段,差点把我误导。
5. 常见问题与排查技巧实录
5.1 检索不准怎么办
这是最高频的问题。排查顺序我总结成一张表:
| 现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 检索结果完全不相关 | 嵌入模型不匹配 | 用几个已知答案的问题测试 | 换更适合中文的嵌入模型 |
| 相关段落没被召回 | 切块太大或太小 | 检查召回片段的完整性 | 调整块大小和重叠 |
| 关键词搜不到 | 纯向量检索的盲区 | 试试关键词检索能否找到 | 加混合检索 |
| 排序靠后 | 缺少重排 | 看正确答案排在第几位 | 加重排模型 |
我的经验是,八成检索问题出在切块上。切块策略不对,后面怎么调都白搭。建议先把切块调好,再考虑换模型或者加重排。
5.2 回答出现幻觉怎么治
幻觉就是模型编造资料里没有的内容。除了提示词里明确要求"不知道就说不知道",还有几个手段:
- 降低生成温度,温度越低越保守,编造倾向越小
- 要求引用来源,让模型在回答里标注每句话来自哪个片段,编造的内容往往标不出来
- 检索结果为空时直接返回"未找到",不要硬让模型回答
- 人工抽查,定期看看回答质量,发现幻觉及时调整
我现在的做法是要求模型在每个关键结论后面标注来源编号,这样我一眼就能看出哪些是有据可查的,哪些可能是编的。
5.3 资料更新后索引不同步
新增或修改资料后,索引不会自动更新,需要手动触发。我的做法是写个脚本,扫描raw目录,对比文件的修改时间,只重新索引变动的文件。
import os, json def sync_index(): state_file = './index/state.json' state = json.load(open(state_file)) if os.path.exists(state_file) else {} for root, _, files in os.walk('./raw'): for f in files: path = os.path.join(root, f) mtime = os.path.getmtime(path) if state.get(path) != mtime: # 重新索引这个文件 reindex(path) state[path] = mtime json.dump(state, open(state_file, 'w'))这样每次只处理变动的文件,速度快很多。记得在删除文件时也要从索引里移除对应的块,否则会检索到已经不存在的资料。
5.4 上下文超长被截断
生成模型有上下文长度限制,检索回来的资料加上历史对话可能超限。处理办法:
- 控制检索返回的块数,一般 5 个够用,多了反而稀释重点
- 对块做压缩,只保留和问题最相关的句子
- 历史对话做摘要,不要原样保留
- 优先保留高分块,超限时从低分的开始丢
我实测下来,5 个块、每个 500 字,加上问题本身,大概 3000 字左右,主流模型都能处理。如果你的模型上下文窗口小,就减到 3 个块。
5.5 几个我踩过的坑
坑一:文件名有特殊字符导致索引失败。有些 PDF 文件名带空格、括号、中文标点,处理时要做转义或者重命名。我现在统一用哈希值做 ID,避免文件名问题。
坑二:编码问题。Markdown 文件如果是 GBK 编码,用 UTF-8 读会乱码。读取时要指定编码,或者用能自动检测编码的库。
坑三:向量库并发写入。多进程同时写同一个向量库会损坏数据。我现在的做法是索引时单进程,查询时可以多进程。
坑四:嵌入模型首次加载慢。第一次用某个模型要下载权重,几百兆到几个 G 不等。建议提前下载好,别等到用的时候干等。
坑五:忘记关进度条导致日志刷屏。批量处理时进度条会输出大量字符,如果重定向到日志文件会撑爆。生产环境记得关掉或者降低刷新频率。
6. 让知识库越用越顺的几个进阶思路
6.1 给检索加一层查询改写
用户的问题往往口语化,直接拿去检索效果不好。可以在检索前先用模型把问题改写成更适合检索的形式。比如"上次那个讲 RAG 瓶颈的是哪篇"改写成"RAG 瓶颈 分析 笔记",检索命中率会高很多。
这个改写步骤可以用小模型做,成本低速度快。我试过用规则做简单改写(去停用词、提取关键词),效果也还行,不一定非要上模型。
6.2 按资料类型分库
PDF 和 Markdown 的检索特点不一样,混在一个库里有时候会互相干扰。我的做法是分两个 collection,检索时分别查再合并。这样可以对不同类型用不同的切块策略和检索参数。
比如 PDF 的块可以大一点(因为 PDF 段落通常较长),Markdown 的块小一点(因为笔记通常精炼)。分开管理灵活度高很多。
6.3 定期评估检索质量
知识库用久了,资料越来越多,检索质量可能会下降。建议定期做一次评估:准备一批"问题-正确答案"对,跑一遍看召回率和准确率。发现下降就排查原因,是切块问题还是模型问题。
这个评估集不用很大,二三十个问题就够。关键是覆盖不同类型的查询:事实型、对比型、总结型。我每加一批新资料就会跑一次,心里有数。
6.4 把常用查询做成快捷入口
有些问题你会反复问,比如"这个项目的技术栈是什么"。与其每次重新检索,不如把结果缓存起来,或者做成预设的查询模板。这样响应更快,也省调用成本。
我用一个简单的 JSON 文件存常用查询和它们的检索结果,查询时先查缓存,命中就直接返回。缓存要设过期时间,资料更新后要失效。
6.5 关于扩展方向的一点想法
这套东西目前够我用了,但还有不少可以扩展的地方。比如加个 Web 界面,不用每次开命令行;比如支持图片和表格的检索,现在只能检索文本;比如接入更多文件格式,像 Word、PPT 这些。
不过我的原则是按需扩展,不为了炫技而加功能。每加一个功能都要维护成本,用不上的功能就是负担。等真的遇到痛点了再动手,这样搭出来的系统才实用。
最后分享一个我自己的使用习惯:我每周会花十分钟翻一翻检索日志,看看哪些问题没答好、哪些资料经常被检索到。前者提示我该补充资料或者调整切块,后者提示我这些内容很重要、值得单独整理。知识库不是搭完就完事,它需要你持续喂养和调教,用得越久越懂你。