构建专属知识库与 AI 咨询机器人小实验

在这篇博客中,我将分享如何利用你在 WordPress 上发布的分享内容,构建一个专属知识库,并结合本地离线大模型(通过 Ollama 部署)实现检索增强生成(Retrieval Augmented Generation,RAG),打造专属 AI 咨询机器人。下面是整个实验的配置步骤和实现过程。


实验背景

在这个小实验中,我们希望利用已有的 WordPress 内容作为知识库,通过以下流程实现:

  1. 数据采集:从 WordPress 获取文章内容(利用 REST API),并保存为 JSON 文件。
  2. 数据预处理与清洗:对获取的 JSON 数据进行 HTML 清洗、文本分段,并保留图片链接信息(可根据需求选择分离存储或插入占位符)。
  3. 文本嵌入与向量数据库构建:利用 SentenceTransformer 将每个文本段落转换为向量表示,并使用 FAISS 构建向量检索索引。
  4. 检索增强生成(RAG)与回答生成:当用户提出问题时,根据用户问题生成查询向量,通过 FAISS 索引检索最相关的文本段落,再构造一个包含上下文的 prompt,最终调用本地 Ollama 部署的大模型生成回答。

实验步骤详解

步骤 1:数据采集

目标:从 WordPress 中提取文章、页面等内容,并保存为 JSON 格式,作为后续知识库的原始数据。

  • 关键操作
    • 确定需要采集的内容(例如:文章)。
    • 使用 WordPress 内置的 REST API(如 https://yourdomain.com/wp-json/wp/v2/posts)获取数据。
    • 编写脚本采集数据,并将数据保存为 JSON 文件。
import requests
import json

# 设置你的 WordPress 站点地址
wp_site = "https://yourdomain.com"
api_endpoint = f"{wp_site}/wp-json/wp/v2/posts"

# 请求数据(可以设置参数,如 per_page、page 等)
response = requests.get(api_endpoint, params={'per_page': 100})
if response.status_code == 200:
    posts = response.json()
    # 将数据保存到本地文件
    with open("wp_posts.json", "w", encoding="utf-8") as f:
        json.dump(posts, f, ensure_ascii=False, indent=2)
    print("数据采集成功,共采集到", len(posts), "篇文章")
else:
    print("数据采集失败,状态码:", response.status_code)

步骤 2:数据预处理与清洗

目标:对采集到的 JSON 数据进行清洗,包括去除 HTML 标签、分段处理,同时保留图片链接信息。

  • 关键操作
    • 读取 JSON 文件中的内容。
    • 使用 BeautifulSoup 去除 HTML 标签,同时保留图片链接(可以选择将 <img> 标签替换为占位符)。
    • 按照自然段或者空行对文本进行分段。
    • 将处理后的数据(文章 ID、标题、各段落及图片链接)保存为新的 JSON 文件。
import json
from bs4 import BeautifulSoup

def extract_images(html_content):
    """
    提取 HTML 内容中的所有图片链接,返回一个列表
    """
    soup = BeautifulSoup(html_content, "html.parser")
    images = []
    for img in soup.find_all("img"):
        src = img.get("src")
        if src:
            images.append(src)
    return images

def clean_html_keep_images(html_content):
    """
    清洗 HTML 标签,但保留图片链接:
    - 替换 img 标签为占位符 [图片: 图片链接]
    - 同时返回一个图片链接列表
    """
    soup = BeautifulSoup(html_content, "html.parser")
    
    # 提取所有图片链接
    image_links = extract_images(html_content)
    
    # 用占位符替换 img 标签
    for img in soup.find_all("img"):
        src = img.get("src")
        placeholder = f"[图片: {src}]"
        img.replace_with(placeholder)
        
    # 获取清洗后的纯文本
    text = soup.get_text(separator="\n")
    return text, image_links

def split_into_paragraphs(text):
    """
    根据空行切分文本为多个段落
    """
    paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
    return paragraphs

def process_wp_posts(json_file, output_file):
    """
    读取 WordPress API 导出的 JSON 数据,处理每篇文章的内容,
    清洗 HTML,分段,并保留图片链接信息,最终保存到 output_file 中。
    """
    with open(json_file, "r", encoding="utf-8") as f:
        posts = json.load(f)
    
    processed_posts = []
    for post in posts:
        # 提取原始 HTML 内容
        raw_html = post.get("content", {}).get("rendered", "")
        # 清洗 HTML 标签,同时保留图片信息
        clean_text, image_links = clean_html_keep_images(raw_html)
        # 将清洗后的文本按照空行切分成段落
        paragraphs = split_into_paragraphs(clean_text)
        
        processed_posts.append({
            "id": post.get("id"),
            "title": post.get("title", {}).get("rendered", ""),
            "paragraphs": paragraphs,
            "images": image_links  # 单独存储图片链接
        })
    
    with open(output_file, "w", encoding="utf-8") as f:
        json.dump(processed_posts, f, ensure_ascii=False, indent=2)
    print(f"处理完成,结果保存在 {output_file}")

if __name__ == "__main__":
    # 假设你已经有一个保存了 WordPress JSON 数据的文件 wp_posts.json
    process_wp_posts("wp_posts.json", "processed_posts_with_img.json")

步骤 3:文本嵌入与向量数据库构建

目标:将预处理好的文本(段落)转换为向量表示,并构建 FAISS 向量检索索引。

3.1 环境准备

确保已安装以下依赖:

pip install sentence-transformers faiss-cpu numpy

3.2 加载数据

  • 读取预处理后的 JSON 文件,抽取所有文本段落,并构建映射信息(记录每个段落所属的文章信息)。

3.3 生成文本嵌入

  • 使用 SentenceTransformer 模型(如 'all-MiniLM-L6-v2')为每个段落生成向量表示。
  • 这些向量能捕获文本的语义信息,相似语义的文本在向量空间中的距离较近。

3.4 构建 FAISS 向量数据库

  • 将生成的嵌入转换为 numpy 数组,构建基于 L2 距离的 FAISS 索引。
  • 将索引保存为二进制文件,同时保存映射信息到 JSON 文件,便于后续查询时还原原始文本。
import json
import numpy as np
import faiss
from sentence_transformers import SentenceTransformer

def load_processed_data(file_path):
    """
    读取预处理后的 JSON 数据,将所有文章段落抽取成一个列表,同时构建一个映射列表。
    映射列表记录了每个段落所属的文章ID、标题、段落索引和文本内容。
    """
    with open(file_path, "r", encoding="utf-8") as f:
        data = json.load(f)
    
    texts = []      # 存放所有段落文本
    mapping = []    # 存放每个段落对应的元数据
    for post in data:
        post_id = post.get("id")
        title = post.get("title", "")
        paragraphs = post.get("paragraphs", [])
        for idx, para in enumerate(paragraphs):
            texts.append(para)
            mapping.append({
                "post_id": post_id,
                "title": title,
                "paragraph_index": idx,
                "text": para
            })
    return texts, mapping

def generate_embeddings(texts, model_name='all-MiniLM-L6-v2'):
    """
    加载 SentenceTransformer 模型,并为每个文本段落生成向量嵌入。
    返回的 embeddings 是一个列表,每个元素是一个浮点数数组(向量)。
    """
    model = SentenceTransformer(model_name)
    embeddings = model.encode(texts, show_progress_bar=True)
    return embeddings

def build_faiss_index(embeddings):
    """
    利用 FAISS 构建向量数据库(索引)。
    这里我们使用 L2 距离作为度量标准。
    返回构建好的 FAISS 索引。
    """
    # 将嵌入转换为 numpy 数组,并保证数据类型为 float32
    embeddings_np = np.array(embeddings).astype('float32')
    dimension = embeddings_np.shape[1]
    # 创建基于 L2 距离的索引
    index = faiss.IndexFlatL2(dimension)
    index.add(embeddings_np)
    return index

def save_index_and_mapping(index, mapping, index_file="faiss_index.index", mapping_file="mapping.json"):
    """
    将 FAISS 索引保存到磁盘,同时保存映射列表为 JSON 文件。
    """
    faiss.write_index(index, index_file)
    with open(mapping_file, "w", encoding="utf-8") as f:
        json.dump(mapping, f, ensure_ascii=False, indent=2)
    print(f"索引已保存到 {index_file}")
    print(f"映射信息已保存到 {mapping_file}")

if __name__ == "__main__":
    # 假设预处理后的文件名为 processed_posts.json
    processed_file = "processed_posts_with_img.json"
    
    # 1. 加载数据,得到所有文本段落和对应映射信息
    texts, mapping = load_processed_data(processed_file)
    print(f"共加载 {len(texts)} 个文本段落。")
    
    # 2. 生成文本嵌入
    embeddings = generate_embeddings(texts)
    print("文本嵌入生成完毕。")
    
    # 3. 构建 FAISS 索引
    index = build_faiss_index(embeddings)
    print("FAISS 向量数据库构建完成,向量总数:", index.ntotal)
    
    # 4. 保存索引和映射信息
    save_index_and_mapping(index, mapping)

向量概念解释

  • 向量:在机器学习中,向量是一串数字,用于表示文本、图像等数据的特征。
  • 文本向量:通过预训练的模型将文本转换为固定维度的数字数组,这个数组包含了文本的语义信息。
  • 相似度计算:当两个文本的向量距离较近时,表示它们的语义相似。在本实验中,我们利用这一特性从知识库中检索与用户问题最相关的文本段落。

步骤 4:检索增强生成(RAG)与回答生成

目标:结合已构建的向量数据库,当用户提出问题时,先检索最相关的上下文,再通过本地部署的大模型生成回答。

4.1 检索相关上下文

  • 使用与预处理时相同的 SentenceTransformer 模型生成用户问题的向量。
  • 在 FAISS 索引中搜索与该向量最相似的文本段落,并从映射信息中提取详细数据。
  • 将检索到的段落拼接成一个上下文字符串。

4.2 构造 Prompt

  • 将检索到的上下文与用户问题组合构成一个 prompt,用于指导大模型生成回答。

4.3 调用本地大模型生成回答

  • 通过 HTTP POST 请求调用你本地 Ollama 部署的大模型服务,传入构造好的 prompt。
  • 由于 Ollama 采用流式返回,代码中会逐行解析返回的 JSON 数据,并将各部分 response 字段拼接成最终回答。
import json
import numpy as np
import faiss
import requests
from sentence_transformers import SentenceTransformer

# --------------------------
# 1. 加载映射和 FAISS 索引
# --------------------------

def load_mapping(mapping_file="mapping.json"):
    with open(mapping_file, "r", encoding="utf-8") as f:
        mapping = json.load(f)
    return mapping

def load_faiss_index(index_file="faiss_index.index"):
    index = faiss.read_index(index_file)
    return index

# --------------------------
# 2. 加载预训练嵌入模型
# --------------------------

# 确保模型与生成向量时使用的一致
model = SentenceTransformer('all-MiniLM-L6-v2')

# --------------------------
# 3. 定义检索函数:给定查询生成嵌入,从索引中检索相关段落
# --------------------------

def retrieve_relevant_context(query, model, index, mapping, top_k=5):
    # 生成查询的向量嵌入
    query_embedding = model.encode([query])
    query_np = np.array(query_embedding).astype('float32')
    # 在 FAISS 索引中进行搜索
    distances, indices = index.search(query_np, top_k)
    
    # 从映射信息中获取对应的段落信息
    results = []
    for idx in indices[0]:
        if idx < len(mapping):
            results.append(mapping[idx])
    # 将检索到的段落文本合并成上下文
    context = "\n\n".join([item['text'] for item in results])
    return context

# --------------------------
# 4. 构造 Prompt
# --------------------------

def construct_prompt(query, context):
    prompt = (
        f"请根据以下知识库内容回答问题:\n\n"
        f"【知识库】:\n{context}\n\n"
        f"【问题】:{query}\n\n"
        f"【答案】:"
    )
    return prompt

# --------------------------
# 5. 调用本地大模型(Ollama)生成回答
# --------------------------

# 此处示例假设你有一个 Ollama API 服务在本地运行,
# 你需要根据实际情况调整 URL、模型名称和其他参数。

def call_ollama(prompt):
    import json
    import requests

    url = "http://localhost:11434/api/generate"  # 请根据实际情况调整地址
    headers = {"Content-Type": "application/json"}
    payload = {
        "prompt": prompt,
        "model": "qwen:14B",  # 替换为你实际使用的模型名称
        "max_tokens": 3000
    }
    response = requests.post(url, headers=headers, json=payload, stream=True)

    result_text = ""
    # 逐行读取响应
    for line in response.iter_lines():
        if line:
            try:
                data = json.loads(line.decode("utf-8"))
                print("解析到一行数据:", data)  # 调试输出
                if "response" in data:
                    result_text += data["response"]
            except Exception as e:
                print("JSON解析错误,行内容:", line)
                print(e)
    return result_text.strip()

# --------------------------
# 6. 整合整个流程:根据用户查询生成答案
# --------------------------

def generate_answer(query, model, index, mapping, top_k=5):
    # 1. 检索知识库中相关的上下文
    context = retrieve_relevant_context(query, model, index, mapping, top_k)
    # 2. 构造完整的提示词
    prompt = construct_prompt(query, context)
    print("构造的 Prompt:\n", prompt)
    # 3. 调用本地大模型生成回答
    answer = call_ollama(prompt)
    return answer

# --------------------------
# 7. 示例:使用整个流程
# --------------------------

if __name__ == "__main__":
    # 加载映射和 FAISS 索引
    mapping = load_mapping("mapping.json")
    index = load_faiss_index("faiss_index.index")
    
    # 示例用户提问
    query = "Give me one typical error code of DRS(Device Registration Service) issue,and provide me the solution."
    
    # 生成回答
    answer = generate_answer(query, model, index, mapping, top_k=5)
    print("生成的回答:", answer)

流程图示意

下面是一张流程图,展示了整个检索与回答生成的流程:


总结

通过以上步骤,我们实现了一个完整的流程:

  1. 数据采集:利用 WordPress REST API 获取文章数据;
  2. 数据预处理:清洗 HTML、分段处理,并保留图片链接信息;
  3. 文本嵌入:利用 SentenceTransformer 将文本转换为向量,并构建 FAISS 索引;
  4. RAG 与回答生成:检索与用户查询最相关的知识库内容,并利用本地大模型生成回答。

整个系统实现了检索增强生成(RAG)的架构,既能利用已有知识库回答问题,又可以通过大模型生成更加智能的回答。