👦抠腚男孩的AI学习之旅 | 7、LangChain (三) - 实战:知识库问答机器人 (RAG )

1. 引言

😄 前两节把 LangChain 的文档撸了个遍,对 "七大核心组件 " 的 API 进行了系统的学习:

  • 【Models】 统一接口调用不同大语言模型,实现灵活切换和简化管理。
  • 【Prompts】 支持动态生成提示词模板,提高复用性和灵活性。
  • 【Tools】 封装外部功能接口,可使Agent调用搜索、计算等多种工具。
  • 【Chains】 链式调用组合多个组件,支持复杂任务和异步流式处理。
  • 【Memory】 管理对话历史和上下文,实现多用户会话隔离和状态持久。
  • 【Indexes】 构建文档索引,支持加载、分割、嵌入和语义检索。
  • 【Agents】 智能体,结合LLM推理和工具调用,实现循环决策与行动。

😶 "基础 " 有了,写起 简单Demo 也是信手拈来,接着就该折腾如何 "进阶 " 了。从 "知道 " 到 "熟练精通 ",最有效的方式往往是通过 "项目实战":

将零散的知识点串联起来,不断深入对框架的理解,逐步掌握高级功能和应用模式,形成解决复杂问题的能力。

💁‍♂️ 由浅入深,先来整个 "基于个人文档的智能问答机器人 ",这是 LangChain 最经典、最重要的应用场景之一,也是所有复杂LLM应用的基石。核心:利用 RAG (检索增强生成) 让 LLM 能够回答其自身知识库之外的、基于你私有文档的问题。RAG 技术实现步骤之前已有提及,再搬运下:

  • 文档加载 :使用 Document Loaders 加载你的本地文件(如PDF、TXT、Markdown)或网页内容。
  • 文本分割:将加载的文档切割成小的、语义完整的文本块 (Chunks),以便于后续处理。
  • 向量化:使用嵌入模型 (如OpenAI Embeddings) 将文本块转换为向量表示。
  • 向量存储:将向量化的文本块存入向量数据库 (如 Chroma, FAISS)。
  • 检索:当用户提问时,将问题也进行向量化,并在向量数据库中检索最相关的文本块。
  • 生成 :将检索到的相关文本块和用户原始问题一起作为上下文,构建一个精确的提示 (Prompt),丢给LLM生成最终答案。

2. 全局思维与规划

😄 别急着 "写代码",先像设计一款真正的软件产品一样来思考,清晰的规划会让整个开发过程事半功倍。

2.1. 明确需求 & 目标

🤔 首先,明确我们希望这个机器人具体能做什么?

  • 核心目标:创建一个智能机器人,它能且仅能根据知识库的内容,回答用户提出的问题。
  • 用户输入:涉及知识库主题的自然语言问题,如:"请解释一下LangChain中的LCEL是什么?"。
  • 期待输出:一个基于知识库、准确流畅的回答,最好能附上答案所参考的原文链接,方便用户深入阅读。
  • 关键约束 :机器人的回答必须严格基于知识库的内容,不能 "自由发挥" 或 "凭空捏造" (即"AI幻觉") 。
  • 用户体验:当找不到答案时,它应该如何回应?如:"根据我的知识库,我找不到相关信息"。
  • 交互方式:是一个简单的命令行工具,还是一个带界面的 Web 应用?

2.2. 技术选型 & 可行性分析

😶 基于需求选择合适的技术栈,LangChain 固然是核心,但它需要语其它组件配合。

  • LLM:用哪个模型?OpenAI、Gemini、Claude,或是其它开源模型 (如 Llama)?这会直接影响到成本和回答的质量。
  • Embedding 模型:如何将你的文本转化为向量?用 OpenAI 的 text-embedding-3-small?还是选择Hugging Face 上的开源模型?
  • 向量数据库:文章向量化后需要存储在哪里以便快速检索?对于个人项目,内存中的 FAISS 或本地存储的 Chroma 都是不错的选择。如果未来文档量巨大,可以考虑 Pinecone 或 Weaviate 等云服务。
  • 文档加载与处理:知识库文章是什么格式的?Markdown、HTML?需要选择合适的文档加载器。

2.3. 开发流程

一个标准的 AI 应用开发流程应该包含以下几个关键阶段:

  • 数据准备 (Data Preparation)
  • 系统设计 (System Design)
  • 核心实现 (Implementation)
  • 评估与迭代 (Evaluation & Iteration)
  • 部署与监控 (Deployment & Monitoring)

😏 规划完,接着就是按部就班一一实践了~

3. 数据准备

3.1. 数据收集

🤔 知识库文章的保存格式?纯文本 (.txt) 还是 Markdown (.md) ?这里我选择后者。然后是 "如何获取知识库的文章 "? 两种常规手段: "逐篇手工复制粘贴另存为" 和 "写爬虫自动爬 "。🤡 杰哥用的 "语雀" 管理知识库:

🤷‍♀️ 不支持整个知识库导出,只支持单篇文章逐一导出,"超级会员 " 可以申请 Token ,然后利用 API 批量导出md文件,具体怎么搞,可以看我之前写的:《🐦数据无价!自己写个"语雀" 自动备份脚本》,懒得看的,直接使用 coder-pig/YuQueBackups 里的 "语雀文档备份脚本.exe",双击运行后,粘贴Token回车即可自动备份:

😶 我这种情况获取到的文章数据都是比较 "干净 " 的,平时写爬虫,爬取到的内容包含导航栏、页脚、广告、评论区等无关信息,这些 "噪声 " 会严重干扰后续的检索质量,所以还需进行 "数据清晰 ",如:使用 BeautifulSoup 库精确提取文章正文部分的HTML内容,或者用 正则表达式 去除固定的页眉页脚。😄 干净的数据源是高质量回答的第一步

3.2. 文本分割

🤔 为什么要分割

答:LLM 有 "上下文长度" 限制,我们无法将一整篇长文一次性都扔给它,需要将长文档分成更小的块。

如何分割

答: LangChain 提供了多种文本分割器,RecursiveCharacterTextSplitter 是一个比较通用的选择,它会尝试按段落、句子等方式进行递归分割,以保持语义的完整性。你只需设定合适的 chunk_size (块大小) 和 chunk_overlap (块之间的重叠字数,有助于保持上下文连续性)。

3.3. 向量化与存储

🤔 嵌入模型OpenAItext-embedding-3-small向量数据库轻量级本地存储Chroma 。创建下我们的项目「cp-qa-robot 」,可以在 Python IDE (如 PyCharm ) 直接创建, 也可以在 终端 中执行下述命令行创建:

python 复制代码
mkdir cp-qa-robot
cd cp-qa-robot

# 创建独立的Python环境,避免与系统中的其它库冲突
python -m venv venv
# 激活虚拟环境
# Windows
.\venv\Scripts\activate

# macOS/Linux
source venv/bin/activate

# 根目录创建 requirements.txt 文件
echo. > requirements.txt

# 打开文件
start requirements.txt

# 添加下述依赖 (复制粘贴到文件中)
langchain>=0.3.27
langchain-community>=0.3.28
langchain-core>=0.3.75
langchain-openai>=0.3.32
langchain-text-splitters>=0.3.9
python-dotenv>=1.0.0
chromadb==0.4.24

# 安装所有依赖
pip install -r requirements.txt

# 创建Git仓库
git init

参考下述 目录结构,创建相关文件和文件夹:

bash 复制代码
/cp-qa-robot/
├── .env                  # 存放环境变量,如 API 密钥 (!!! 不要提交到 Git)
├── .gitignore            # Git 忽略文件配置
├── README.md             # 项目说明文档
├── requirements.txt      # 项目依赖的 Python 包
|
├── data/                 # 存放你的原始知识库文档 (例如 .md 文件)
│   └── my-blog-post-1.md
│   └── my-blog-post-2.md
|
├── vector_store/         # 存放持久化的向量数据库文件 (例如 ChromaDB 的数据)
|
├── scripts/              # 存放一次性或辅助性的脚本
│   └── ingest.py         # 数据处理脚本:加载、分割、向量化并存入数据库
|
├── src/                  # 核心源代码目录
│   ├── __init__.py
│   ├── core/             # 存放项目核心逻辑
│   │   ├── __init__.py
│   │   └── qa_chain.py   # 问答链的构建逻辑,这是项目的"大脑"
│   ├── utils/            # 存放通用的辅助函数
│   │   ├── __init__.py
│   │   └── helpers.py
│   ├── main_cli.py       # 命令行交互的入口文件
│   └── main_web.py       # (可选) Web 应用 (如 Streamlit, FastAPI) 的入口文件
|
└── tests/                # (进阶) 存放测试用例
    ├── __init__.py
    └── test_qa_chain.py

.env 文件中配下API_KEY等参数,然后添加到 .gitignore文件中,避免提交到Git:

bash 复制代码
API_KEY="sk-xxx"
BASE_URL="https://xxx.xxx.ai/v1"
LLM_MODEL="gemini-2.5-flash-lite"
EMBEDDINGS_MODEL="text-embedding-3-small"

3.3.1. Chroma 速成

创建 Chroma 客户端

一位20歲中國女生,擁有野性美與精緻五官。瘦瘦的臉龐、大眼、小巧挺直的鼻子、櫻桃小嘴與尖下巴,面带微笑。黑色長直髮細軟飄逸,偏瘦的模特兒身材。她身穿黃色露肩針織長袖上衣,內搭細吊帶,露出鎖骨,下身配淺藍色寬鬆牛仔褲,雙腿屈膝盤坐。簡約室內環境,淺色牆面與棕色物件構成背景,半身近景居中構圖。日系清新自然風格,8K高清畫質。柔和室內漫射光營造溫暖氛圍,7200K暖光逆側光與倫勃朗光突顯立體感,暗調與暗影增添質感,低飽和色調搭配細膩膚質表現,展現居家生活的寧靜愜意與鬆弛感。 复制代码
import chromadb

# 内存
chromadb.Client()

# 持久化 (后面是数据存储路径)
chromadb.PersistentClient(path="vector_store")

💡 当你首次创建集合并没有指定其它 "嵌入函数 " 时,Chroma 会在后台自动下载 all-MiniLM-L6-v2 模型所需的文件。如果需要使用其它 Sentence Transformer 模型,可在创建集合时通过 embedding_function 参数明确指定类型的名称。

基础操作

一位20歲中國女生,擁有野性美與精緻五官。瘦瘦的臉龐、大眼、小巧挺直的鼻子、櫻桃小嘴與尖下巴,面带微笑。黑色長直髮細軟飄逸,偏瘦的模特兒身材。她身穿黃色露肩針織長袖上衣,內搭細吊帶,露出鎖骨,下身配淺藍色寬鬆牛仔褲,雙腿屈膝盤坐。簡約室內環境,淺色牆面與棕色物件構成背景,半身近景居中構圖。日系清新自然風格,8K高清畫質。柔和室內漫射光營造溫暖氛圍,7200K暖光逆側光與倫勃朗光突顯立體感,暗調與暗影增添質感,低飽和色調搭配細膩膚質表現,展現居家生活的寧靜愜意與鬆弛感。 复制代码
# ================================
# 💡 ① 创建集合用于存储向量数据
# ================================
collection = client.get_or_create_collection(
    # 用于标识向量集合的名称
    name="book_notes", 
    # 集合的元数据配置,告诉ChromaDB应使用什么方式计算向量间的相似度
    # hnsw:space 参数
    # [cosine]:余弦相似度 - 最常用,因为它关注向量方向而不是绝对距离,
    # 非常适合比较文档或文本片段的语义相似性!
    # [12]:欧几里得距离 (L2距离)
    # [ip]:内积相似度
    metadata={"hnsw:space": "cosine"}  
)

# ================================
# 💡 ② 添加集合
# ================================
documents = [
    "Python 是一种高级编程语言,广泛用于数据科学和机器学习。",
    "JavaScript 是网页开发的核心语言,可以运行在浏览器和服务器上。",
    "机器学习是人工智能的一个重要分支,通过算法让计算机学习数据模式。",
    "深度学习使用神经网络来解决复杂的模式识别问题。",
    "自然语言处理帮助计算机理解和生成人类语言。"
]

metadatas = [
    {"category": "programming", "language": "python"},
    {"category": "programming", "language": "javascript"},
    {"category": "ai", "topic": "machine_learning"},
    {"category": "ai", "topic": "deep_learning"},
    {"category": "ai", "topic": "nlp"}
]

ids = ["doc1", "doc2", "doc3", "doc4", "doc5"]

print("📝 添加文档到集合...")
collection.add(
    documents=documents, # 要存储的实际文档内容,list[str],这些文档会被自动转换为向量嵌入并存储
    metadatas=metadatas, # 为每个文档添加额外的信息标签, 可以用来过滤搜索结果,比如只搜索某个类别的文档
    ids=ids # 为每个文档提供唯一的标识符,用于后续更新、删除或查询特定文档
)

# ================================
# 💡 ③ 查询文档
# ================================
print("\n🔍 查询相似文档...")
results = collection.query(
    query_texts=["编程语言"],   # 进行向量搜索的查询文本
    n_results=3   # 返回的结果数量
)
print("查询结果:")
for i, (doc, distance, metadata) in enumerate(zip(
    results['documents'][0], 
    results['distances'][0], 
    results['metadatas'][0]
)):
    print(f"  {i+1}. 距离: {distance:.4f}")
    print(f"     文档: {doc}")
    print(f"     元数据: {metadata}")
    print()

# ================================
# 💡 ④ 文档更新
# ================================
collection.update(
    ids=["update_doc_1"],
    documents=["这是更新后的文档内容,包含更多信息"],
    metadatas=[{"version": 2, "status": "published"}]
)

# ================================
# 💡 ⑤ 文档删除
# ================================
collection.delete(ids=["temp_1", "temp_2"])

# ================================
# 💡 ⑥ 其它
# ================================
# Collection
upsert() # 插入或更新文档
delete() # 删除指定文档
count() #获取文档总数
name # 集合名称
metadata #集合元数据
id # 集合ID

# Client
list_collections() # 列出所有集合
get_collection() #获取现有集合
get_or_create_collection() # 获取或创建集合
delete_collection() # 删除集合
reset() #重置所有数据

运行输出结果 (🤡 这里踩了一个小坑,一开始用的 chromadb 版本是1.0.20 ,执行add、query等操作,程序就直接结束了,后续代码不走,也没有任何的输出,后面发现回退到 0.4.24 版本就好了,猜测是兼容性问题):

高级过滤 (query设置其它参数)

一位20歲中國女生,擁有野性美與精緻五官。瘦瘦的臉龐、大眼、小巧挺直的鼻子、櫻桃小嘴與尖下巴,面带微笑。黑色長直髮細軟飄逸,偏瘦的模特兒身材。她身穿黃色露肩針織長袖上衣,內搭細吊帶,露出鎖骨,下身配淺藍色寬鬆牛仔褲,雙腿屈膝盤坐。簡約室內環境,淺色牆面與棕色物件構成背景,半身近景居中構圖。日系清新自然風格,8K高清畫質。柔和室內漫射光營造溫暖氛圍,7200K暖光逆側光與倫勃朗光突顯立體感,暗調與暗影增添質感,低飽和色調搭配細膩膚質表現,展現居家生活的寧靜愜意與鬆弛感。 复制代码
# ================================
# 💡 根据元数据进行过滤搜索
# ================================
# 根据类别过滤
where={"category": "programming"}

# 复杂过滤条件
where={
  "$and": [
      {"category": "ai"}, 
      {"topic": "machine_learning"}
  ]
}

# 数值范围过滤
where={"price": {"$gte": 100, "$lte": 500}}

# ================================
# 💡 根据文档内容进行过滤
# ================================
# 包含特定文本的文档
where_document={"$contains": "Python"}

# 不包含特定文本
where_document={"$not_contains": "JavaScript"}

# ================================
# 💡 指定返回结果中包含的信息类型
# 默认值:["metadatas", "documents", "distances"]
# 可选值:默认值的基础上多个"embeddings"
# ================================
# 只返回文档和距离
include=["documents", "distances"]

# 返回所有信息
include=["embeddings", "metadatas", "documents", "distances"]

# ================================
# 💡 直接使用向量嵌入进行查询,与query_texts二选一
# ================================
# 使用预计算的向量
query_embeddings=[[0.1, 0.2, 0.3, ...]]

# ================================
# 💡 使用图像进行查询(需要支持多模态的嵌入模型)
# ================================
# 读取本地图像文件
with open("./images/query_image.jpg", "rb") as img_file:
    image_data = img_file.read()

# 使用图像数据进行查询
results = collection.query(
    query_images=[image_data],  # 传入图像的二进制数据
    n_results=5,
    include=["documents", "metadatas", "distances"]
)

# ================================
# 💡 使用URI进行查询
# ================================
# 使用网络图像URL进行查询
results = collection.query(
    query_uris=["https://example.com/sample_image.jpg"],
    n_results=5,
    where={"category": "nature"},  # 可以结合过滤条件
    include=["documents", "metadatas", "distances"]
)

print("基于URL的图像查询结果:")
for doc, metadata in zip(results['documents'][0], results['metadatas'][0]):
    print(f"找到相似内容: {doc}")
    print(f"标签: {metadata}")

3.3.2. 嵌入模型的敲定

上面说了 ChromaDB 默认使用 all-MiniLM-L6-v2 模型作为 "嵌入参数":

一位20歲中國女生,擁有野性美與精緻五官。瘦瘦的臉龐、大眼、小巧挺直的鼻子、櫻桃小嘴與尖下巴,面带微笑。黑色長直髮細軟飄逸,偏瘦的模特兒身材。她身穿黃色露肩針織長袖上衣,內搭細吊帶,露出鎖骨,下身配淺藍色寬鬆牛仔褲,雙腿屈膝盤坐。簡約室內環境,淺色牆面與棕色物件構成背景,半身近景居中構圖。日系清新自然風格,8K高清畫質。柔和室內漫射光營造溫暖氛圍,7200K暖光逆側光與倫勃朗光突顯立體感,暗調與暗影增添質感,低飽和色調搭配細膩膚質表現,展現居家生活的寧靜愜意與鬆弛感。 复制代码
# 使用 "默认模型" 需要安装 sentence-transformers 库 (也间接依赖 torch库)
from langchain_community.embeddings import SentenceTransformerEmbeddings

embeddings = SentenceTransformerEmbeddings(model_name="all-MiniLM-L6-v2")

# 上述代码运行会有警告,因为 SentenceTransformerEmbeddings
# (实际上是 HuggingFaceEmbeddings) 在 LangChain 0.2.2被标记为废弃
# pip 安装完 langchain_huggingface 库后,做下修改
from langchain_huggingface import HuggingFaceEmbeddings

embeddings = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")

🤔 这个模型的特点:

  • 优势:轻量 (模型小巧,推理速度快)、通用性好 (适合大多数英文文本场景)、开箱即用 (ChromaDB 默认集成,无需额外配置)、成本低 (本地运行,无API调用费用)。
  • 局限对中文文本的理解 可能不如专门的中文模型,特定领域/任务 (医学、法律) 可能表现不佳,向量维度-384维,可能不如更大模型的表达能力。

😶 我的知识库里的文档都是 中文 ,还是用 LLM 厂商提供的嵌入模型吧,让AI比较下 OpenAIGemini

Gemini 很强很好,但是我还是选择了 "性价比最高 " 的 OpenAI text-embedding-3-small → $0.02/M tokens,🤡 Gemini embedding-001text-embedding-3-large 还贵(0.15 VS 0.13)...

3.3.3. 文本分割的设计

🤔 分割器用的 RecursiveCharacterTextSplitter ,主要是 chunk_sizechunk_overlapseparators 这三参数配置的权衡。先是 嵌入模型 - OpenAI text-embedding-3-small 的技术规格:

  • 最大输入长度: 8,191 tokens,使用 cl100k_base 编码 (OpenAI自研分词方案,词表规模约100000)
  • 推荐工作范围: 512-4096 tokens (最佳性能区间)
  • 中文Token化比例: 1字符 ≈ 1.5-2 tokensde

💡 估计有同学会有这样的疑问:"GPT-4o 上下文都128k,怎么这个嵌入模型才8k啊?" 😄 因为 应用场景不同 ,前者用于一次性处理极长对话或文档,如整本书、数小时对话、完整代码库等。后者则被设计用于 将文本切分成向量 ,主要关注 向量质量吞吐量成本。扩大上下文会使计算和显存成本按二次方增长,还会降低每次请求的吞吐量和效率。对于大多数检索、聚类或分类任务,8 k token 已足够覆盖绝大部分文档片段。

然后是 知识库特点 的分析:

  • 内容类型: 心理学、自我成长类长文本
  • 段落结构: 观点+案例+总结的模式
  • 平均段落长度: 200-400字符
  • 语义单元: 一个完整观点通常需要500-800字符

😶 思考结果:

  • chunk_size -块大小,1000 ,x1.5 = 1500 tokens (保守估计),x 2 = 2000 tokens (最大情况),出于嵌入模型的 "甜区" (模型性能最佳区间)。1000字符也正好包含一个完整的"观点+案例",能保证语义的完整性。远低于8191 tokens限制,还能避免截断风险。
  • chunk_overlap -块之间的重叠字数,150 ,150 ÷ 1000 = 15%重叠率,认知科学研究 15-20%重叠率 是维持文本连贯性的最小阈值。信息检索论文时15%的重叠在精确度和效率之间能达到最佳平衡。心理学文本的观点往往有逻辑递进,15%能捕获关键连接词。
  • separators -分割优先级段落级 ("\n\n") → 最大的语义单元,心理学文本的每段通常包含一个核心观点。句子级 ("。", "!", "?") → 语义边界 -句号标志完整思想表达、情感完整 -感叹号、问号保持情感表达的完整性。子句级 (";", ",") → 逻辑连接 -分号连接相关观点、语义缓冲-逗号作为最后的语义边界。

3.3.4. 嵌入模型的调用

😂 一开始就碰到一个 "小坑" ,用的 Chroma.from_documents() 来向量化和存储数据:

($0.15 复制代码
texts = text_splitter.split_documents(documents)
db = Chroma.from_documents(texts, embeddings, persist_directory=DB_PATH)

OpenAIEmbeddings 初始化没自定义 chunk_size 参数,这玩意默认值是 1000 ❗️ ❗️ ❗️

内部实现:

($0.15 复制代码
# OpenAIEmbeddings 
def embed_documents(self, texts: list[str], chunk_size: Optional[int] = None):
    chunk_size_ = chunk_size or self.chunk_size  # 默认1000
    
    # 关键:OpenAI会自动分批处理!
    embeddings = []
    for i in range(0, len(texts), chunk_size_):
        response = self.client.create(
            input=texts[i : i + chunk_size_],  # 分批发送
            **client_kwargs
        )
        embeddings.extend(r["embedding"] for r in response["data"])
    return embeddings

🙃 直接一次性发送了 1000个 分割文档,把 OpenAI 都干到 拒绝连接 了:

看控制台直接一次干了 694535 的 Token...

🤔 想了下,决定每批2个文档块,2 × 2000 tokens ≈ 4000 tokens,仍在512-4096最佳性能区间,最终代码:

($0.15 复制代码
import os
import logging
import uuid
from dotenv import load_dotenv
import chromadb
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_openai import OpenAIEmbeddings

# 加载 .env 文件中的环境变量
load_dotenv()

# 配置日志
logging.basicConfig(
    level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)

# 启用详细日志
logging.getLogger("openai").setLevel(logging.DEBUG)
logging.getLogger("langchain").setLevel(logging.INFO)
logging.getLogger("chromadb").setLevel(logging.INFO)

# 定义常量
DATA_PATH = "data/"
DB_PATH = "vector_store/"
COLLECTION_NAME = "read_notes"


def create_vector_db():
    """
    创建并持久化向量数据库
    """
    logging.info("开始加载文档...")

    # 使用 DirectoryLoader 加载 data 目录下的所有 .md 文件
    loader = DirectoryLoader(
        DATA_PATH,
        glob="*.md",
        # lambda表达式创建一个指定UTF-8编码的TextLoader类
        loader_cls=lambda path: TextLoader(path, encoding="utf-8"),
    )

    documents = loader.load()
    logging.info(f"成功加载 {len(documents)} 篇文档")

    # 文本分割器
    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=1000,
        chunk_overlap=150,
        separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
    )
    texts = text_splitter.split_documents(documents)
    logging.info(f"文档被分割成 {len(texts)} 个文本块")

    logging.info("开始创建并存储向量...")

    # 使用OpenAI嵌入模型
    embeddings = OpenAIEmbeddings(
        openai_api_key=os.getenv("API_KEY"),
        openai_api_base=os.getenv("BASE_URL"),
        model=os.getenv("EMBEDDINGS_MODEL"),
        request_timeout=600,  # 设置超时时间
        max_retries=3,  # 设置重试次数
        chunk_size=2,  # 批处理大小
    )

    # 创建ChromaDB客户端和集合
    client = chromadb.PersistentClient(path=DB_PATH)
    collection = client.get_or_create_collection(name=COLLECTION_NAME)

    logging.info(f"开始处理 {len(texts)} 个文本块(一次性生成嵌入向量)...")

    try:
        # 准备所有文档数据
        all_documents = []
        all_metadatas = []
        all_ids = []

        for i, doc in enumerate(texts):
            # 生成唯一ID
            doc_id = str(uuid.uuid4())

            # 准备文档内容
            all_documents.append(doc.page_content)

            # 准备元数据
            metadata = dict(doc.metadata)
            metadata["chunk_index"] = i
            all_metadatas.append(metadata)

            # 添加ID
            all_ids.append(doc_id)

        # 一次性生成所有嵌入向量(OpenAI内部会自动分批)
        logging.info("🚀 开始生成嵌入向量...")
        all_embeddings = embeddings.embed_documents(all_documents)
        logging.info(f"✅ 成功生成 {len(all_embeddings)} 个嵌入向量")

        # 一次性添加到ChromaDB集合
        logging.info("💾 添加到向量数据库...")
        collection.add(
            documents=all_documents,
            metadatas=all_metadatas,
            ids=all_ids,
            embeddings=all_embeddings,
        )
        logging.info("✅ 成功添加到向量数据库")

    except Exception as e:
        logging.error(f"❌ 处理过程中发生错误: {str(e)}")
        raise

    # 最终统计信息
    count = collection.count()
    logging.info(
        f"向量数据库创建完成 - 集合: {COLLECTION_NAME}, 文档数: {count}, 路径: {DB_PATH}"
    )


if __name__ == "__main__":
    create_vector_db()

1110个文本块,分560个批次处理完,看下价格,总共估计就花了2美分多一点:

可以用 SQLiteStudio (免费) 等工具打开生成 vector_store\chroma.sqlite3,写sql查询记录数:

💁‍♂️ 这一阶段的产出:包含知识库所有文章向量化数据的、可供检索的向量数据库

💡 Tips :如果想自己统计花费的 Token,可以使用 tiktoken (OpenAI官方推荐) 或 transformers 库。

python 复制代码
import tiktoken
# 获取对应模型的编码器
encoding = tiktoken.encoding_for_model("text-embedding-3-small")
# 统计token数
def count_tokens(text):
    return len(encoding.encode(text))

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("openai/clip-vit-base-patch32")
def count_tokens(text):
    return len(tokenizer.encode(text))

3.3.5. 优化:增量索引

🤔 不难看出上述代码存在 "效率问题 ",每次运行都会重新处理所有文档,不管文档是否发生变更,都会重新执行分割和向量化,由此产生一些没必要的API调用费用。😄 这个问题可以用 LangChainIndexes (索引) 组件的 "增量索引 " 来解决:基于 文件哈希 检测文档是否发生变化,只处理新增或修改的文档,自动清理被删除的文档。涉及到两个API:

RecordManager - 记录管理器

跟踪哪些文档已经被索引,记录文档的哈希值和时间戳,检测文档变更 (通过哈希对比),管理 source_id 映射。

python 复制代码
from langchain.indexes import SQLRecordManager

# 创建记录管理器
record_manager = SQLRecordManager(
    namespace="my_index",  # 命名空间,用于隔离不同的索引
    db_url="sqlite:///record_manager_cache.sql"  # 存储文档状态的数据库
)
record_manager.create_schema()  # 创建必要的表结构

index() 函数

python 复制代码
result = index(
    #【文档源】,可以是BaseLoader或文档的可迭代对象,懒加载,只有在需要时才读取文档
    loader,  
    
    #【记录管理器】,踪文档的状态和时间戳 (存储文档的哈希值、最后更新时间、源文件信息、文档是否存在于向量库中)
    record_manager,  
    
    #【向量存储】,实际存储文档向量的地方,提供向量搜索、添加、删除文档能力
    vectorstore,
    
    #【清理模式】,四种:
    #「incremental」增量,实时清理不再存在的文档,只删除与当前批次源文件相关的过期文档,边索引边清理,减少重复内容
    # 需要设置 需要【source_id_key】参数,优点是:最高效,适合频繁更新,
    # 
    #「full」全量,删除所有未被本次加载器返回的文档,清理在索引完成后执行,使用场景:完全重建索引时
    #
    #「scoped_full」范围全量,类似于full,但只清理与本次源文件相关的文档,内存中跟踪源 ID,使用场景:难以确定批大小时
    #
    #「None」无清理,不删除任何文档,只添加和更新,使用场景:纯增量添加
    cleanup="incremental",  # 增量清理模式
    
    # 源文件标识, 标识文档来源,用于增量清理,通常是文档的 metadata["source"] 字段
    source_id_key="source",
   
    # 批处理大小,每次索引的文档数量,越大效率越高,但需要更多内存,推荐2-10
    batch_size=2,  # 批处理大小
    
    # ,用于生成文档的唯一标识,默认是sha1,推荐使用sha256,提高安全性
    # 其它:sha512-最安全但较慢,blake2b-高性能且安全
    key_encoder="sha256",
)

完整实现代码 (注意 Chroma 的导包是有区别的 ❗️):

python 复制代码
import os
import logging
from dotenv import load_dotenv
from langchain.indexes import SQLRecordManager, index
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings

# 加载环境变量
load_dotenv()

# 配置日志
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)

# 定义常量
DATA_PATH = "data/"
DB_PATH = "vector_store/"
COLLECTION_NAME = "read_notes"
NAMESPACE = f"chroma/{COLLECTION_NAME}"

def create_incremental_vector_db():
    """
    使用LangChain indexing API创建增量向量数据库
    """
    logging.info("🚀 开始增量索引...")
    
    # 1. 初始化嵌入模型
    embeddings = OpenAIEmbeddings(
        openai_api_key=os.getenv("API_KEY"),
        openai_api_base=os.getenv("BASE_URL"),
        model=os.getenv("EMBEDDINGS_MODEL"),
        request_timeout=600,
        max_retries=3,
        chunk_size=2
    )
    
    # 2. 初始化向量存储
    vectorstore = Chroma(
        collection_name=COLLECTION_NAME,
        embedding_function=embeddings,
        persist_directory=DB_PATH,
    )
    
    # 3. 初始化记录管理器
    record_manager = SQLRecordManager(
        namespace=NAMESPACE,
        db_url=f"sqlite:///{DB_PATH}/record_manager.db"
    )
    record_manager.create_schema()
    
    # 4. 创建文档加载器
    loader = DirectoryLoader(
        DATA_PATH,
        glob="*.md",
        loader_cls=lambda path: TextLoader(path, encoding="utf-8"),
    )
    
    # 5. 设置文本分割器
    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=1000,
        chunk_overlap=150,
        separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
    )
    
    # 6. 加载并分割文档
    logging.info("📚 加载文档...")
    documents = loader.load()
    logging.info(f"成功加载 {len(documents)} 篇文档")
    
    # 为每个文档设置source字段(用于增量更新)
    for doc in documents:
        # 使用相对路径作为source_id
        source_path = os.path.relpath(doc.metadata['source'], DATA_PATH)
        doc.metadata['source'] = source_path
    
    logging.info("✂️ 分割文档...")
    texts = text_splitter.split_documents(documents)
    logging.info(f"文档被分割成 {len(texts)} 个文本块")
    
    # 7. 执行增量索引 🎯
    logging.info("🔄 开始增量索引...")
    result = index(
        texts,
        record_manager,
        vectorstore,
        cleanup="incremental",
        source_id_key="source",
        batch_size=50,
        force_update=False,
        key_encoder="sha256",    
    )
    
    # 8. 输出结果统计 📊
    logging.info("✅ 索引完成!")
    logging.info(f"📈 统计信息:")
    logging.info(f"  • 新增文档: {result['num_added']}")
    logging.info(f"  • 更新文档: {result['num_updated']}")
    logging.info(f"  • 跳过文档: {result['num_skipped']}")
    logging.info(f"  • 删除文档: {result['num_deleted']}")
    
    # 9. 最终统计
    total_docs = vectorstore._collection.count()
    logging.info(f"🎉 向量数据库总计: {total_docs} 个文档块")
    
    return result

if __name__ == "__main__":
    create_incremental_vector_db()

运行输出结果:

4. 系统设计

🤔 接着,就要设计如何利用准备好的数据来回答问题了,这里无脑 RAG (检索增强生成)。LangChain 中,可以利用 LCEL (LangChain表达式语言 ) 来优雅地构建这个流程,它的美妙之处就在于它的 管道(|) 操作,"将左边的输出,作为右边的输入 ",然后使用 {}嵌套 可以 并行执行,先列出期望的代码执行流程:

python 复制代码
# 代码执行流程:
用户问题 ("如何提高学习效率?")
    ↓
第1步: {"context": retriever | format_docs, "question": RunnablePassthrough()}
    ├─ retriever: 检索相关文档 → List[Document]
    ├─ | format_docs: 格式化文档 → str 
    └─ question: 直接传递问题 → str
    ↓ 输出: {"context": "格式化的文档字符串", "question": "如何提高学习效率?"}
    ↓
第2步: | prompt  
    ↓ 将字典数据填入提示词模板
    ↓ 输出: 完整的提示词字符串
    ↓
第3步: | llm
    ↓ LLM生成回答
    ↓ 输出: AIMessage对象
    ↓
第4步: | StrOutputParser()
    ↓ 提取字符串内容
    ↓ 输出: 最终的回答字符串

接着写下伪代码 "串出一条链":

python 复制代码
retriever = vector_store.as_retriever()
prompt_template = ChatPromptTemplate.from_template(...)
llm = ChatOpenAI()

rag_chain = (
    {
        "context": retriever | format_docs, # 检索相关文档 → 格式化文档
        "question": RunnablePassthrough()	# 将上一步用户输入的问题直接传递到下一个组件
    } | prompt_template |llm| StrOutputParser()
)

😄 流程非常清楚了,直接写出qa_chain.py的代码:

python 复制代码
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough


def format_docs(docs):
    """
    将检索到的文档列表格式化为结构化字符串
    
    参数:
        docs: List[Document] - 检索器返回的文档列表
    
    返回:
        str - 格式化后的文档字符串,包含来源信息
    """
    if not docs:
        return "没有找到相关文档。"
    
    formatted_docs = []
    for i, doc in enumerate(docs, 1):
        source = doc.metadata.get('source', '未知来源')
        # 提取文件名(去掉路径前缀)
        if '\' in source:
            source = source.split('\')[-1]
        elif '/' in source:
            source = source.split('/')[-1]
        # 获取文档内容
        content = doc.page_content.strip()
        doc_section = f"【参考资料 {i}】\n来源:{source}\n内容:{content}"
        formatted_docs.append(doc_section)
    
    return "\n\n".join(formatted_docs)


def create_qa_chain(retriever, llm):
    """
    基于提供的检索器和 LLM 创建一个 RAG 问答链。

    参数:
        retriever: 一个配置好的 LangChain 检索器对象。
        llm: 一个配置好的 LangChain LLM 或 ChatModel 对象。

    返回:
        一个可执行的 LangChain 链 (Runnable)。
    """

    # ===============================
    # 💡 创建提示词模板
    # ===============================
    prompt_template = """
    # 角色
    
    你是一位专业的AI问答助手,严格按照以下规则回答问题。

    # 判断标准
    
    - 如果上下文为空(显示"没有找到相关文档")或上下文内容与问题完全无关,则输出:"根据您提供的文档,我无法回答这个问题。"
    - 如果上下文中包含与问题直接相关的信息,则基于上下文内容回答问题。

    # 输出格式要求
    
    ## 情况一:信息不足
    输出内容:根据您提供的文档,我无法回答这个问题。
    
    ## 情况二:信息充足  
    格式:
    [回答内容]
    
    **参考来源:**
    - [来源1]
    - [来源2]
    (来源需要去重,只列出实际引用的文档)

    # 严格约束
    
    1. 绝对不允许编造任何上下文中没有的信息
    2. 绝对不允许使用自己的知识补充回答
    3. 必须严格按照上述两种格式之一输出
    4. 如果上下文信息模糊或不完整,选择"信息不足"的回答方式

    上下文:
    {context}

    问题:
    {question}
    
    请严格按照上述格式要求回答:"""
    prompt = ChatPromptTemplate.from_template(prompt_template)

    # ===============================
    # 💡 创建问答链
    # ===============================
    qa_chain = (
        {"context": retriever | format_docs, "question": RunnablePassthrough()}
        | prompt
        | llm
        | StrOutputParser()
    )
    return qa_chain

💁‍♂️ 这一阶段的产出:一个定义清晰、逻辑完整的问答(RAG)链

💡 Tips :😶 可能部分读者对这个 LECL 的使用还不太理解,上点代码示例就懂了~

python 复制代码
# 例子1: 简单的文本处理链
text_chain = input_text | text_processor | output_formatter

# 例子2: 复杂的RAG链
rag_chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt_template
    | llm
    | output_parser
)

# 例子3: 多路处理
complex_chain = {
    "main_context": {
        "documents": retriever | format_docs,
        "summary": retriever | summarizer,
        "metadata": retriever | extract_metadata
    },
    "user_input": {
        "question": RunnablePassthrough(),
        "intent": RunnablePassthrough() | intent_classifier,
        "language": RunnablePassthrough() | language_detector
    },
    "system_info": {
        "timestamp": lambda _: datetime.now().isoformat(),
        "version": lambda _: "1.0.0"
    }
}

5. 核心实现

🤔 这步就是将前面的所有 设计(想法) 付诸实践的阶段,😄 因为复杂度比较低,边想边写,代码基本写得差不多了。就差一个接受用户输入,调用RAG链,生成结果输出的 "交互界面 "。先写个最简单的 while循环的命令行界面 做下验证吧:

python 复制代码
import os
from dotenv import load_dotenv
from langchain_community.vectorstores import Chroma
from langchain_openai import ChatOpenAI, OpenAIEmbeddings

from core.qa_chain import create_qa_chain


VECTOR_STORE_PATH = os.path.join(os.path.dirname(__file__), "../vector_store/")
COLLECTION_NAME = "read_notes"
load_dotenv()
API_KEY = os.getenv("API_KEY")  
BASE_URL = os.getenv("BASE_URL")
EMBEDDINGS_MODEL = os.getenv("EMBEDDINGS_MODEL")
LLM_MODEL = os.getenv("LLM_MODEL")


def main():
    if not os.path.exists(VECTOR_STORE_PATH):
        print(f"错误:向量数据库目录 '{VECTOR_STORE_PATH}' 不存在。")
        print("请先运行 'scripts/ingest.py' 来创建数据库。")
        return

    # ① 初始化组件
    embeddings = OpenAIEmbeddings(
        openai_api_key=API_KEY,
        openai_api_base=BASE_URL,
        model=EMBEDDINGS_MODEL,
    )
    vector_store = Chroma(
        persist_directory=VECTOR_STORE_PATH, 
        collection_name=COLLECTION_NAME,
        embedding_function=embeddings,
    )
    llm = ChatOpenAI(
        openai_api_key=API_KEY,
        openai_api_base=BASE_URL,
        model=LLM_MODEL,
        temperature=0,
    )
    retriever = vector_store.as_retriever(
        search_type="similarity",  # 搜索类型:similarity(相似度)或 mmr(最大边际相关性)
        search_kwargs={"k": 10}
    )
    

    # ② 创建RAG问答链
    qa_chain = create_qa_chain(retriever, llm)

    print("\n你好!我是你的个人知识库问答机器人。")
    print("请输入你的问题,或者输入 'exit' 或 'quit' 退出。")

    # ③ 启动交互式问答循环
    while True:
        try:
            question = input("\n> ")
            if question.lower() in ["exit", "quit"]:
                print("感谢使用,再见!")
                break

            if not question.strip():
                continue

            print("\n思考中...")

            # 4. 调用链并打印结果
            answer = qa_chain.invoke(question)
            print("\n--- 回答 ---")
            print(answer)
            print("------------")

        except Exception as e:
            print(f"\n发生错误: {e}")
            break


if __name__ == "__main__":
    main()

运行输出结果:

👏 Nice ,是我们想要的效果,接着用 Streamlit 快速搭建一个简单的 "Web UI",以提供更好的交互体验~

5.1. Streamlit 速成

Streamlit 是一个专门用于 快速构建和部署 数据科学和机器学习应用Web界面的开源Python库,旨在以最简单、最快速的方式将 数据脚本 转化为可共享的 Web 应用。极大地简化了 Web 开发的流程,让您 无需掌握前端技术 (如 HTML, CSS, JavaScript) 就能构建功能丰富的用户界面。

5.1.1. 简单示例

pip install streamlit 装下库,写个简单Demo试试水:

python 复制代码
import streamlit as st
import pandas as pd
import numpy as np

# 页面配置
st.set_page_config(
    page_title="我的第一个Streamlit应用",
    page_icon="🎉",
    layout="wide"
)

# 标题
st.title("🎉 欢迎来到Streamlit世界!")
st.write("这是我的第一个Streamlit应用")

# 创建一些示例数据
data = pd.DataFrame({
    'x': np.random.randn(100),
    'y': np.random.randn(100)
})

# 显示图表
st.line_chart(data)

执行命令运行应用:streamlit run test_streamlit.py,中断会输出一个url,并自动打开浏览器:

效果还不错:

5.1.2. 概念 & 原理

Streamlit 采用 "从上到下" 的执行模式::

  • 重新运行机制:每次用户交互都会重新执行整个脚本。
  • 状态管理 :通过 st.session_state 管理应用状态。
  • 缓存系统 :使用 @st.cache_data@st.cache_resource 优化性能

核心架构

python 复制代码
import streamlit as st

# 1. 页面配置(必须在最前面)
st.set_page_config(...)

# 2. 导入和初始化
# 数据加载、模型初始化等

# 3. 侧边栏(可选)
with st.sidebar:
    # 侧边栏内容

# 4. 主要内容区域
# 标题、文本、图表、交互组件等

# 5. 用户交互处理
# 按钮点击、输入处理等

5.1.3. 基础使用

python 复制代码
import streamlit as st
import pandas as pd
import numpy as np

# ==============================
# 💡 文本和标题
# ==============================
# 各级标题
st.title("主标题")
st.header("二级标题")
st.subheader("三级标题")

# 文本显示
st.text("纯文本")
st.markdown("**Markdown文本**")
st.write("万能显示函数")

# 代码显示
st.code("print('Hello Streamlit')", language='python')

# 数学公式
st.latex(r"\frac{1}{2} \sum_{i=1}^{n} x_i^2")

# ==============================
# 💡 交互组件
# ==============================
# 输入组件
name = st.text_input("请输入您的姓名")
age = st.number_input("请输入您的年龄", min_value=0, max_value=120)
date = st.date_input("选择日期")
time = st.time_input("选择时间")

# 选择组件
option = st.selectbox("选择选项", ["选项1", "选项2", "选项3"])
options = st.multiselect("多选", ["A", "B", "C", "D"])
slider_val = st.slider("滑块", 0, 100, 50)
range_val = st.slider("范围滑块", 0, 100, (20, 80))

# 布尔组件
agree = st.checkbox("我同意条款")
radio = st.radio("选择一个", ["是", "否"])

# 按钮
if st.button("点击我"):
    st.write("按钮被点击了!")

# 文件上传
uploaded_file = st.file_uploader("上传文件", type=['csv', 'txt'])
if uploaded_file:
    st.write(uploaded_file.name)
    
# ==============================
# 💡 布局和容器
# ==============================
# 列布局
col1, col2, col3 = st.columns([1, 2, 1])
with col1:
    st.write("左列")
with col2:
    st.write("中间列")
with col3:
    st.write("右列")

# 扩展器
with st.expander("展开查看详情"):
    st.write("这里是详细内容")

# 容器
container = st.container()
with container:
    st.write("容器内容")

# 空占位符
placeholder = st.empty()
placeholder.text("占位符内容")


# ==============================
# 💡 数据显示
# ==============================
# 创建示例数据
df = pd.DataFrame({
    'name': ['Alice', 'Bob', 'Charlie'],
    'age': [25, 30, 35],
    'score': [85, 90, 78]
})

# 数据表格
st.dataframe(df)  # 交互式表格
st.table(df)      # 静态表格

# JSON数据
st.json({'name': 'Alice', 'age': 25})

# 指标显示
st.metric("温度", "37°C", "1.2°C")

# ==============================
# 💡 图标可视化
# ==============================
import matplotlib.pyplot as plt
import plotly.express as px
import altair as alt

# 原生图表
data = pd.DataFrame(np.random.randn(20, 3), columns=['a', 'b', 'c'])

st.line_chart(data)      # 折线图
st.area_chart(data)      # 面积图
st.bar_chart(data)       # 柱状图

# Matplotlib图表
fig, ax = plt.subplots()
ax.plot(data['a'])
st.pyplot(fig)

# Plotly图表
fig = px.scatter(data, x='a', y='b')
st.plotly_chart(fig)

# Altair图表
chart = alt.Chart(data).mark_circle().encode(x='a', y='b')
st.altair_chart(chart)

5.1.4. 高级功能

python 复制代码
# ==============================
# 💡 状态管理
# ==============================
# 初始化会话状态
if 'counter' not in st.session_state:
    st.session_state.counter = 0

# 使用状态
st.write(f"计数器: {st.session_state.counter}")

# 修改状态
if st.button("增加"):
    st.session_state.counter += 1

# 回调函数中修改状态
def increment():
    st.session_state.counter += 1

st.button("增加(回调)", on_click=increment)

# ==============================
# 💡 缓存系统
# ==============================
# 数据缓存 - 用于缓存数据(DataFrame、字典等)
@st.cache_data
def load_data():
    """加载数据的函数"""
    return pd.read_csv("large_dataset.csv")

# 资源缓存 - 用于缓存全局资源(数据库连接、模型等)
@st.cache_resource
def init_model():
    """初始化机器学习模型"""
    from sklearn.ensemble import RandomForestClassifier
    model = RandomForestClassifier()
    return model

# 使用缓存
data = load_data()
model = init_model()

import time

# ==============================
# 💡 进度条和状态
# ==============================
# 进度条
progress_bar = st.progress(0)
status_text = st.empty()

for i in range(100):
    progress_bar.progress(i + 1)
    status_text.text(f'进度: {i+1}%')
    time.sleep(0.01)

# 状态消息
st.success("成功!")
st.error("错误!")
st.warning("警告!")
st.info("信息提示")

# 气球庆祝
st.balloons()

# ==============================
# 💡 表单处理
# ==============================
# 表单容器
with st.form("my_form"):
    name = st.text_input("姓名")
    age = st.number_input("年龄")
    email = st.text_input("邮箱")
    
    # 表单提交按钮
    submitted = st.form_submit_button("提交")
    
    if submitted:
        st.write(f"姓名: {name}, 年龄: {age}, 邮箱: {email}")

# ==============================
# 💡 多页面应用
# ==============================
# pages/home.py
import streamlit as st

st.title("首页")
st.write("这是首页内容")

# pages/about.py
import streamlit as st

st.title("关于我们")
st.write("这是关于页面")

# main.py
import streamlit as st

# 页面配置
st.set_page_config(page_title="多页面应用")

# 侧边栏导航
pages = {
    "首页": "home",
    "关于": "about"
}

selected_page = st.sidebar.selectbox("选择页面", list(pages.keys()))

# 根据选择显示不同页面
if selected_page == "首页":
    exec(open("pages/home.py").read())
elif selected_page == "关于":
    exec(open("pages/about.py").read())

5.1.5. 简单页面实现

😄 说下核心逻辑:

  • qa_chain() 对象的创建逻辑放到自定义的 load_qa_system() 方法中,使用 @st.cache_resource 装饰,它能确保系统组件只初始化一次!
  • 会话状态初始化:st.session_state 可以用于在页面刷新间保持数据状态。
  • 表单提交后,更新状态:st.session_state.messages.append({"role": "user", "content": question})
  • st.rerun() 触发整个页面重新执行。

直接写出代码:

python 复制代码
import os
import streamlit as st
from dotenv import load_dotenv
from langchain_community.vectorstores import Chroma
from langchain_openai import ChatOpenAI, OpenAIEmbeddings

from core.qa_chain import create_qa_chain


# 配置页面
st.set_page_config(
    page_title="📚 个人知识库问答机器人",
    page_icon="🤖",
    layout="wide"
)

# 常量配置
VECTOR_STORE_PATH = os.path.join(os.path.dirname(__file__), "../vector_store/")
COLLECTION_NAME = "read_notes"


@st.cache_resource
def load_qa_system():
    """
    初始化问答系统组件(使用缓存提高性能)
    """
    load_dotenv()
    API_KEY = os.getenv("API_KEY")  
    BASE_URL = os.getenv("BASE_URL")
    EMBEDDINGS_MODEL = os.getenv("EMBEDDINGS_MODEL")
    LLM_MODEL = os.getenv("LLM_MODEL")
    
    # 检查向量数据库是否存在
    if not os.path.exists(VECTOR_STORE_PATH):
        st.error(f"❌ 向量数据库目录 '{VECTOR_STORE_PATH}' 不存在。")
        st.info("📝 请先运行 'scripts/ingest.py' 来创建数据库。")
        st.stop()
    
    try:
        # 初始化组件
        embeddings = OpenAIEmbeddings(
            openai_api_key=API_KEY,
            openai_api_base=BASE_URL,
            model=EMBEDDINGS_MODEL,
        )
        
        vector_store = Chroma(
            persist_directory=VECTOR_STORE_PATH, 
            collection_name=COLLECTION_NAME,
            embedding_function=embeddings,
        )
        
        llm = ChatOpenAI(
            openai_api_key=API_KEY,
            openai_api_base=BASE_URL,
            model=LLM_MODEL,
            temperature=0,
        )
        
        retriever = vector_store.as_retriever(
            search_type="similarity",
            search_kwargs={"k": 10}
        )
        
        # 创建问答链
        qa_chain = create_qa_chain(retriever, llm)
        
        return qa_chain
        
    except Exception as e:
        st.error(f"❌ 初始化问答系统失败: {str(e)}")
        st.stop()


def main():
    """
    主函数:构建Streamlit Web界面
    """

    # 页面标题和描述
    st.title("📚 个人知识库问答机器人")
    st.markdown("---")
    st.markdown("### 🤖 你好!我是你的个人知识库助手")
    st.markdown("💡 **使用说明**: 在下方输入框中输入你的问题,点击发送按钮获取答案")
    
    # 初始化会话状态
    if "messages" not in st.session_state:
        st.session_state.messages = []
    
    # 初始化问答系统
    with st.spinner("🔄 正在初始化问答系统..."):
        qa_chain = load_qa_system()
    
    st.success("✅ 问答系统已就绪!")
    
    # 显示历史对话
    if st.session_state.messages:
        st.markdown("### 💬 对话历史")
        for i, message in enumerate(st.session_state.messages):
            if message["role"] == "user":
                st.markdown(f"**🙋‍♂️ 你**: {message['content']}")
            else:
                st.markdown(f"**🤖 助手**: {message['content']}")
        st.markdown("---")
    
    # 问题输入区域
    st.markdown("### ❓ 请输入你的问题")
    
    # 创建输入表单
    with st.form(key="question_form", clear_on_submit=True):
        col1, col2 = st.columns([4, 1])
        
        with col1:
            question = st.text_input(
                label="问题",
                placeholder="例如:什么是刻意练习?如何提高自控力?",
                label_visibility="collapsed"
            )
        
        with col2:
            submit_button = st.form_submit_button(
                label="🚀 发送",
                use_container_width=True,
                type="primary"
            )
    
    # 处理用户问题
    if submit_button and question.strip():
        # 添加用户问题到会话状态
        st.session_state.messages.append({"role": "user", "content": question})
        
        # 显示用户问题
        st.markdown(f"**🙋‍♂️ 你**: {question}")
        
        # 显示思考状态
        with st.spinner("🤔 正在思考中..."):
            try:
                # 调用问答链获取答案
                answer = qa_chain.invoke(question)
                
                # 添加助手回答到会话状态
                st.session_state.messages.append({"role": "assistant", "content": answer})
                
                # 显示回答
                st.markdown("**🤖 助手**:")
                st.markdown(answer)
                
            except Exception as e:
                error_message = f"❌ 抱歉,处理问题时出现错误: {str(e)}"
                st.error(error_message)
                st.session_state.messages.append({"role": "assistant", "content": error_message})
        
        # 自动重新运行以更新显示
        st.rerun()
    
    elif submit_button and not question.strip():
        st.warning("⚠️ 请输入有效的问题!")
    
    # 清除对话按钮
    if st.session_state.messages:
        st.markdown("---")
        if st.button("🗑️ 清除对话历史", type="secondary"):
            st.session_state.messages = []
            st.rerun()


if __name__ == "__main__":
    main()

😳 每次输入一堆命令来启动,有些麻烦,直接搞一个启动脚本,还可以做一些命令参数的配置:

python 复制代码
import subprocess
import sys
import os


def main():
    """
    启动Streamlit Web应用
    """
    print("🚀 正在启动个人知识库问答机器人Web界面...")
    print("📝 提示:启动后会自动在浏览器中打开,如未自动打开请访问 http://localhost:8501")
    print("🔄 按 Ctrl+C 停止服务")
    print("-" * 50)
    
    # 检查src/main_web.py是否存在
    web_app_path = os.path.join("src", "main_web.py")
    if not os.path.exists(web_app_path):
        print(f"❌ 错误:找不到文件 {web_app_path}")
        sys.exit(1)
    
    try:
        # 运行streamlit应用
        subprocess.run([
            sys.executable, "-m", "streamlit", "run", 
            web_app_path,
            "--server.address", "localhost",
            "--server.port", "8501",
            "--server.headless", "false",
            "--browser.gatherUsageStats", "false"
        ], check=True)
        
    except subprocess.CalledProcessError as e:
        print(f"❌ 启动失败:{e}")
        sys.exit(1)
    except KeyboardInterrupt:
        print("\n👋 感谢使用,再见!")
        sys.exit(0)


if __name__ == "__main__":
    main()

运行后:

6. 小结

😄 本节我们实现了一个基于 RAG 技术的 "个人知识库问题机器人🤖",听着很高大上,实现起来还挺简单的,只是踩坑费了点时间,核心其实就三步:

  • ① 文档分割,批量调嵌入模型向量化,数据塞向量数据库里头。
  • ② 用户提问,对问题向量化,基于余弦相似度检索对应数据。
  • ③ 写Prompt模板,拼接问题+上面的数据 (作为上下文),丢给随便一个LLM模型就好了。

😶 例子只是抛砖引玉,感兴趣的读者还可以继续折腾,如:

  • 尝试不同的文本分割策略,观察其对检索效果的影响。
  • 替换不同的嵌入模型和向量数据库,理解它们的优劣。
  • 优化检索过程,使用不同的检索算法 (如MMR, Maximal Marginal Relevance)。
  • 使用 LangSmith 等工具对这个机器人进行追踪、可视化和评估。
  • 将你的应用打包成 Docker 镜像,并部署在云服务上,供更多的人使用,等等...

🤷‍♀️ 懒得传Github了,直接压缩传网盘,源码取需:cp-qa-robot.7z

相关推荐
Goboy18 小时前
OpenClaw 卸载教程,一篇讲透
ai编程
饼干哥哥19 小时前
这43个OpenClaw Skill,直接干翻跨境电商
aigc
饼干哥哥20 小时前
把n8n逼死后,Openclaw重构了跨境电商的内容创作流程
aigc
刀法如飞20 小时前
AI时代,程序员都应该是需求描述工程师
程序员·aigc·ai编程·需求文档
小兵张健20 小时前
白嫖党的至暗时期
人工智能·chatgpt·aigc
还好还好不是吗1 天前
使用 trae skills免费codeview 你的最新pr代码
ai编程·trae
孟健1 天前
得物前端部门,没了
ai编程
该用户已不存在1 天前
除了OpenClaw还有谁?五款安全且高效的开源AI智能体
人工智能·aigc·ai编程
量子位1 天前
Meta亚历山大王走人?小扎回应了
meta·aigc