1. 为什么我要自己动手做一个知识库问答机器人
我平时有大量阅读和记录的习惯,公众号文章、技术文档、会议纪要、随手记的灵感,散落在微信收藏、Obsidian、Notion、本地 Markdown 文件夹里。时间一长,问题就来了:我记得自己看过某个东西,但就是想不起来在哪看到的,关键词也搜不准。用搜索引擎吧,搜出来的全是别人的二手解读;用笔记软件自带的搜索吧,它只会做字面匹配,我换个说法就找不到。
这就是我决定动手做一个个人知识库问答机器人的直接原因。它要解决的核心问题只有一个:让我用自然语言提问,它从我自己的资料里找出答案,并且告诉我答案来自哪篇文档。不是让 AI 凭空编,而是让 AI 基于我的资料回答,这就是RAG(检索增强生成)的思路。
这篇文章适合谁看?如果你手上有几百上千篇笔记、文档,想用 AI 把它们盘活;如果你听过 Agent、RAG、知识库这些词但不知道从哪下手;如果你已经试过一些现成工具但觉得不够可控——那这篇就是写给你的。我会把整个项目的设计思路、技术选型、实操步骤、踩过的坑全部摊开讲,代码和配置尽量给到能直接抄的程度。
先给一个整体判断:个人知识库问答机器人,本质上是一个 RAG 系统加一层 Agent 编排。RAG 负责"查得准",Agent 负责"用得活"。很多人一上来就追求全自动 Agent,结果连最基础的检索都没做好,答非所问。我的建议是先把 RAG 这条链路跑通,再考虑加 Agent 能力。下面我按这个顺序展开。
2. 整体架构设计与技术选型思路
2.1 先想清楚:这个机器人到底要干什么
动手之前我列了一张需求清单,避免做到一半跑偏。核心需求有这么几条:
- 多格式摄入:Markdown、PDF、纯文本、网页剪藏都要能进库,图片里的文字最好也能提取。
- 语义检索:我提问"怎么配置缓存过期时间",文档里写的是"cache TTL 设置",字面不匹配但语义一致,也要能命中。
- 答案带出处:每个回答必须附上来源文档和片段,方便我回去核对原文。
- 本地优先:个人资料涉及隐私,能本地跑的就本地跑,云端 API 只用在必要环节。
- 增量更新:新加一篇笔记,不用全量重建索引。
这五条决定了后面的技术选型。比如"答案带出处"这一条,直接排除了纯生成式的方案,必须走检索加生成。"本地优先"这一条,决定了嵌入模型优先选能本地部署的。
2.2 RAG 流水线的四个阶段
一个标准的 RAG 流水线分四个阶段,我用一个生活化的类比来解释:把它想象成一个图书馆管理员。
- 摄入(Ingest):把书收进图书馆,登记造册。对应到项目里,就是读取各种格式的文档,清洗掉无关内容。
- 切分(Chunking):把整本书拆成一个个便于查找的段落卡片。文档太长,模型一次读不完,必须切成小块。
- 索引(Indexing):给每张卡片贴上语义标签,存进能快速查找的柜子。这一步把文本转成向量,存进向量数据库。
- 检索与生成(Retrieve & Generate):你问一个问题,管理员先找出最相关的几张卡片,再根据卡片内容组织语言回答你。
很多人做 RAG 失败,问题往往出在切分和检索这两步,而不是模型不够强。切分切得稀碎,检索自然找不准;检索找不准,再强的生成模型也只能瞎编。所以我在这个项目里,把大量精力花在了切分策略和检索调优上。
2.3 技术选型:为什么是这几个组件
我把选型过程整理成一张表,方便你对照自己的情况调整。
| 环节 | 我的选择 | 备选方案 | 选择理由 |
|---|---|---|---|
| 文档解析 | Markdown 直读 + PDF 用解析库 | 通用文档解析服务 | 个人资料以 Markdown 为主,直读最省事 |
| 切分 | 递归字符切分 + 语义边界 | 固定长度切分 | 按标题和段落切,保留上下文完整性 |
| 嵌入模型 | 本地部署的中文嵌入模型 | 云端嵌入 API | 隐私优先,且中文语义效果够用 |
| 向量库 | 轻量本地向量库 | 分布式向量数据库 | 个人数据量在万级,本地库完全够用 |
| 生成模型 | 本地或云端大模型 | 纯本地小模型 | 按算力灵活切换 |
| 编排 | 自己写的轻量 Agent 循环 | 现成编排框架 | 逻辑简单,自己写更可控 |
这里要重点说一下嵌入模型的选择。嵌入模型的作用是把文本转成一串数字(向量),语义相近的文本,向量距离也近。中文场景下,选嵌入模型要看它在中文语义相似度任务上的表现,而不是只看参数量。我实测下来,一个几亿参数的中文嵌入模型,在个人知识库这种场景下,效果和调用云端 API 差别不大,但省了钱也保住了隐私。
提示:嵌入模型和生成模型是两回事。嵌入模型负责"找",生成模型负责"说"。找得准是基础,说得好在其次。别把预算全砸在生成模型上,检索这一环才是决定体验的关键。
2.4 为什么先不上复杂 Agent
现在 Agent 这个词很热,很多人一上来就想搞多 Agent 协作、工具调用、自主规划。我的观点是:个人知识库问答这个场景,90% 的价值来自 RAG 本身,Agent 只是锦上添花。
我一开始也试过让 Agent 自主决定"要不要检索""检索几次""要不要换个关键词再查",结果发现它经常在该检索的时候不检索,或者反复检索同一个词。后来我改成固定流程:先检索,再生成,如果检索结果置信度低,才触发一次改写查询重试。这个简单的规则,比让模型自由发挥稳定得多。
所以这个项目的定位是:一个带轻量 Agent 能力的 RAG 问答系统。Agent 能力体现在查询改写、多轮追问、结果重排这几个点上,而不是让它完全自主。等你把基础 RAG 跑顺了,再往上加复杂编排也不迟。
3. 核心细节解析与实操要点
3.1 文档摄入:把散落各处的资料收拢
摄入这一步看着简单,其实坑最多。我的资料主要来自三个地方:本地 Markdown 文件夹、Obsidian 库、以及从网页剪藏保存的文章。不同来源的格式差异很大,需要分别处理。
对于 Markdown 文件,直接读取文本即可,但要注意去掉 YAML front matter(文件开头用---包裹的元数据块),否则这些元数据会污染检索结果。对于 PDF,我用解析库提取文本,但扫描版 PDF 需要先做 OCR,这一步我单独处理,不放进主流程,避免拖慢速度。
网页剪藏的文章往往带一堆导航栏、广告、评论区文字,直接入库会引入大量噪声。我的做法是先做一轮清洗:去掉连续的空行、去掉长度过短的段落、去掉明显是导航的重复文本。清洗规则不用太复杂,几条正则就能过滤掉大部分噪声。
import re def clean_text(text): # 去掉多余空行 text = re.sub(r'\n{3,}', '\n\n', text) # 去掉行首行尾空白 lines = [line.strip() for line in text.split('\n')] # 过滤掉过短的噪声行 lines = [line for line in lines if len(line) > 2 or line == ''] return '\n'.join(lines)注意:清洗不要过度。我一开始写了个激进的过滤器,把很多短小但重要的列表项也删了,结果检索时经常漏掉关键信息。清洗的目标是去噪声,不是去内容,宁可保守一点。
3.2 切分策略:决定检索质量的关键一步
切分是 RAG 里最容易被低估的环节。切得太长,一个块里混了好几个主题,检索时定位不准;切得太短,上下文丢失,模型拿到碎片也答不好。
我的切分策略是按语义边界递归切分,优先级从高到低:先按一级标题切,再按二级标题切,再按段落切,最后才按固定长度硬切。这样切出来的块,天然带着文档的层级结构,语义完整性好。
具体参数上,我把目标块大小设在 500 到 800 个字符之间,块之间保留 100 字符左右的重叠。重叠的作用是防止一个完整的句子正好被切在边界上,导致两边都读不全。这个数值不是拍脑袋定的,是我拿自己的文档试出来的:太小了检索碎片化,太大了检索不精准。
def split_by_headers(text, max_len=800, overlap=100): # 按标题层级切分,保留标题作为块的上下文 sections = re.split(r'\n(?=#{1,3} )', text) chunks = [] for sec in sections: if len(sec) <= max_len: chunks.append(sec) else: # 超长段落再按长度切,带重叠 for i in range(0, len(sec), max_len - overlap): chunks.append(sec[i:i + max_len]) return chunks这里有个经验:给每个块加上它所属的标题路径。比如一个块来自"第三章 > 3.2 节 > 缓存配置",我就把这段路径拼在块内容前面。这样即使块本身没提到"缓存"两个字,检索时也能靠标题路径命中。这个技巧对结构化文档特别有效。
3.3 向量化与索引:让机器理解语义
切分完成后,每个块都要转成向量。这一步用嵌入模型批量处理,注意控制批大小,太大容易爆内存,太小速度慢。我一般设成 32 或 64,具体看机器配置。
向量存进向量库时,除了向量本身,还要存原始文本和元数据(来源文件、标题路径、块序号)。元数据在检索后用来展示出处,非常重要,千万别省。
关于知识库类型的选择,这里展开说一下。热词里提到的 kg 知识库、rag 知识库、结构知识库,其实是三种不同的组织方式:
- RAG 知识库:以文本块加向量为主,适合非结构化内容,比如笔记、文章。上手快,是我的主力方案。
- KG 知识库(知识图谱):以实体和关系为主,适合需要推理的场景,比如"张三的上级的部门有哪些人"。构建成本高,个人场景下性价比一般。
- 结构化知识库:以表格、字段为主,适合规整数据,比如通讯录、配置清单。查询精确但不擅长语义。
我的做法是以 RAG 为主,结构化数据单独存一份。比如我的配置清单用表格存,问答时如果问题涉及具体配置项,直接查表;其他开放性问题走 RAG。两种方式各司其职,比强行统一成一种要靠谱。
3.4 检索:从"找得到"到"找得准"
检索阶段我做了三件事:向量检索、关键词检索、结果重排。
向量检索负责语义匹配,关键词检索(比如 BM25)负责精确匹配。两者各有盲区:向量检索对专有名词、代码符号不敏感,关键词检索对同义表达无能为力。把两者结果融合,召回率明显提升。这个技术叫混合检索,是我实测下来性价比最高的一步优化。
融合之后再做重排。重排用一个专门的重排模型,对候选结果重新打分排序。它比向量检索慢,但只作用在少量候选上,开销可接受。加了重排之后,最相关的结果基本都能排到前三。
def hybrid_retrieve(query, top_k=10): vec_results = vector_search(query, top_k=top_k) kw_results = keyword_search(query, top_k=top_k) # 用倒数排名融合两路结果 merged = {} for rank, item in enumerate(vec_results): merged[item.id] = merged.get(item.id, 0) + 1 / (rank + 60) for rank, item in enumerate(kw_results): merged[item.id] = merged.get(item.id, 0) + 1 / (rank + 60) ranked = sorted(merged.items(), key=lambda x: -x[1]) return [get_chunk(i) for i, _ in ranked[:top_k]]提示:融合公式里的 60 是个经验常数,来自倒数排名融合的常见做法。它的作用是压低高排名的绝对优势,让两路结果更均衡地参与竞争。你可以根据自己数据调这个值。
3.5 生成:让答案有据可依
生成阶段的核心是提示词设计。我的提示词里明确要求模型:只根据提供的资料回答,资料里没有就说"资料中没有相关信息",不要编造。同时要求它在答案里标注引用来源的编号。
这个约束非常重要。不加约束的话,模型会习惯性地用自己的知识补充,答出来的东西看着对,其实和你的资料无关。加了约束之后,虽然偶尔会显得"保守",但可信度高得多。
多轮追问的处理上,我会把历史对话和当前问题一起送给模型,让它理解上下文。但历史不能无限带,我一般只保留最近三轮,更早的做摘要压缩。否则上下文太长,既慢又贵。
4. 完整实操流程与关键环节实现
4.1 环境准备与依赖安装
先把环境搭起来。我用的是 Python,建议 3.10 以上版本。核心依赖包括文档解析、向量库、嵌入模型推理、以及一个 Web 框架用来做交互界面。
pip install markdown-it-py pypdf pip install sentence-transformers pip install faiss-cpu pip install fastapi uvicorn pip install rank-bm25 jieba这里解释一下每个依赖的作用:markdown-it-py和pypdf负责解析文档,sentence-transformers用来加载嵌入模型,faiss-cpu是向量库,fastapi做接口,rank-bm25和jieba做中文关键词检索。中文分词必须用jieba,因为 BM25 依赖分词质量。
如果你有 GPU,把faiss-cpu换成faiss-gpu,嵌入模型也能跑在 GPU 上,速度提升明显。没有 GPU 也不影响,个人数据量下 CPU 完全够用。
4.2 构建索引的完整脚本
下面是我实际用的索引构建脚本,做了简化但保留了核心逻辑。它的流程是:遍历文档目录,解析每个文件,清洗,切分,向量化,存入向量库和关键词索引。
import os from pathlib import Path from sentence_transformers import SentenceTransformer import faiss import numpy as np import pickle model = SentenceTransformer('your-chinese-embedding-model') dim = model.get_sentence_embedding_dimension() index = faiss.IndexFlatIP(dim) # 内积索引,配合归一化向量等价于余弦相似度 chunks_store = [] def build_index(root_dir): all_chunks = [] for path in Path(root_dir).rglob('*.md'): text = path.read_text(encoding='utf-8') text = clean_text(text) chunks = split_by_headers(text) for i, c in enumerate(chunks): all_chunks.append({ 'text': c, 'source': str(path), 'chunk_id': i }) # 批量向量化 texts = [c['text'] for c in all_chunks] embeddings = model.encode(texts, batch_size=32, normalize_embeddings=True, show_progress_bar=True) embeddings = np.array(embeddings).astype('float32') index.add(embeddings) chunks_store.extend(all_chunks) # 持久化 faiss.write_index(index, 'kb.index') with open('kb_chunks.pkl', 'wb') as f: pickle.dump(chunks_store, f) if __name__ == '__main__': build_index('./my_notes')几个关键点说明一下。normalize_embeddings=True把向量归一化,这样内积就等于余弦相似度,检索更稳定。IndexFlatIP是精确检索,数据量在十万级以内速度都很快,不需要换成近似索引。如果你数据量特别大,再考虑IndexIVFFlat这类近似索引,但会牺牲一点精度。
4.3 问答接口的实现
索引建好后,问答接口的逻辑是:接收问题,混合检索,重排,拼提示词,调用生成模型,返回答案和出处。
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class Query(BaseModel): question: str history: list = [] @app.post('/ask') def ask(q: Query): # 1. 混合检索 candidates = hybrid_retrieve(q.question, top_k=10) # 2. 重排取前 4 个 top_chunks = rerank(q.question, candidates)[:4] # 3. 拼上下文 context = '\n\n'.join( f"[{i+1}] {c['text']}\n来源: {c['source']}" for i, c in enumerate(top_chunks) ) # 4. 生成 prompt = f"""根据以下资料回答问题,只使用资料中的信息。 资料中没有的内容,请回答"资料中未找到相关信息"。 回答时用 [编号] 标注引用来源。 资料: {context} 问题:{q.question} """ answer = call_llm(prompt) return { 'answer': answer, 'sources': [{'source': c['source'], 'text': c['text'][:200]} for c in top_chunks] }这个接口跑起来后,用uvicorn启动,就能通过 HTTP 请求调用了。前端可以很简单,一个输入框加一个结果展示区就够。
4.4 增量更新:新文档怎么进库
全量重建索引在文档多的时候很慢,所以我做了增量更新。思路是记录每个文件的修改时间和内容哈希,每次更新时只处理变化的文件。新增的块追加到向量库,删除的块从库里移除。
这里有个坑:faiss的IndexFlatIP不支持直接删除。我的解决办法是给每个块一个唯一 ID,删除时把 ID 记进一个"墓碑"列表,检索后过滤掉这些 ID。等墓碑积累到一定数量,再做一次全量重建清理。这个方案简单有效,个人场景下完全够用。
def incremental_update(root_dir): current_files = {str(p): p.stat().st_mtime for p in Path(root_dir).rglob('*.md')} for path, mtime in current_files.items(): if file_index.get(path) != mtime: # 该文件有变化,重新处理 remove_chunks_by_source(path) add_chunks_from_file(path) file_index[path] = mtime # 处理已删除的文件 for path in list(file_index.keys()): if path not in current_files: remove_chunks_by_source(path) del file_index[path]4.5 图片内容的处理
热词里有人问"rag 知识库能存储图片吗",这是个好问题。严格说,向量库存的是文本向量,图片本身不直接存。但图片里的信息可以通过两种方式进库:
一是OCR 提取文字,把图片里的文字转成文本再入库。适合截图、扫描件。二是多模态嵌入,用支持图文的多模态模型,把图片和文本映射到同一向量空间。这种方式更强大,但模型更大,部署成本高。
我的做法是:对截图类图片做 OCR,把文字提取出来,同时在元数据里记录原图路径。这样检索到文字时,能顺带把原图展示出来。对纯装饰性图片直接跳过,不浪费算力。
5. 常见问题与排查技巧实录
5.1 检索不准的排查思路
检索不准是最常见的问题,表现是"明明文档里有,就是搜不出来"。我总结了一套排查顺序:
| 现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 完全搜不到 | 文档没进库 | 检查索引块数量 | 重新构建索引 |
| 搜到但排很后 | 切分太碎 | 看命中块的完整性 | 调大块大小 |
| 语义相近搜不到 | 嵌入模型不适配 | 换模型对比测试 | 换中文优化模型 |
| 专有名词搜不到 | 缺关键词检索 | 检查 BM25 是否生效 | 加混合检索 |
| 结果重复 | 重叠太大 | 看块间重叠比例 | 减小重叠 |
我踩过最深的一个坑是嵌入模型和文档语言不匹配。早期我用了个英文为主的嵌入模型,中文检索效果惨不忍睹。换成中文优化的模型后,同样的数据,命中率提升了一大截。所以选嵌入模型,一定要拿自己的真实数据测,别只看排行榜。
5.2 生成答案胡编的应对
模型胡编,通常是两个原因:要么检索没找到相关内容,模型只能自己编;要么提示词约束不够,模型习惯性补充。
针对第一个原因,我在提示词里加了"资料中没有就说没有"的硬约束,并且在检索结果置信度低于阈值时,直接返回"未找到相关信息",不调用生成模型。这样虽然偶尔会漏答,但杜绝了瞎编。
针对第二个原因,我把提示词里的约束写得更具体,比如"不要使用资料之外的任何知识""如果资料只提到部分信息,只回答这部分"。约束越具体,模型越听话。
注意:不要指望一次就把提示词调好。我前后改了十几版,每版都拿一批固定问题测试,记录哪些答对了哪些答错了,逐步优化。这个过程没有捷径。
5.3 性能优化的几个实用技巧
个人知识库虽然数据量不大,但如果不注意,检索也会慢。我做了这几件事:
- 向量归一化后用内积索引:比欧氏距离快,且结果等价。
- 嵌入模型批处理:建索引时批量编码,比逐条快好几倍。
- 检索结果缓存:相同问题直接返回缓存,省去重复计算。
- 关键词索引预加载:BM25 索引启动时加载进内存,避免每次重建。
还有一个容易被忽略的点:嵌入模型加载本身很慢。如果每次请求都重新加载,响应时间会很长。我的做法是服务启动时加载一次,常驻内存。这个改动让单次响应从好几秒降到了几百毫秒。
5.4 数据安全与隐私的注意事项
个人知识库涉及隐私,这几点必须注意:
- 本地优先:嵌入和检索尽量本地跑,不把原文传到外部。
- 生成环节谨慎:如果必须用云端生成模型,只传检索到的片段,不传整个知识库。
- 访问控制:如果做成 Web 服务,加个简单的鉴权,别裸奔在公网。
- 备份索引:向量库和原始文档都要定期备份,重建索引很费时间。
我自己是全程本地跑的,生成模型也用本地部署的。虽然效果比顶级云端模型差一点,但隐私这块心里踏实。如果你对效果要求高,可以只把生成环节放云端,检索和存储保持本地。
6. 后续可以怎么扩展
基础版本跑通后,我陆续加了几个扩展,体验提升明显。
第一个是多轮追问。用户问"缓存怎么配",答完后接着问"那过期时间呢",系统能理解这是在问缓存的过期时间。实现上就是把历史对话拼进提示词,让模型做指代消解。
第二个是来源高亮。答案里标注了引用编号,前端点击编号能跳到原文对应位置。这个功能对核对信息特别有用,实现上需要在元数据里记录块在原文的字符偏移。
第三个是定时增量更新。用定时任务每天扫一遍文档目录,有变化就增量更新索引。这样新写的笔记第二天就能被检索到,不用手动触发。
再往深了做,可以尝试查询改写:用户的问题表述不好时,让模型先改写成更适合检索的形式,再拿去检索。我试过,对口语化提问效果提升明显,但会增加一次模型调用,要权衡延迟。
最后分享一个我踩过的坑:别过早追求完美。我一开始想把切分、检索、重排每个环节都调到最优,结果卡了两周没出成果。后来改成先跑通一个能用的版本,再逐步优化,反而进展快得多。个人项目,能用比完美重要。