乐于分享
好东西不私藏

从0搭建你的个人AI助手:Python + RAG 完整实战(上)

从0搭建你的个人AI助手:Python + RAG 完整实战(上)

我的笔记,找不到了

我有一个习惯,遇到值得记的东西就往Obsidian里丢。读书笔记、技术备忘、项目方案、甚至菜谱,全堆在一起。

三年下来,攒了800多个md文件。

问题是,我开始找不到东西了。上个月客户问了一个之前写过方案的问题,我明明记得写过,死活搜不到。用关键词搜,搜出来的全是无关内容。换了个关键词,还是不行。最后花了20分钟,在一个叫杂项202403的文件夹里翻到了。

笔记的价值不在于记,在于能找到。找不到,等于白写。


RAG是什么

RAG,Retrieval-Augmented Generation,检索增强生成。说白了就是两步:

  1. 先把你的文档切块、转成向量、存起来

  2. 你提问时,先从向量库里找出最相关的几段,再把这些段落和你的问题一起丢给大模型,让它基于你的内容回答

这样做比纯大模型对话靠谱得多。大模型会编,但RAG会先帮它画好边界:回答必须基于你提供的材料。

本文是上篇,专注第一步:搭建文档向量化和检索系统。下篇会接入大模型,做成一个完整的对话助手。


技术选型

组件选择理由
框架LangChain生态好,组件多,社区活跃
向量数据库ChromaDB本地运行,不用装额外服务,Python原生
Embedding模型sentence-transformers免费,本地跑,中文效果不错
文档加载LangChain内置loader支持md/txt/pdf/docx多种格式

这里没用FAISS,因为ChromaDB用起来更省心,不需要额外装C++依赖。对于几百上千篇笔记的规模,ChromaDB完全够用。


环境准备

pip install langchain langchain-community chromadb sentence-transformers

如果你还需要加载PDF或docx文件:

pip install pypdf python-docx

装完就可以开始了。


项目结构

my_rag/├── docs/            # 把你的笔记文件丢这里│   ├── note1.md│   ├── note2.md│   └── ...├── build_index.py   # 第一步:建立向量索引├── search.py        # 第二步:检索测试└── chat.py          # 下篇:对话助手(暂时不用管)

docs/目录下放你的文件。md、txt、pdf都行,脚本会自动识别。


第一步:建立向量索引

这是核心步骤。把文档加载进来,切块,转成向量,存到ChromaDB里。

"""build_index.py把 docs/ 目录下的文档向量化,存入 ChromaDB运行方式:python build_index.py"""import osfrom pathlib import Pathfrom langchain_community.document_loaders import (DirectoryLoader,TextLoader,UnstructuredMarkdownLoader,PyPDFLoader,)from langchain.text_splitter import RecursiveCharacterTextSplitterfrom langchain_community.embeddings import HuggingFaceEmbeddingsfrom langchain_community.vectorstores import Chroma# ============ 配置 ============DOCS_DIR = Path("docs")                    # 文档目录CHROMA_DIR = Path("chroma_db")             # 向量数据库存储路径EMBEDDING_MODEL = "shibing624/text2vec-base-chinese"# 中文embedding模型CHUNK_SIZE = 500# 每个文本块的最大字符数CHUNK_OVERLAP = 80# 块之间的重叠字符数# ============ 1. 加载文档 ============def load_documents(docs_dir):"""加载目录下所有 md、txt、pdf 文件"""loaders = []# Markdown文件md_loader = DirectoryLoader(str(docs_dir),glob="**/*.md",loader_cls=UnstructuredMarkdownLoader,loader_kwargs={"mode""single"},    )loaders.append(md_loader)# 纯文本文件txt_loader = DirectoryLoader(str(docs_dir),glob="**/*.txt",loader_cls=TextLoader,loader_kwargs={"encoding""utf-8"},    )loaders.append(txt_loader)# PDF文件pdf_loader = DirectoryLoader(str(docs_dir),glob="**/*.pdf",loader_cls=PyPDFLoader,    )loaders.append(pdf_loader)all_docs = []for loader in loaders:try:docs = loader.load()all_docs.extend(docs)print(f"  加载了 {len(docs)} 个文件({loader.glob})")except Exception as e:print(f"  加载 {loader.glob} 文件时出错: {e}")return all_docs# ============ 2. 文本切块 ============def split_documents(documents):"""把长文档切成适合向量化的小块"""splitter = RecursiveCharacterTextSplitter(chunk_size=CHUNK_SIZE,chunk_overlap=CHUNK_OVERLAP,# 按中文习惯的分隔符来切separators=["\n\n""\n""。""!""?"";"" """],    )chunks = splitter.split_documents(documents)return chunks# ============ 3. 向量化并存储 ============def build_vector_store(chunkspersist_dir):"""将文本块转成向量,存入 ChromaDB"""# 加载embedding模型(首次运行会自动下载,约400MB)print("  正在加载 embedding 模型...")embeddings = HuggingFaceEmbeddings(model_name=EMBEDDING_MODEL,model_kwargs={"device""cpu"},  # 有GPU可以改成 "cuda"    )# 存入 ChromaDBprint(f"  正在向量化 {len(chunks)} 个文本块...")vectorstore = Chroma.from_documents(documents=chunks,embedding=embeddings,persist_directory=str(persist_dir),collection_name="my_notes",    )return vectorstore# ============ 主流程 ============def main():print("=== 开始构建文档索引 ===\n")# 检查文档目录是否存在if not DOCS_DIR.exists():print(f"文档目录 {DOCS_DIR} 不存在,请先创建并放入文件")DOCS_DIR.mkdir(parents=True)print(f"已创建 {DOCS_DIR},请放入文件后重新运行")return# 加载文档print("[1/3] 加载文档...")documents = load_documents(DOCS_DIR)if not documents:print("没有找到任何文档,请检查 docs/ 目录")returnprint(f"  共加载 {len(documents)} 个文档\n")# 切块print("[2/3] 文本切块...")chunks = split_documents(documents)print(f"  切成了 {len(chunks)} 个文本块\n")# 向量化print("[3/3] 向量化并存储...")build_vector_store(chunksCHROMA_DIR)print(f"  索引已保存到 {CHROMA_DIR}")print("\n=== 索引构建完成 ===")if __name__ == "__main__":main()

运行一下:

python build_index.py

输出大概长这样:

=== 开始构建文档索引 ===[1/3] 加载文档...  加载了 47 个文件(**/*.md)  加载了 12 个文件(**/*.txt)  共加载 59 个文档[2/3] 文本切块...  切成了 384 个文本块[3/3] 向量化并存储...  正在加载 embedding 模型...  正在向量化 384 个文本块...  索引已保存到 chroma_db=== 索引构建完成 ===

首次运行会下载embedding模型,大概400MB,之后就不用再下了。


第二步:检索测试

索引建好了,来测试一下能不能搜到想要的内容。

"""search.py从向量数据库中检索与问题最相关的文档片段运行方式:python search.py "你的问题""""import sysfrom pathlib import Pathfrom langchain_community.embeddings import HuggingFaceEmbeddingsfrom langchain_community.vectorstores import ChromaCHROMA_DIR = Path("chroma_db")EMBEDDING_MODEL = "shibing624/text2vec-base-chinese"def search(querytop_k=3):"""根据问题检索最相关的文档片段"""# 加载embedding模型embeddings = HuggingFaceEmbeddings(model_name=EMBEDDING_MODEL,model_kwargs={"device""cpu"},    )# 从 ChromaDB 加载已有的向量索引vectorstore = Chroma(persist_directory=str(CHROMA_DIR),embedding_function=embeddings,collection_name="my_notes",    )# 相似度检索results = vectorstore.similarity_search_with_relevance_scores(queryk=top_k)return resultsdef main():if len(sys.argv<2:print("用法:python search.py \"你的问题\"")print("示例:python search.py \"Python怎么连接MySQL\"")returnquery = sys.argv[1]top_k = int(sys.argv[2]) if len(sys.argv>else 3print(f"问题:{query}\n")print("="*50)results = search(querytop_k)for i, (docscorein enumerate(results1):source = doc.metadata.get("source""未知来源")print(f"\n--- 结果 {i}(相关度: {score:.4f})---")print(f"来源: {source}")print(f"内容:\n{doc.page_content[:500]}")print()print(f"\n共找到 {len(results)} 个相关片段")if __name__ == "__main__":main()

试几个问题看看效果:

python search.py "之前那个项目的部署流程是什么"
问题:之前那个项目的部署流程是什么==================================================--- 结果 1(相关度: 0.8734)---来源: docs/projects/deploy-guide.md内容:## 部署流程1. 先在测试环境跑通所有用例2. 用docker-compose打包服务3. 推到测试服务器验证4. 通过后切到生产环境注意事项:数据库迁移脚本一定要在部署前手动执行...--- 结果 2(相关度: 0.7291)---来源: docs/weekly/2024-w12.md内容:本周完成了客户管理系统的部署。踩坑记录:- Nginx反向代理的proxy_pass后面要加斜杠- 环境变量一定要在Dockerfile里设,别用.env...--- 结果 3(相关度: 0.6845)---来源: docs/notes/docker-tips.md内容:Docker Compose 部署模板...共找到 3 个相关片段

搜到了。而且相关度分数能帮你判断结果靠不靠谱。0.87说明非常匹配,0.68说明有一定关联但可能不太直接。


关于切块参数的选择

CHUNK_SIZE 和 CHUNK_OVERLAP 是两个要调的参数。

CHUNK_SIZE=500 适合大多数笔记场景。如果你的文档都是长篇大论,可以调大到800或1000。如果都是短条目,调到200也行。

CHUNK_OVERLAP=80 的意思是相邻两个块之间有80个字符的重叠。这样做是为了防止一句话刚好被切断,导致上下文丢失。一般设为CHUNK_SIZE的15%到20%。

没有万能参数。你自己的文档,跑几次搜一搜,感觉不对就调。


关于Embedding模型

我选的是 shibing624/text2vec-base-chinese,中文效果不错,模型也比较小。

如果你笔记里英文内容居多,可以换成 all-MiniLM-L6-v2,英文表现更好。

如果你想要两全其美,可以试试 BAAI/bge-small-zh-v1.5,中英文都还行。

换模型只需要改 EMBEDDING_MODEL 的值。注意,换了模型之后要重新跑 build_index.py,因为不同模型生成的向量维度不一样,不能混用。


下篇预告

到目前为止,你已经有了一个能用的文档检索系统。输入问题,输出相关文档片段。

但这还不够。我想做的是:直接问问题,AI基于我的笔记给出回答,而不是只返回原始片段。

下篇会做这些事:

  1. 接入大模型API(通义千问或OpenAI)

  2. 把检索结果和原始问题一起喂给模型

  3. 做一个命令行对话界面,持续对话

  4. 加上对话历史记忆

上篇是地基,下篇才盖房子。


附:如果你不想用本地模型

如果你觉得本地跑embedding模型太慢,或者机器配置不够,也可以用API方式。比如用通义千问的embedding接口:

# pip install langchain-community dashscopefrom langchain_community.embeddings import DashScopeEmbeddingsembeddings = DashScopeEmbeddings(model="text-embedding-v2",dashscope_api_key="your_api_key_here",)

效果差不多,速度快一些,但要联网,而且有API调用费用。本地模型的好处是一次下载终身免费,离线也能跑。

看你自己的情况选择。

下篇见。

“无他,惟手熟尔”!有需要的用起来!关注微信公众号「Nicholas与Pypi」获取更多Python实战!
------加入知识库与更多人一起学习------

https://ima.qq.com/wiki/?shareId=f2628818f0874da17b71ffa0e5e8408114e7dbad46f1745bbd1cc1365277631c