吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 12598|回复: 405
收起左侧

[Python 原创] [保姆级教程] 0基础在Windows本地搭建“DeepSeek”私人知识库 (附源码)

    [复制链接]
蜗牛很牛 发表于 2025-12-22 16:54

[md]

[保姆级教程] 0基础在Windows本地搭建“DeepSeek”私人知识库 (附源码)

在这个AI爆发的时代,你是否想过把电脑里的几百份PDF、Word文档变成一个可以随时提问的“超级大脑”?而且完全免费、不用联网、数据不出本地

今天手把手教大家利用 Ollama + DeepSeek + Python 搭建一个本地 RAG(检索增强生成)系统。不用怕代码,照着做就行!


🛠️ 第一步:准备环境 (Windows篇)

我们需要两个核心工具:Ollama(运行AI的大脑)和 Python(处理文档的管家)。

1. 安装 Ollama

  • 下载:访问 Ollama官网 下载 Windows 版本并安装。
  • 验证:安装完成后,打开电脑的“命令提示符”(按 Win+R,输入 cmd,回车)。
  • 拉取模型:在黑框里输入以下命令并回车(这一步需要联网下载模型,约 9GB):
ollama pull deepseek-r1:14b

💡 注意:如果不差显存(12G以上)用 14b 效果最好;如果电脑配置一般(8G/16G内存),建议把命令换成 ollama pull deepseek-r1:7b,速度会快很多。

2. 安装 Python

  • 下载:访问 Python官网 下载最新的 Python 3.10 或 3.11 版本。
  • 关键一步:安装界面最下方一定要勾选 "Add Python to PATH" (添加到环境变量),然后点 Install Now

📂 第二步:搭建项目文件夹 (把资料喂给AI)

这一步最关键!我们要把资料整理好,放在项目文件夹里。
DeepSeek 不挑食,你可以把文件直接散乱地堆在根目录,也可以建各种子文件夹分类存放,程序会自动把它们全部扫描出来。

1. 推荐的目录结构

在桌面上新建一个文件夹叫 MyLocalAI,建议参照下面的结构存放文件:

MyLocalAI/  (你的项目主文件夹)
│
├── build_kb.py          <-- (等下要创建的代码文件1,负责吃书)
├── chat_rag.py          <-- (等下要创建的代码文件2,负责聊天)
│
└── 📂 知识库 (你可以随便起名,把所有资料丢这里)
    │
    ├── 📂 2024工作总结/
    │   ├── 年终汇报.pptx
    │   └── 部门会议纪要.docx
    │
    ├── 📂 行业研报/
    │   ├── AI发展趋势.pdf
    │   └── 竞品分析.pdf
    │
    └── 📂 财务数据/
        └── Q4预算表.xlsx

2. 支持的文件类型

你可以随意往里面丢以下格式的文件:

  • PDF (.pdf):各种论文、扫描件(必须是文字版,纯图片PDF读不了)。
  • Word (.docx):工作文档、合同、论文。
  • PPT (.pptx):汇报胶片、课件。
  • Excel (.xlsx):简单的表格数据(程序会转成文本读取)。

💻 第三步:安装依赖库

我们需要安装一些 Python 插件来处理文件。
MyLocalAI 文件夹的空白处,按住 Shift 键 + 点击鼠标右键,选择 “在此处打开 Powershell 窗口” (或者CMD)。

复制下面这行命令粘贴进去,回车:

pip install numpy faiss-cpu pandas pypdf python-docx python-pptx fastembed tqdm ollama openpyxl -i https://pypi.tuna.tsinghua.edu.cn/simple

(这里使用了清华镜像源,下载速度飞快)


📝 第四步:创建代码文件 (直接复制)

我们需要在文件夹里创建两个 Python 文件。
方法:新建一个文本文件 -> 粘贴代码 -> 保存 -> 重命名为 xxx.py

文件 1:build_kb.py (构建知识库)

这个脚本负责扫描刚才那些文件夹,把文档“嚼碎”并存成AI能读懂的向量格式。

import os
import gc
import shutil
import logging
import numpy as np
import faiss
import pandas as pd
from pypdf import PdfReader
from docx import Document
from pptx import Presentation
from fastembed import TextEmbedding
from tqdm import tqdm

# =========================
# ⚙️ 配置区域
# =========================
# 设置国内镜像,解决下载慢的问题
os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
logging.getLogger("pypdf").setLevel(logging.ERROR)

BASE_DIR = "." # 扫描当前目录
INDEX_FILE = "kb.index"
CHUNKS_FILE = "kb_chunks.npy"
BATCH_SIZE = 256

# =========================
# 📄 文档加载器
# =========================
def load_pdf(path):
    try:
        reader = PdfReader(path)
        return "\n".join([page.extract_text().strip() for page in reader.pages if page.extract_text()])
    except: return ""

def load_docx(path):
    try:
        doc = Document(path)
        return "\n".join([p.text.strip() for p in doc.paragraphs if p.text.strip()])
    except: return ""

def load_pptx(path):
    try:
        prs = Presentation(path)
        out = []
        for slide in prs.slides:
            for shape in slide.shapes:
                if hasattr(shape, "text_frame"):
                    for p in shape.text_frame.paragraphs:
                        if p.text.strip(): out.append(p.text.strip())
        return "\n".join(out)
    except: return ""

def load_xlsx(path):
    try:
        return pd.read_excel(path).to_markdown(index=False)
    except: return ""

def split_text(text, size=500, overlap=50):
    res = []
    for i in range(0, len(text), size - overlap):
        chunk = text[i:i + size].strip()
        if len(chunk) > 20: res.append(chunk)
    return res

def scan_files():
    print("📂 正在扫描当前目录及子文件夹下的文档...")
    chunks = []
    supported_ext = ['.pdf', '.docx', '.pptx', '.xlsx']

    files = []
    for root, _, filenames in os.walk(BASE_DIR):
        for f in filenames:
            if any(f.lower().endswith(ext) for ext in supported_ext):
                files.append(os.path.join(root, f))

    print(f"📄 找到 {len(files)} 个文件,开始解析...")

    for file_path in tqdm(files, desc="解析文件"):
        content = ""
        ext = os.path.splitext(file_path)[1].lower()
        if ext == '.pdf': content = load_pdf(file_path)
        elif ext == '.docx': content = load_docx(file_path)
        elif ext == '.pptx': content = load_pptx(file_path)
        elif ext == '.xlsx': content = load_xlsx(file_path)

        if content:
            # 在内容前加上文件名,方便AI知道出处
            file_name = os.path.basename(file_path)
            chunk_list = split_text(content)
            chunks.extend([f"【来源:{file_name}】\n{c}" for c in chunk_list])

    return chunks

# =========================
# 🚀 主程序
# =========================
def main():
    # 1. 扫描文件并制作切片
    # 如果想强制重新扫描,删除目录下的 kb_chunks.npy 即可
    if os.path.exists(CHUNKS_FILE):
        print("✅ 检测到旧缓存,直接加载 (如需更新请删除 kb_chunks.npy)...")
        chunks = np.load(CHUNKS_FILE, allow_pickle=True).tolist()
    else:
        chunks = scan_files()
        if not chunks:
            print("❌ 没有找到有效内容,请检查文件夹里有没有文档!")
            return
        np.save(CHUNKS_FILE, np.array(chunks, dtype=object))

    print(f"📦 共生成 {len(chunks)} 个文本块,准备向量化...")

    # 2. 初始化模型
    model_name = "BAAI/bge-small-zh-v1.5"
    try:
        embedder = TextEmbedding(model_name=model_name)
    except:
        print("⚠️ 模型加载受阻,尝试自动修复...")
        cache_dir = os.path.join(os.getcwd(), ".fastembed")
        if os.path.exists(cache_dir): shutil.rmtree(cache_dir)
        embedder = TextEmbedding(model_name=model_name)

    # 3. 向量化并建立索引
    print("🚀 正在将文本转化为向量 (如果是第一次运行,会自动下载模型,请耐心等待)...")
    embeddings = list(embedder.embed(chunks))

    embeddings_np = np.array([list(e) for e in embeddings]).astype('float32')

    dim = embeddings_np.shape[1]
    index = faiss.IndexFlatIP(dim)
    index.add(embeddings_np)

    faiss.write_index(index, INDEX_FILE)
    print(f"🎉 知识库构建完成!索引文件: {INDEX_FILE}")

if __name__ == "__main__":
    main()

文件 2:chat_rag.py (开始对话)

这个脚本负责连接 Ollama 和刚才建好的知识库。

import os
import faiss
import numpy as np
import ollama
from fastembed import TextEmbedding

# =========================
# ⚙️ 配置区域
# =========================
os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
INDEX_FILE = "kb.index"
CHUNKS_FILE = "kb_chunks.npy"
# ⚠️ 如果你安装的是 7b 模型,请把下面这行改成 "deepseek-r1:7b"
OLLAMA_MODEL = "deepseek-r1:14b" 
EMBED_MODEL = "BAAI/bge-small-zh-v1.5"

def main():
    if not os.path.exists(INDEX_FILE):
        print("❌ 没找到知识库!请先运行 build_kb.py")
        return

    print("⏳ 正在加载大脑...")
    index = faiss.read_index(INDEX_FILE)
    chunks = np.load(CHUNKS_FILE, allow_pickle=True).tolist()
    embedder = TextEmbedding(model_name=EMBED_MODEL)

    print("\n" + "="*40)
    print(f"🤖 本地AI知识库助手 ({OLLAMA_MODEL}) 已启动")
    print("💡 输入 'exit' 退出")
    print("="*40)

    while True:
        query = input("\n🙋 你的问题: ").strip()
        if query.lower() in ['exit', 'quit']: break
        if not query: continue

        # 1. 检索
        print("🔍 正在翻阅资料...", end="\r")
        query_vec = list(embedder.embed([query]))[0]
        D, I = index.search(np.array([query_vec], dtype="float32"), k=4)

        context = []
        for idx in I[0]:
            if idx >= 0: context.append(chunks[idx])

        # 2. 生成
        prompt = f"""
        基于以下资料回答问题。如果资料里没有提到,就说不知道,不要瞎编。

        【资料】:
        {"\n---".join(context)}

        【问题】:{query}
        """

        print("\n🤖 DeepSeek 回答:")
        try:
            stream = ollama.chat(model=OLLAMA_MODEL, messages=[{'role': 'user', 'content': prompt}], stream=True)
            for chunk in stream:
                print(chunk['message']['content'], end='', flush=True)
            print("\n")
        except Exception as e:
            print(f"❌ 也就是这里出错了: {e}")
            print("请检查 Ollama 是否已在后台运行!")

if __name__ == "__main__":
    main()

▶️ 第五步:运行起来!

1. 构建知识库

在命令窗口输入:

python build_kb.py
  • 第一次运行会下载一个约 100MB 的向量模型(已配置国内镜像,很快)。
  • 你会看到进度条在扫描你的文档。
  • 显示 🎉 知识库构建完成! 就成功了。

2. 开始聊天

在命令窗口输入:

python chat_rag.py
  • 程序启动后,输入你的问题,比如:“总结一下Q4预算表的内容”、“2024年工作汇报里提到了什么”。
  • 你会发现 AI 回答的内容完全是基于你文件夹里的文件!

❓ 常见问题 (FAQ)

Q: 运行 build_kb.py 报错说 SSL 连接错误?
A: 这是网络问题。代码里已经加了 HF_ENDPOINT 镜像,如果还不行,尝试关掉你的科学上网工具,或者切换手机热点试试。

Q: 聊天时电脑非常卡,出字很慢?
A: 你的模型可能太大了。

  1. 去 CMD 运行 ollama rm deepseek-r1:14b 删除大模型。
  2. 运行 ollama pull deepseek-r1:1.5b (这个超快,渣机也能跑)。
  3. 记得修改 chat_rag.py 里的配置代码。

Q: 我往文件夹里加了新文档,AI 怎么不知道?
A: 只要删掉文件夹里的 kb_chunks.npykb.index 这两个旧文件,重新运行 python build_kb.py 即可重建索引。


教程结束,祝大家搭建愉快!

[/md]

点评

https://pypi.tuna.tsinghua.edu.cn/simple 这个今天测试了下,网站过期了,我改用阿里云的了  发表于 2025-12-26 17:44

免费评分

参与人数 162威望 +1 吾爱币 +156 热心值 +144 收起 理由
Zercher + 1 谢谢@Thanks!
wumeng + 1 + 1 谢谢@Thanks!
sundaywarm + 1 + 1 用心分享!
zcly12345 + 1 + 1 我很赞同!
cili007 + 1 + 1 谢谢@Thanks!
SuperLee017 + 1 热心回复!
unscinf101 + 1 + 1 我很赞同!
小师叔 + 1 + 1 用心讨论,共获提升!
yeah52 + 1 + 1 谢谢@Thanks!
longxiaoxiao + 1 + 1 热心回复!
jamessteed + 1 + 1 谢谢@Thanks!
kikyo293 + 1 谢谢@Thanks!
qiujunjian1 + 1 + 1 谢谢@Thanks!
ahaneo + 1 + 1 谢谢@Thanks!
苏紫方璇 + 1 + 10 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
waynewange + 1 + 1 谢谢@Thanks!
749 + 1 + 1 我很赞同!
我无聊路过1989 + 1 我很赞同!
tzxinqing + 1 + 1 要是提供个前端网页窗口就更好了,不然只能自己用。
zzzxcv + 1 + 1 热心回复!
jzjcsm + 1 谢谢@Thanks!
唐小样儿 + 1 + 1 我很赞同!
yxpp + 1 谢谢@Thanks!
专业老中医 + 1 + 1 谢谢@Thanks!
demigod.dww + 1 + 1 我很赞同!
抱薪风雪雾 + 1 + 1 谢谢@Thanks!
Issacclark1 + 1 谢谢@Thanks!
li302573596 + 1 + 1 谢谢@Thanks!
aowu121 + 1 + 1 我很赞同!
laputaxu + 1 + 1 我很赞同!
zbaby523 + 1 + 1 谢谢@Thanks!
smallchop + 1 + 1 谢谢@Thanks!
xkonka + 1 + 1 谢谢@Thanks!
那又A怎样 + 1 + 1 谢谢@Thanks!
znyue + 1 + 1 谢谢@Thanks!
爱生活520 + 1 + 1 鼓励转贴优秀软件安全工具和文档!
jk998 + 1 + 1 热心回复!
四哥! + 1 + 1 谢谢@Thanks!
likun1129 + 1 + 1 谢谢@Thanks!
hwq1 + 1 + 1 用心讨论,共获提升!
xincheng + 1 + 1 感谢您的宝贵建议,我们会努力争取做得更好!
快乐的小驹 + 1 + 1 谢谢@Thanks!
jeo009 + 1 + 1 谢谢@Thanks!
sem_0564 + 1 用心讨论,共获提升!
chengdragon + 1 + 1 感谢分享
ssk148150105 + 1 我很赞同!
linfafa2 + 1 + 1 谢谢@Thanks!
snowfox007 + 1 谢谢@Thanks!
zqslc + 1 谢谢@Thanks!
福森108 + 1 + 1 用心讨论,共获提升!
dxlmn + 1 谢谢@Thanks!
dzhenhua0919 + 1 谢谢@Thanks!
easyabc88 + 1 + 1 谢谢@Thanks!
tanghongi + 1 + 1 我很赞同!
wangzhu3366 + 1 我很赞同!
gaotaiqdq + 1 + 1 用心讨论,共获提升!
gleave + 1 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
康康认真的 + 1 + 1 谢谢@Thanks!
diaohui102 + 1 + 1 谢谢@Thanks!
wuming2594 + 1 + 1 谢谢@Thanks!
wanfen11 + 1 我很赞同!
zhixiangwangluo + 1 + 1 谢谢@Thanks!
墨浔丈节 + 1 + 1 谢谢@Thanks!
lvye168 + 1 + 1 我很赞同!
LYH666 + 1 + 1 用心讨论,共获提升!
ClancyHD + 1 我很赞同!
apkexe + 1 + 1 热心回复!
伏热 + 1 + 1 谢谢@Thanks!
whiteky1226 + 1 + 1 谢谢@Thanks!
woaigaoqing + 1 + 1 热心回复!
kaixinguoguo + 1 + 1 谢谢@Thanks!
jikic + 1 + 1 热心回复!
gbm15651075073 + 1 谢谢@Thanks!
寓言hh + 1 谢谢@Thanks!
ssq + 1 + 1 谢谢@Thanks!
funsfy + 1 谢谢@Thanks!
yjn866y + 1 + 1 谢谢@Thanks!
ngfc + 1 我很赞同!
ziyuejun + 1 谢谢@Thanks!
quanq5 + 1 谢谢@Thanks!
joxin + 1 + 1 谢谢@Thanks!
改变世界 + 1 我很赞同!
SanyueJun + 1 + 1 --------
xinzhi + 1 + 1 感谢您的宝贵建议,我们会努力争取做得更好!
moonrabbit + 1 + 1 谢谢@Thanks!
Duke0910 + 1 + 1 谢谢@Thanks!
terryyann + 1 + 1 用心讨论,共获提升!
wzzjnb2006 + 1 + 1 这个一定要试一下。
spss1019 + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
後天 + 1 + 1 用心讨论,共获提升!
2014258722 + 1 + 1 热心回复!
zli9988 + 1 我很赞同!
MayMayHai8971 + 1 + 1 用心讨论,共获提升!
yeek2006 + 1 + 1 我很赞同!
kofcsjx888 + 1 + 1 用心讨论,共获提升!
IcePlume + 1 + 1 我很赞同!
k13 + 1 + 1 谢谢@Thanks!
hdfskycat + 1 + 1 谢谢@Thanks!
pojiecainiao + 1 + 1 谢谢@Thanks!
TaPai + 1 用心讨论,共获提升!

查看全部评分

本帖被以下淘专辑推荐:

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

pyjiujiu 发表于 2025-12-22 23:15
本帖最后由 pyjiujiu 于 2025-12-27 17:27 编辑

谢分享,确实很简单
尝试理解了下,embedder.embed 负责加载模型--> 生成矢量;faiss 负责 计算相似度,储存矢量到索引库;

#12-24 补充
楼主的 chat_rag.py  ,实际最低兼容版本是  python 3.12
3.11或之前版本会报错,因为用了个 高级功能  斜杠加到f-stringreplacement field里面去了

下载模型,是去 hf-mirror.com, 下载的是 /Qdrant-bge-small-zh-v1.5(fastembed 这个库也是 Qdrant下的)

---///---
根据楼主的 建造索引 build_kb.py ,写了个tkitner 的 GUI 版本(不另外开贴,就分享在这)
- 额外支持 epub 和 txt 文档格式(可手动不选),需要 ebooklib 和 bs4两个库
- 支持拖文件夹( 暂时没加 多文件夹功能)
- 带搜索界面,可直接语义搜索(无关AI)

#12-26 补充
- 支持 增量更新 功能 (说明: 还在测试中 不排除有bug;另外多三个配置文件(和楼主的chat_rag.py 完全兼容 );注意点击“增量更新”按钮前 确保文件后缀选对;首次需新建,之后会记住知识库绝对路径)

备注:
(12-24)借助ds(问一次),总花费2个小时多,未来需要优化速度,这种短脚本应该控制在半个小时以内;
其次本来应该是pyqt6,为了适应一般用户的易用性,选择tkinter,不过性能确实存在短板,文字多就卡
(12-26) 文件更新 主要通过 修改时间mtime 和size 的改变,并没有 哈希参与;faiss 的id功能 主要是 faiss.IndexIDMap 进行包裹;
大模型主要还是 ds ,小的问题问 qwen3-coder,文件部分也参考 doubao

---///---

[Python] 纯文本查看 复制代码
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
import tkinterdnd2 as tkdnd
import threading
import queue
import time
import os
import sys
import json
from pathlib import Path
import traceback
 
import pandas as pd
from pypdf import PdfReader
from docx import Document
from pptx import Presentation
 
try:
    import bs4
    from ebooklib import epub
    import ebooklib
except:
    pass
 
# 设置DPI感知(在高DPI屏幕上更清晰)
try:
    from ctypes import windll
    windll.shcore.SetProcessDpiAwareness(1)
except:
    pass
 
import faiss
import numpy as np
from fastembed import TextEmbedding
 
# 设置国内镜像,解决下载慢的问题
os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
MODEL_NAME = "BAAI/bge-small-zh-v1.5"
 
BASE_DIR = "."  # 扫描当前目录
INDEX_FILE = "kb.index"
CHUNKS_FILE = "kb_chunks.npy"
ID_MAP_FILE = "kb_id_map.npy"          # id 储存
FILE_STATE_FILE = "kb_file_state.json"  # 文件状态记录
CHUNK_INFO_FILE = "kb_chunk_info.json"  # chunk详细信息
 
# 也可设置 分割 size 和 重叠
def split_text(text, size=500, overlap=50):
    """分割文本为chunks"""
    res = []
    for i in range(0, len(text), size - overlap):
        chunk = text[i:i + size].strip()
        if len(chunk) > 20: 
            res.append(chunk)
    return res
 
class FaissIncrementalIndex:
    def __init__(self, dimension=128, use_l2=True, use_id_map=True):
        """
        初始化FAISS增量索引
         
        Args:
            dimension: 向量维度
            use_l2: 是否使用L2距离(True为L2,False为内积IP)
            use_id_map: 是否使用ID映射以支持稳定ID
        """
        self.dimension = dimension
        self.use_l2 = use_l2
        self.use_id_map = use_id_map
         
        # 创建基础索引
        if use_l2:
            self.base_index = faiss.IndexFlatL2(dimension)
        else:
            self.base_index = faiss.IndexFlatIP(dimension)
         
        # 包装为ID映射索引以支持稳定ID
        if use_id_map:
            self.index = faiss.IndexIDMap(self.base_index)
        else:
            self.index = self.base_index
             
        # 跟踪已使用的ID(仅在use_id_map=True时有效)
        self.used_ids = set()
        self.next_id = 0
         
    def _generate_ids(self, n_vectors):
        """生成唯一ID"""
        ids = []
        for _ in range(n_vectors):
            while self.next_id in self.used_ids:  #从头 取没有用的 id
                self.next_id += 1
            ids.append(self.next_id)
            self.used_ids.add(self.next_id)
            self.next_id += 1
        return np.array(ids, dtype=np.int64)
     
    def add_vectors(self, vectors, ids=None):
        """
        添加向量到索引
         
        Args:
            vectors: 向量数组,形状为(n, dimension)
            ids: 可选的ID数组,如果为None则自动生成
        """
        vectors = np.asarray(vectors, dtype=np.float32)
         
        if vectors.ndim != 2:
            raise ValueError(f"vectors should be 2D, got {vectors.ndim}D")
             
        if vectors.shape[1] != self.dimension:
            raise ValueError(f"Vector dimension mismatch: expected {self.dimension}, got {vectors.shape[1]}")
         
        if self.use_id_map:
            if ids is None:
                ids = self._generate_ids(len(vectors))
            else:
                ids = np.asarray(ids, dtype=np.int64)
                # 检查ID是否已存在
                for id_val in ids:
                    if id_val in self.used_ids:
                        raise ValueError(f"ID {id_val} already exists in index. Use update_vectors to update.")
                self.used_ids.update(ids)
             
            self.index.add_with_ids(vectors, ids)
            print(f"Added {len(vectors)} vectors with IDs: {ids[:5]}{'...' if len(ids) > 5 else ''}")
        else:
            self.index.add(vectors)
            print(f"Added {len(vectors)} vectors without ID mapping")
     
    def update_vectors(self, vectors, ids):
        """
        更新已存在的向量
         
        Args:
            vectors: 新的向量数组
            ids: 要更新的ID数组
        """
        if not self.use_id_map:
            raise RuntimeError("Cannot update vectors without ID mapping. Initialize with use_id_map=True.")
         
        vectors = np.asarray(vectors, dtype=np.float32)
        ids = np.asarray(ids, dtype=np.int64)
         
        if len(vectors) != len(ids):
            raise ValueError(f"Number of vectors ({len(vectors)}) and IDs ({len(ids)}) must match")
         
        # 检查ID是否存在
        for id_val in ids:
            if id_val not in self.used_ids:
                raise ValueError(f"ID {id_val} not found in index")
         
        # 移除旧向量,添加新向量
        self.index.remove_ids(ids)
        self.index.add_with_ids(vectors, ids)
         
        print(f"Updated {len(vectors)} vectors with IDs: {ids}")
     
    def remove_vectors(self, ids):
        """
        从索引中移除向量
         
        Args:
            ids: 要移除的ID数组
        """
        if not self.use_id_map:
            raise RuntimeError("Cannot remove vectors without ID mapping. Initialize with use_id_map=True.")
         
        ids = np.asarray(ids, dtype=np.int64)
         
        # 从跟踪集合中移除ID
        for id_val in ids:
            self.used_ids.discard(id_val)
         
        # 从索引中移除
        removed_count = self.index.remove_ids(ids)
        print(f"Removed {removed_count} vectors with IDs: {ids}")
     
    def search(self, query_vectors, k=5):
        """
        搜索最近的k个邻居
         
        Args:
            query_vectors: 查询向量
            k: 返回的最近邻数量
         
        Returns:
            distances, indices
        """
        query_vectors = np.asarray(query_vectors, dtype=np.float32)
         
        if query_vectors.ndim == 1:
            query_vectors = query_vectors.reshape(1, -1)
         
        distances, indices = self.index.search(query_vectors, k)
        return distances, indices
     
    def save_index(self, filepath):
        """保存索引到文件"""
        faiss.write_index(self.index, filepath)
        print(f"Index saved to {filepath}")
     
    def load_index(self, filepath):
        """从文件加载索引"""
        self.index = faiss.read_index(filepath)
        if self.use_id_map:
            # 重新构建used_ids集合
            self.used_ids.clear()
            # 注意:FAISS没有直接获取所有ID的API,这里我们需要其他方式
            # 在实际使用中,你可能需要单独存储ID列表
            print(f"Loaded index from {filepath}. Note: used_ids set is empty after loading.")
        else:
            print(f"Loaded index from {filepath}")
 
 
def read_text_file(filename):
    """读取文本文件,尝试多种编码"""
    encodings = ['utf-8', 'gb2312', 'gbk', 'gb18030', 'latin1']
     
    for encoding in encodings:
        try:
            with open(filename, 'r', encoding=encoding) as f:
                return f.read().strip()
        except UnicodeDecodeError:
            continue
     
    # 如果所有编码都失败,使用错误处理
    with open(filename, 'r', encoding='utf-8', errors='ignore') as f:
        return f.read()
 
def load_pdf(path):
    """加载PDF文件"""
    try:
        reader = PdfReader(path)
        return "\n".join([page.extract_text().strip() for page in reader.pages if page.extract_text()])
    except: 
        return ""
 
def load_docx(path):
    """加载DOCX文件"""
    try:
        doc = Document(path)
        return "\n".join([p.text.strip() for p in doc.paragraphs if p.text.strip()])
    except: 
        return ""
 
def load_pptx(path):
    """加载PPTX文件"""
    try:
        prs = Presentation(path)
        out = []
        for slide in prs.slides:
            for shape in slide.shapes:
                if hasattr(shape, "text_frame"):
                    for p in shape.text_frame.paragraphs:
                        if p.text.strip(): 
                            out.append(p.text.strip())
        return "\n".join(out)
    except: 
        return ""
 
def load_xlsx(path):
    """加载XLSX文件"""
    try:
        return pd.read_excel(path).to_markdown(index=False)
    except: 
        return ""
 
def load_epub(path):
    """加载EPUB文件"""
    try:
        book = epub.read_epub(path)
        texts = []
        for item in book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
            soup = bs4.BeautifulSoup(item.get_content(), 'html.parser')
            texts.append(soup.get_text().strip())
        return "\n".join(texts)
    except: 
        return ""
 
def save_chunks_with_ids(chunks, chunk_infos, ids=None,previous_id_slot=None):
    """保存chunks和它们的元数据"""
    # 保存chunks文本
    np.save(CHUNKS_FILE, np.array(chunks, dtype=object))
     
    chunk_infos.append({"left_last_time":previous_id_slot or []})  # 放在最后,保证性能
 
    # 保存chunk元数据
    with open(CHUNK_INFO_FILE, 'w', encoding='utf-8') as f:
        json.dump(chunk_infos, f, ensure_ascii=False, indent=2)
     
    # 如果有ID,保存ID映射 #第一次建立是有的
    if ids is not None:
        np.save(ID_MAP_FILE, np.array(ids, dtype=np.int64))
     
    print(f"\u2705 保存了 {len(chunks)} 个chunks和元数据")
 
def load_chunks_with_ids():
    """加载chunks和它们的元数据"""
    chunks = []
    chunk_infos = []
    ids = None
     
    try:
        if os.path.exists(CHUNKS_FILE):
            chunks = np.load(CHUNKS_FILE, allow_pickle=True).tolist()
         
        if os.path.exists(CHUNK_INFO_FILE):
            with open(CHUNK_INFO_FILE, 'r', encoding='utf-8') as f:
                *chunk_infos,previous_id_slot = json.load(f)
         
        if os.path.exists(ID_MAP_FILE):
            ids = np.load(ID_MAP_FILE, allow_pickle=True)
    except Exception as e:
        print(f"\u26a0\ufe0f 加载chunks数据失败: {e}")
     
    return chunks, chunk_infos, ids,previous_id_slot["left_last_time"]
 
def update_chunks_incrementally(base_dir, selected_exts, existing_chunks, existing_chunk_infos, existing_ids,
                                previous_id_slot:list):
    """
    增量更新chunks
    返回: (新增chunks, 新增chunk_infos, 新增ids, 需要删除的ids)
    """
    # 加载历史文件状态
    if os.path.exists(FILE_STATE_FILE):
        with open(FILE_STATE_FILE, 'r', encoding='utf-8') as f:
            history_state = json.load(f)
    else:
        history_state = {}
     
    # 扫描当前目录状态
    current_state = {}
    current_state[base_dir] = {}
    for root, _, filenames in os.walk(base_dir):
        for f in filenames:
            ext = os.path.splitext(f)[1].lower()
            if ext in selected_exts:
                file_path = os.path.join(root, f)
                # 使用相对路径作为key
                rel_path = os.path.relpath(file_path, base_dir)
                stat = os.stat(file_path)
                current_state[base_dir][rel_path] = {
                    "mtime": stat.st_mtime,
                    "size": stat.st_size
                }
     
    # 检测变化
    history_files = set(history_state[base_dir].keys())
    current_files = set(current_state[base_dir].keys())
     
    added = list(current_files - history_files)
    deleted = list(history_files - current_files)
    modified = []
    for file in history_files & current_files:
        if history_state[base_dir][file] != current_state[base_dir][file]:
            modified.append(file)
     
    print(f"\U0001f50d 检测到变化: 新增{len(added)}个, 修改{len(modified)}个, 删除{len(deleted)}个")
     
    # 收集需要删除的chunk IDs
    ids_to_remove = []
    if existing_chunk_infos:
        # 对于删除的文件,找到对应的所有chunk IDs
        for file_path in deleted:
            file_ids = [info["id"] for info in existing_chunk_infos if info.get("file_path") == file_path]
            ids_to_remove.extend(file_ids)
            print(f"\U0001f5d1\ufe0f  文件 {file_path} 被删除,将移除 {len(file_ids)} 个chunks")
     
    # 对于修改的文件,先删除旧的,再添加新的
    for file_path in modified:
        file_ids = [info["id"] for info in existing_chunk_infos if info.get("file_path") == file_path]
        ids_to_remove.extend(file_ids)
        print(f"\u270f\ufe0f  文件 {file_path} 被修改,将更新 {len(file_ids)} 个chunks")
     
    # 处理新增和修改的文件(修改的文件需要重新处理)
    files_to_process = added + modified
    new_chunks = []
    new_chunk_infos = []
    new_ids = []
     
    # 先填满 ids_to_remove
    if existing_ids is not None and len(existing_ids) > 0:
        next_id = int(existing_ids.max()) + 1 
    else:
        next_id = 0
     
    previous_id_slot = previous_id_slot  
    previous_id_slot.extend(ids_to_remove.copy()) #浅拷贝
    len_of_previous_id_slot = len(previous_id_slot)
     
    for file_rel_path in files_to_process:
        file_path = os.path.join(base_dir, file_rel_path)
        ext = os.path.splitext(file_path)[1].lower()
         
        # 加载文件内容
        content = ""
        if ext == '.pdf':
            content = load_pdf(file_path)
        elif ext == '.docx':
            content = load_docx(file_path)
        elif ext == '.pptx':
            content = load_pptx(file_path)
        elif ext == '.xlsx':
            content = load_xlsx(file_path)
        elif ext == '.epub':
            content = load_epub(file_path)
        elif ext == '.txt':
            content = read_text_file(file_path)
         
        if content:
            file_name = os.path.basename(file_path)
            chunk_list = split_text(content)
             
            for chunk_text in chunk_list:
                if len_of_previous_id_slot >0:
                    chunk_id = previous_id_slot.pop()  
                    len_of_previous_id_slot -= 1
                else:
                    chunk_id = next_id         
                    next_id += 1
                new_ids.append(chunk_id)
                new_chunks.append(f"【来源:{file_name}】\n{chunk_text}")
                new_chunk_infos.append({
                    "id": chunk_id,
                    "file_path": file_rel_path,
                    "file_name": file_name,
                    "chunk_index": len(new_chunks) - 1
                })
                # next_id += 1
     
    # 保存新的文件状态
    with open(FILE_STATE_FILE, 'w', encoding='utf-8') as f:
        json.dump(current_state, f, ensure_ascii=False, indent=2)
     
    return new_chunks, new_chunk_infos, new_ids, ids_to_remove,previous_id_slot 
 
class EmbeddingApp:
    def __init__(self, root):
        self.root = root
        self.root.title("智能知识库构建与搜索系统 (支持增量更新)")
        self.root.geometry("1200x800")
         
        # 设置图标和样式
        self.setup_styles()
         
        # 创建队列用于线程间通信
        self.build_queue = queue.Queue()
        self.search_queue = queue.Queue()
         
        # 设置变量
        self.base_dir = tk.StringVar(value=".")    # 知识库根目录,准备扫描的
        self.progress_var = tk.DoubleVar(value=0)
        self.status_var = tk.StringVar(value="就绪")
        self.search_mode = tk.StringVar(value="语义搜索")
         
        # 支持的扩展名及其变量
        self.extensions = {
            '.pdf': tk.BooleanVar(value=True),
            '.docx': tk.BooleanVar(value=True),
            '.pptx': tk.BooleanVar(value=True),
            '.xlsx': tk.BooleanVar(value=True),
            '.epub': tk.BooleanVar(value=True),
            '.txt': tk.BooleanVar(value=True)
        }
         
        # 初始化组件
        self.setup_ui()
         
        # 检查拖放支持
        self.setup_drag_drop()
         
        # 开始处理队列的循环
        self.process_queue()
         
        # 绑定窗口关闭事件
        self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
 
        self.load_index_embeder()  # 加载embeder和index,chunks
 
        self.update_incremental_button_state() #检测知识库 + 切换增量更新按钮
     
    def setup_styles(self):
        """设置应用样式"""
        style = ttk.Style()
        style.theme_use('clam')
         
        # 自定义颜色
        self.root.configure(bg='#f0f0f0')
         
        # 配置样式
        style.configure('Title.TLabel', font=('微软雅黑', 16, 'bold'), background="#ffffff")
        style.configure('Subtitle.TLabel', font=('微软雅黑', 12), background="#ffffff")
        style.configure('Status.TLabel', font=('微软雅黑', 10), background='#f0f0f0', foreground='#666666')
        style.configure('Accent.TButton', font=('微软雅黑', 10), padding=10)
        style.configure('Frame.TFrame', background='#ffffff', relief=tk.RAISED, borderwidth=1)
         
        # 进度条样式
        style.configure("Custom.Horizontal.TProgressbar",
                       troughcolor='#e0e0e0',
                       background='#4CAF50',
                       lightcolor='#4CAF50',
                       darkcolor='#4CAF50',
                       bordercolor='#e0e0e0',
                       borderwidth=1)
 
    def setup_ui(self):
        """设置用户界面"""
        # 创建主容器
        main_container = tk.PanedWindow(self.root, orient=tk.HORIZONTAL, sashwidth=5, sashrelief=tk.RAISED)
        main_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
         
        # 左侧:构建面板
        build_frame = ttk.Frame(main_container, style='Frame.TFrame')
        main_container.add(build_frame, minsize=400)
         
        # 右侧:搜索面板
        search_frame = ttk.Frame(main_container, style='Frame.TFrame')
        main_container.add(search_frame, minsize=400)
         
        # 构建左侧面板
        self.setup_build_panel(build_frame)
        # 构建右侧面板
        self.setup_search_panel(search_frame)
         
        # 底部状态栏
        self.setup_status_bar()
     
    def setup_build_panel(self, parent):
        """构建知识库面板"""
        # 标题
        title_label = ttk.Label(parent, text="\U0001f527 知识库构建 (支持增量更新)", style='Title.TLabel')
        title_label.pack(pady=(15, 10))
         
        # 目录选择区域
        dir_frame = ttk.Frame(parent)
        dir_frame.pack(fill=tk.X, padx=20, pady=5)
         
        ttk.Label(dir_frame, text="扫描目录:").pack(side=tk.LEFT)
        dir_entry = ttk.Entry(dir_frame, textvariable=self.base_dir, width=40)
        dir_entry.pack(side=tk.LEFT, padx=5, fill=tk.X, expand=True)
         
        browse_btn = ttk.Button(dir_frame, text="浏览...", command=self.browse_directory, width=10)
        browse_btn.pack(side=tk.LEFT)
         
        # 拖放区域
        drop_frame = ttk.Frame(parent, relief=tk.SUNKEN, borderwidth=2)
        drop_frame.pack(fill=tk.X, padx=20, pady=10, ipady=30)
         
        self.drop_label = ttk.Label(drop_frame, text="\U0001f4c1 拖放文件夹到此处", 
                                   font=('微软雅黑', 12), foreground='#666666')
        self.drop_label.pack(expand=True)
         
        # 文件类型选择
        type_frame = ttk.LabelFrame(parent, text="支持的文件类型", padding=10)
        type_frame.pack(fill=tk.X, padx=20, pady=10)
         
        # 创建两列复选框
        col1_frame = ttk.Frame(type_frame)
        col1_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
         
        col2_frame = ttk.Frame(type_frame)
        col2_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
         
        ext_items = list(self.extensions.items())
        mid = len(ext_items) // 2
         
        for i, (ext, var) in enumerate(ext_items[:mid]):
            cb = ttk.Checkbutton(col1_frame, text=ext, variable=var)
            cb.pack(anchor=tk.W, pady=2)
             
        for i, (ext, var) in enumerate(ext_items[mid:]):
            cb = ttk.Checkbutton(col2_frame, text=ext, variable=var)
            if ext in {".epub", ".txt"}: 
                var.set(False)
            cb.pack(anchor=tk.W, pady=2)
         
        # 构建按钮和进度条
        build_frame = ttk.Frame(parent)
        build_frame.pack(fill=tk.X, padx=20, pady=10)
         
        # 按钮容器
        button_container = ttk.Frame(build_frame)
        button_container.pack(fill=tk.X)
         
        # 增量更新按钮
        self.incremental_btn = ttk.Button(
            button_container, 
            text="增量更新", 
            command=self.start_incremental_thread,
            state=tk.DISABLED  # 初始禁用
        )
        self.incremental_btn.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 5))
         
        # 构建按钮
        self.build_btn = ttk.Button(
            button_container, 
            text="开始构建知识库", 
            command=self.start_build_thread, 
            style='Accent.TButton'
        )
        self.build_btn.pack(side=tk.LEFT, fill=tk.X, expand=True)
         
        # 进度条
        self.progress_bar = ttk.Progressbar(
            build_frame, 
            variable=self.progress_var,
            style="Custom.Horizontal.TProgressbar"
        )
        self.progress_bar.pack(fill=tk.X, pady=5)
         
        # 日志区域
        log_label = ttk.Label(parent, text="构建日志:", style='Subtitle.TLabel')
        log_label.pack(anchor=tk.W, padx=20, pady=(10, 5))
         
        self.build_log = scrolledtext.ScrolledText(parent, height=12, font=('Consolas', 9))
        self.build_log.pack(fill=tk.BOTH, expand=True, padx=20, pady=(0, 10))
         
        # 清除日志按钮
        clear_btn = ttk.Button(parent, text="清除日志", command=self.clear_build_log)
        clear_btn.pack(anchor=tk.E, padx=20, pady=(0, 10))
     
    def setup_search_panel(self, parent):
        """构建搜索面板"""
        # 标题
        title_label = ttk.Label(parent, text="\U0001f50d 知识库搜索", style='Title.TLabel')
        title_label.pack(pady=(15, 10))
         
        # 搜索模式选择
        mode_frame = ttk.Frame(parent)
        mode_frame.pack(fill=tk.X, padx=20, pady=5)
         
        ttk.Label(mode_frame, text="搜索模式:").pack(side=tk.LEFT)
         
        semantic_rb = ttk.Radiobutton(mode_frame, text="语义搜索", 
                                     variable=self.search_mode, value="语义搜索")
        semantic_rb.pack(side=tk.LEFT, padx=10)
         
        keyword_rb = ttk.Radiobutton(mode_frame, text="关键词搜索", 
                                    variable=self.search_mode, value="关键词搜索")
        keyword_rb.pack(side=tk.LEFT)
         
        # 搜索输入区域
        search_frame = ttk.Frame(parent)
        search_frame.pack(fill=tk.X, padx=20, pady=10)
         
        self.search_entry = ttk.Entry(search_frame, font=('微软雅黑', 11))
        self.search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 10))
        self.search_entry.bind('<Return>', lambda e: self.start_search_thread())
         
        self.search_btn = ttk.Button(search_frame, text="搜索", 
                                    command=self.start_search_thread, width=10)
        self.search_btn.pack(side=tk.LEFT)
         
        # 搜索结果数量
        result_frame = ttk.Frame(parent)
        result_frame.pack(fill=tk.X, padx=20, pady=(5, 0))
         
        ttk.Label(result_frame, text="显示结果数量:").pack(side=tk.LEFT)
         
        self.result_count = tk.IntVar(value=5)
        result_spin = ttk.Spinbox(result_frame, from_=1, to=20, width=5,
                                 textvariable=self.result_count)
        result_spin.pack(side=tk.LEFT, padx=5)
         
        # 搜索结果区域
        result_label = ttk.Label(parent, text="搜索结果:", style='Subtitle.TLabel')
        result_label.pack(anchor=tk.W, padx=20, pady=(10, 5))
         
        # 使用PanedWindow实现自适应结果区域
        result_paned = tk.PanedWindow(parent, orient=tk.VERTICAL, sashwidth=3)
        result_paned.pack(fill=tk.BOTH, expand=True, padx=20, pady=(0, 10))
         
        # 列表区域
        list_frame = ttk.Frame(result_paned)
        result_paned.add(list_frame, minsize=100)
         
        # 创建Treeview显示结果
        columns = ('score', 'source', 'preview')
        self.result_tree = ttk.Treeview(list_frame, columns=columns, show='tree headings', height=8)
         
        # 设置列
        self.result_tree.heading('#0', text='序号', anchor=tk.W)
        self.result_tree.column('#0', width=50, stretch=False)
         
        self.result_tree.heading('score', text='相关性', anchor=tk.W)
        self.result_tree.column('score', width=80, stretch=False)
         
        self.result_tree.heading('source', text='来源文件', anchor=tk.W)
        self.result_tree.column('source', width=150, stretch=False)
         
        self.result_tree.heading('preview', text='内容预览', anchor=tk.W)
         
        # 添加滚动条
        scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.result_tree.yview)
        self.result_tree.configure(yscrollcommand=scrollbar.set)
         
        self.result_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
         
        # 绑定选择事件
        self.result_tree.bind('<<TreeviewSelect>>', self.on_result_select)
         
        # 详情区域
        detail_frame = ttk.Frame(result_paned)
        result_paned.add(detail_frame, minsize=100)
         
        detail_label = ttk.Label(detail_frame, text="详细内容:", style='Subtitle.TLabel')
        detail_label.pack(anchor=tk.W, pady=(5, 5))
         
        self.detail_text = scrolledtext.ScrolledText(detail_frame, font=('微软雅黑', 10))
        self.detail_text.pack(fill=tk.BOTH, expand=True)
         
        # 复制按钮
        copy_btn = ttk.Button(detail_frame, text="复制内容", command=self.copy_detail)
        copy_btn.pack(anchor=tk.E, pady=(5, 0))
     
    def setup_status_bar(self):
        """设置状态栏"""
        status_bar = ttk.Frame(self.root, relief=tk.SUNKEN, borderwidth=1)
        status_bar.pack(side=tk.BOTTOM, fill=tk.X)
         
        # 状态标签
        status_label = ttk.Label(status_bar, textvariable=self.status_var, 
                                style='Status.TLabel')
        status_label.pack(side=tk.LEFT, padx=10)
         
        # 知识库状态
        self.kb_status = tk.StringVar(value="知识库: 未构建")
        kb_label = ttk.Label(status_bar, textvariable=self.kb_status, 
                            style='Status.TLabel')
        kb_label.pack(side=tk.RIGHT, padx=10)
     
    def setup_drag_drop(self):
        """设置拖放功能"""
        try:
            # 注册拖放
            self.root.drop_target_register(tkdnd.DND_FILES)
            self.root.dnd_bind('<<Drop>>', self.on_drop)
                 
        except Exception as e:
            self.log_build(f"\u26a0\ufe0f 拖放功能初始化失败: {str(e)}")
     
    def on_drop(self, event):
        """处理拖放事件"""
        try:
            # 解析拖放的文件列表
            if hasattr(event, 'data'):
                files = self.root.tk.splitlist(event.data)
            else:
                files = [event.data]
             
            # 只处理第一个文件夹
            if files:
                path = files[0].strip('{}')
                if os.path.isdir(path):
                    self.base_dir.set(path)
                    self.log_build(f"\U0001f4c2 已选择文件夹: {path}")
                    # 更新增量更新按钮状态
                    self.update_incremental_button_state(load_file_by_user=True) #不和drag ,
                else:
                    self.log_build(f"\u26a0\ufe0f 请拖放文件夹而不是文件: {path}")
                     
        except Exception as e:
            self.log_build(f"\u274c 拖放处理错误: {str(e)}")
     
    def browse_directory(self):
        """浏览选择目录"""
        directory = filedialog.askdirectory(title="选择扫描目录", initialdir=self.base_dir.get())
        if directory:
            self.base_dir.set(directory)
            self.log_build(f"\U0001f4c2 已选择文件夹: {directory}")
            # 更新增量更新按钮状态
            self.update_incremental_button_state(load_file_by_user=True)
     
    def update_incremental_button_state(self,load_file_by_user:bool=False):
        """更新增量更新按钮状态"""
        # 检查是否存在索引文件和ID映射文件
        has_index = os.path.exists(INDEX_FILE) and os.path.exists(ID_MAP_FILE)
        has_chunks = os.path.exists(CHUNKS_FILE) and os.path.exists(CHUNK_INFO_FILE)
         
        if has_index and has_chunks:
            if os.path.exists(FILE_STATE_FILE):
                # 加载历史文件状态
                with open(FILE_STATE_FILE, 'r', encoding='utf-8') as f:
                    history_state = json.load(f)
                    history_state_root_dir = list(history_state.keys())[0]
                     
            if load_file_by_user: 
                if self.base_dir.get() != history_state_root_dir:
                    self.incremental_btn.config(state=tk.DISABLED)
                    return
                 
            self.base_dir.set(history_state_root_dir)
            self.incremental_btn.config(state=tk.NORMAL)
            self.log_build("\u2705 检测到已有知识库,增量更新功能已启用")
        else:
            self.incremental_btn.config(state=tk.DISABLED)
            self.log_build("\u26a0\ufe0f 未检测到完整知识库,请先构建知识库")
     
    def start_build_thread(self):
        """启动构建线程"""
        if not self.base_dir.get():
            messagebox.showwarning("警告", "请先选择扫描目录!")
            return
         
        # 检查是否选择了至少一种文件类型
        selected_exts = [ext for ext, var in self.extensions.items() if var.get()]
        if not selected_exts:
            messagebox.showwarning("警告", "请至少选择一种文件类型!")
            return
         
        # 禁用构建按钮和增量更新按钮
        self.build_btn.config(state=tk.DISABLED, text="构建中...")
        self.incremental_btn.config(state=tk.DISABLED)
        self.status_var.set("正在构建知识库...")
         
        # 启动构建线程
        thread = threading.Thread(target=self.build_knowledge_base, 
                                 args=(selected_exts,), daemon=True)
        thread.start()
     
    def start_incremental_thread(self):
        """启动增量更新线程"""
        if not self.base_dir.get():
            messagebox.showwarning("警告", "请先选择扫描目录!")
            return
         
        # 检查是否选择了至少一种文件类型
        selected_exts = [ext for ext, var in self.extensions.items() if var.get()]
        if not selected_exts:
            messagebox.showwarning("警告", "请至少选择一种文件类型!")
            return
         
        # 检查是否存在知识库
        if not (os.path.exists(INDEX_FILE) and os.path.exists(CHUNKS_FILE)):
            messagebox.showwarning("警告", "请先构建知识库!")
            return
         
        # 禁用构建按钮和增量更新按钮
        self.build_btn.config(state=tk.DISABLED)
        self.incremental_btn.config(state=tk.DISABLED, text="增量更新中...")
        self.status_var.set("正在增量更新知识库...")
         
        # 启动增量更新线程
        thread = threading.Thread(target=self.incremental_update_knowledge_base, 
                                 args=(selected_exts,), daemon=True)
        thread.start()
     
    def build_knowledge_base(self, selected_exts):
        """构建知识库(全量构建)"""
        try:
            # 保存当前支持的扩展
            BASE_DIR = self.base_dir.get()
             
            self.build_queue.put(('progress', 10))
            self.build_queue.put(('log', f"\U0001f4c2 开始扫描目录: {BASE_DIR}"))
             
            # 扫描文件并生成chunks
            chunks = []
            chunk_infos = []
            chunk_id = 0
             
            for root, _, filenames in os.walk(BASE_DIR):
                for f in filenames:
                    ext = os.path.splitext(f)[1].lower()
                    if ext in selected_exts:
                        file_path = os.path.join(root, f)
                        rel_path = os.path.relpath(file_path, BASE_DIR)
                         
                        # 加载文件内容
                        content = self.load_file_content(file_path, ext)
                         
                        if content:
                            file_name = os.path.basename(file_path)
                            chunk_list = split_text(content)
                             
                            for chunk_text in chunk_list:
                                chunks.append(f"【来源:{file_name}】\n{chunk_text}")
                                chunk_infos.append({
                                    "id": chunk_id,
                                    "file_path": rel_path,
                                    "file_name": file_name,
                                    "chunk_index": len(chunks) - 1
                                })
                                chunk_id += 1
             
            if not chunks:
                self.build_queue.put(('error', "没有找到有效内容!"))
                return
             
            self.build_queue.put(('progress', 30))
            self.build_queue.put(('log', f"\U0001f4e6 生成 {len(chunks)} 个文本块"))
             
            # 保存文件状态
            current_state = {}
            current_state[self.base_dir.get()] = {}
            for root, _, filenames in os.walk(BASE_DIR):
                for f in filenames:
                    ext = os.path.splitext(f)[1].lower()
                    if ext in selected_exts:
                        file_path = os.path.join(root, f)
                        rel_path = os.path.relpath(file_path, BASE_DIR)
                        stat = os.stat(file_path)
                        current_state[self.base_dir.get()][rel_path] = {
                            "mtime": stat.st_mtime,
                            "size": stat.st_size
                        }
             
            with open(FILE_STATE_FILE, 'w', encoding='utf-8') as f:
                json.dump(current_state, f, ensure_ascii=False, indent=2)
             
            self.build_queue.put(('progress', 40))
            self.build_queue.put(('log', "\U0001f4dd 保存文件状态记录"))
             
            # 初始化模型
            self.build_queue.put(('log', "\U0001f680 正在加载嵌入模型..."))
            model_name = MODEL_NAME
             
            try:
                embedder = TextEmbedding(model_name=model_name)
            except Exception as e:
                self.build_queue.put(('log', f"\u26a0\ufe0f 模型加载失败: {e}"))
                self.build_queue.put(('log', "尝试清理缓存并重新下载..."))
                import shutil
                cache_dir = os.path.join(os.getcwd(), ".fastembed")
                if os.path.exists(cache_dir):
                    shutil.rmtree(cache_dir)
                embedder = TextEmbedding(model_name=model_name)
             
            # 向量化
            self.build_queue.put(('log', "正在将文本转化为向量..."))
            embeddings_generator = embedder.embed(chunks)
            embeddings_np = np.array(list(embeddings_generator)).astype('float32')
             
            # 创建增量索引
            dim = embedder.embedding_size
            index = FaissIncrementalIndex(dimension=dim, use_l2=False, use_id_map=True)
             
            # 生成ID
            ids = np.arange(len(chunks), dtype=np.int64)
             
            # 添加向量到索引
            index.add_vectors(embeddings_np, ids)
             
            # 保存索引
            index.save_index(INDEX_FILE)
             
            # 保存chunks和ID映射
            save_chunks_with_ids(chunks, chunk_infos, ids)
             
            # 重置缓存
            self.index = self.chunks = self.embedder = None
 
            self.build_queue.put(('progress', 100))
            self.build_queue.put(('log', "\u2705 知识库构建完成!"))
            self.build_queue.put(('complete', len(chunks))) #切换 增量更新按钮
             
        except Exception as e:
            self.build_queue.put(('error', f"构建失败: {str(e)}\n{traceback.format_exc()}"))
     
    def incremental_update_knowledge_base(self, selected_exts):
        """增量更新知识库"""
        try:
            BASE_DIR = self.base_dir.get()
 
            self.build_queue.put(('progress', 10))
            self.build_queue.put(('log', "\U0001f504 开始增量更新知识库..."))
             
            # 加载现有的chunks和元数据
            existing_chunks, existing_chunk_infos, existing_ids, previous_id_slot= load_chunks_with_ids()
             
            if not existing_chunks:
                self.build_queue.put(('error', "无法加载现有知识库数据!(知识库数据为空,请重新建立)"))
                return
             
            self.build_queue.put(('progress', 20))
            self.build_queue.put(('log', f"\U0001f4ca 现有知识库: {len(existing_chunks)} 个chunks"))
             
            # 检测变化并获取需要更新的数据
            new_chunks, new_chunk_infos, new_ids, ids_to_remove,previous_id_slot_ = update_chunks_incrementally(
                BASE_DIR, selected_exts, existing_chunks, existing_chunk_infos, existing_ids,previous_id_slot
            )
             
            self.build_queue.put(('progress', 40))
            self.build_queue.put(('log', f"\U0001f4c8 检测到 {len(new_chunks)} 个新增/修改的chunks"))
            self.build_queue.put(('log', f"\U0001f5d1\ufe0f  需要删除 {len(ids_to_remove)} 个chunks"))
             
            if not new_chunks and not ids_to_remove:
                self.build_queue.put(('progress', 100))
                self.build_queue.put(('log', "\u2705 没有检测到变化,知识库已是最新"))
                self.build_queue.put(('complete', len(existing_chunks)))
                return
             
            # 加载模型
            self.build_queue.put(('log', "\U0001f680 正在加载嵌入模型..."))
            embedder = TextEmbedding(model_name=MODEL_NAME)
             
            # 加载增量索引
            if os.path.exists(INDEX_FILE):
                incremental_index = FaissIncrementalIndex(dimension=embedder.embedding_size, use_l2=False, use_id_map=True)
                incremental_index.load_index(INDEX_FILE)
                # 注意:需要重新构建used_ids集合
                # 在实际应用中,需要保存和加载used_ids
            else:
                self.build_queue.put(('error', "索引文件不存在!"))
                return
             
            self.build_queue.put(('progress', 60))
             
            # 处理删除
            if ids_to_remove:
                self.build_queue.put(('log', f"正在删除 {len(ids_to_remove)} 个chunks..."))
                incremental_index.remove_vectors(np.array(ids_to_remove, dtype=np.int64))
                 
                # 从现有数据中删除
                ids_to_remove_set = set(ids_to_remove)
                updated_chunks = [chunk for i, chunk in enumerate(existing_chunks) 
                                 if existing_chunk_infos[i]["id"] not in ids_to_remove_set] 
                updated_chunk_infos = [info for info in existing_chunk_infos 
                                      if info["id"] not in ids_to_remove_set]
                updated_ids = [id_val for id_val in existing_ids if id_val not in ids_to_remove_set]
            else:
                updated_chunks = existing_chunks
                updated_chunk_infos = existing_chunk_infos
                updated_ids = existing_ids
             
            self.build_queue.put(('progress', 70))
             
            # 处理新增
            if new_chunks:
                self.build_queue.put(('log', f"正在处理 {len(new_chunks)} 个新增chunks..."))
                 
                # 向量化新chunks
                embeddings_generator = embedder.embed(new_chunks)
                new_embeddings = np.array(list(embeddings_generator)).astype('float32')
                 
                # 添加到索引
                incremental_index.add_vectors(new_embeddings, new_ids)
                 
                # 更新数据
                updated_chunks.extend(new_chunks) 
                updated_chunk_infos.extend(new_chunk_infos)  
                if updated_ids is not None:
                    updated_ids = np.concatenate([updated_ids, new_ids])
                else:
                    updated_ids = new_ids
             
            self.build_queue.put(('progress', 80))
            self.build_queue.put(('log', "\U0001f4be 保存更新后的数据..."))
             
            # 保存更新后的索引
            incremental_index.save_index(INDEX_FILE)
             
            # 保存更新后的chunks和ID映射
            save_chunks_with_ids(updated_chunks, updated_chunk_infos, updated_ids,previous_id_slot_)
             
            # 重置缓存
            self.index = self.chunks = self.embedder = None
             
            self.build_queue.put(('progress', 100))
            self.build_queue.put(('log', f"\u2705 增量更新完成!知识库现有 {len(updated_chunks)} 个chunks"))
            self.build_queue.put(('complete', len(updated_chunks)))
             
 
             
        except Exception as e:
            self.build_queue.put(('error', f"增量更新失败: {str(e)}\n{traceback.format_exc()}"))
     
    def scan_files_with_filter(self, selected_exts):
        """带过滤的扫描文件函数"""
        chunks = []
         
        for root, _, filenames in os.walk(self.base_dir.get()):
            for f in filenames:
                ext = os.path.splitext(f)[1].lower()
                if ext in selected_exts:
                    file_path = os.path.join(root, f)
                    # 加载文件内容
                    content = self.load_file_content(file_path, ext)
                     
                    if content:
                        file_name = os.path.basename(file_path)
                        chunk_list = split_text(content)
                        chunks.extend([f"【来源:{file_name}】\n{c}" for c in chunk_list])
         
        return chunks
     
    def load_file_content(self, file_path, ext):
        """加载文件内容"""
        try:
            if ext == '.pdf':
                return load_pdf(file_path)
            elif ext == '.docx':
                return load_docx(file_path)
            elif ext == '.pptx':
                return load_pptx(file_path)
            elif ext == '.xlsx':
                return load_xlsx(file_path)
            elif ext == '.epub':
                return load_epub(file_path)
            elif ext == '.txt':
                return read_text_file(file_path)
        except Exception as e:
            self.log_build(f"\u274c 读取文件失败 {file_path}: {str(e)}")
            return ""
     
    def start_search_thread(self):
        """启动搜索线程"""
        query = self.search_entry.get().strip()
        if not query:
            messagebox.showwarning("警告", "请输入搜索内容!")
            return
         
        if not os.path.exists(INDEX_FILE):
            messagebox.showwarning("警告", "请先构建知识库!")
            return
         
        # 禁用搜索按钮
        self.search_btn.config(state=tk.DISABLED, text="搜索中...")
        self.status_var.set("正在搜索...")
         
        # 清空之前的搜索结果
        self.result_tree.delete(*self.result_tree.get_children())
        self.detail_text.delete(1.0, tk.END)
         
        # index是否存在
        if not self.index:
            self.load_index_embeder()
 
        k = self.result_count.get()
 
        # 启动搜索线程
        thread = threading.Thread(target=self.search_knowledge_base, 
                                 args=(query,k), daemon=True)
        thread.start()
 
    def load_index_embeder(self):
        """加载索引、嵌入器和chunks"""
        self.index = self.chunks = self.embedder = None
        try:
            # 加载索引
            if os.path.exists(INDEX_FILE):
                self.index = faiss.read_index(INDEX_FILE)
 
            if os.path.exists(CHUNKS_FILE):
                # 加载chunks和ID映射
                self.chunks, self.chunk_infos, self.ids, _ = load_chunks_with_ids()
                 
                # 建立ID到chunk的映射
                self.id_to_chunk_map = {}
                if self.chunk_infos and len(self.chunks) == len(self.chunk_infos):
                    for info, chunk in zip(self.chunk_infos, self.chunks):
                        self.id_to_chunk_map[info["id"]] = chunk
             
                # 加载模型
                self.embedder = TextEmbedding(model_name=MODEL_NAME)
             
        except Exception as e:
            self.log_build(f"\u26a0\ufe0f 加载索引失败: {e}")
 
    def search_knowledge_base(self, query,top_K:int):
        """搜索知识库(在后台线程中运行)"""
        try:
            # 生成查询向量
            query_vec = list(self.embedder.embed([query]))[0]
             
            # 搜索
            D, I = self.index.search(np.array([query_vec], dtype="float32"), k=top_K)
             
            # 准备结果
            results = []
            for i, (score, idx) in enumerate(zip(D[0], I[0]), 1):
                if idx >= 0:  
                    # 使用ID到chunk的映射(如果有的话)
                    if hasattr(self, 'id_to_chunk_map') and idx in self.id_to_chunk_map:
                        chunk = self.id_to_chunk_map[idx]
                    else:
                        # 如果没有映射,假设ID就是列表索引
                        if idx < len(self.chunks):
                            chunk = self.chunks[idx]
                        else:
                            # ID超出范围,跳过
                            continue
                     
                    # 提取来源和内容
                    lines = chunk.split('\n', 1)
                    source = lines[0].replace('【来源:', '').strip('】') if len(lines) > 0 else "未知"
                    content = lines[1] if len(lines) > 1 else chunk
                     
                    # 缩短预览
                    preview = content[:500]
                     
                    results.append({
                        'index': i,
                        'score': f"{score:.3f}",
                        'source': source,
                        'preview': preview,
                        'full_content': chunk
                    })
             
            self.search_queue.put(('results', results))
             
        except Exception as e:
            self.search_queue.put(('error', f"搜索失败: {str(e)}"))
     
    def on_result_select(self, event):
        """处理结果选择事件"""
        selection = self.result_tree.selection()
        if selection:
            item = self.result_tree.item(selection[0])
            values = item['values']
             
            # 在详情区域显示完整内容
            self.detail_text.delete(1.0, tk.END)
             
            # 查找完整内容
            if values:
                self.detail_text.insert(tk.END, f"来源: {values[1]}\n")
                self.detail_text.insert(tk.END, f"相关性: {values[0]}\n")
                self.detail_text.insert(tk.END, "\n内容:\n")
                self.detail_text.insert(tk.END, values[2])
     
    def copy_detail(self):
        """复制详情内容到剪贴板"""
        content = self.detail_text.get(1.0, tk.END)
        if content.strip():
            self.root.clipboard_clear()
            self.root.clipboard_append(content.strip())
            messagebox.showinfo("成功", "内容已复制到剪贴板!")
     
    def clear_build_log(self):
        """清除构建日志"""
        self.build_log.delete(1.0, tk.END)
     
    def log_build(self, message):
        """记录构建日志(线程安全)"""
        timestamp = time.strftime("%H:%M:%S")
        self.build_log.insert(tk.END, f"[{timestamp}] {message}\n")
        self.build_log.see(tk.END)
        self.root.update_idletasks()
     
    def process_queue(self):
        """处理线程队列"""
        # 处理构建队列
        try:
            while True:
                msg_type, data = self.build_queue.get_nowait()
                 
                if msg_type == 'progress':
                    self.progress_var.set(data)
                elif msg_type == 'log':
                    self.log_build(data)
                elif msg_type == 'complete':
                    self.progress_var.set(100)
                    self.build_btn.config(state=tk.NORMAL, text="开始构建知识库")
                    self.incremental_btn.config(state=tk.NORMAL, text="增量更新")
                    self.status_var.set("构建完成")
                    self.kb_status.set(f"知识库: {data}个文档")
                    messagebox.showinfo("成功", f"知识库构建完成!共处理{data}个文本块。")
                    # 更新增量更新按钮状态
                    self.update_incremental_button_state()
                elif msg_type == 'error':
                    self.build_btn.config(state=tk.NORMAL, text="开始构建知识库")
                    self.incremental_btn.config(state=tk.NORMAL, text="增量更新")
                    self.status_var.set("构建失败")
                    messagebox.showerror("错误", data)
                     
        except queue.Empty:
            pass
         
        # 处理搜索队列
        try:
            while True:
                msg_type, data = self.search_queue.get_nowait()
                 
                if msg_type == 'results':
                    # 显示结果
                    self.result_tree.delete(*self.result_tree.get_children())
                     
                    for result in data:
                        self.result_tree.insert('', tk.END, 
                                              text=str(result['index']),
                                              values=(result['score'], 
                                                     result['source'], 
                                                     result['preview']))
                     
                    self.search_btn.config(state=tk.NORMAL, text="搜索")
                    self.status_var.set(f"找到 {len(data)} 个结果")
                     
                elif msg_type == 'error':
                    self.search_btn.config(state=tk.NORMAL, text="搜索")
                    self.status_var.set("搜索失败")
                    messagebox.showerror("错误", data)
                     
        except queue.Empty:
            pass
         
        # 每100ms检查一次队列
        self.root.after(100, self.process_queue)
     
    def on_closing(self):
        """关闭应用程序"""
        if messagebox.askokcancel("退出", "确定要退出应用程序吗?"):
            self.root.destroy()
            sys.exit()
 
def main():
    """主函数"""
    # 创建主窗口
    root = tkdnd.TkinterDnD.Tk()  # 使用TkinterDnD的Tk
 
    # 创建应用
    app = EmbeddingApp(root)
     
    # 启动主循环
    root.mainloop()
 
if __name__ == "__main__":
    main()



lengbingling 发表于 2025-12-23 14:59
 楼主| 蜗牛很牛 发表于 2025-12-23 09:38
SherlockProel 发表于 2025-12-23 09:05
厉害了老哥,你这个简单啊,我本地都有这些环节,几分钟搞定了,不过我是cpu,选14b的得转几分钟才出结果, ...

要生成向量目录。AI自己去找才有关联性
 楼主| 蜗牛很牛 发表于 2025-12-22 22:06
CC 发表于 2025-12-22 21:34
着实没法了,换了python-3.10.0,python-3.11.0,python-3.14.2。模型也换了几个,还是模型加载受阻

给你发了教程在审核。等通过了看下
chengduld 发表于 2025-12-23 22:53
老大,这个又是怎么回事,请教

PS D:\MyLocalAI> python build_kb.py
&#9989; 检测到旧缓存,直接加载 (如需更新请删除 kb_chunks.npy)...
&#128230; 共生成 7 个文本块,准备向量化...
&#9888;&#65039; 模型加载受阻,尝试自动修复...
Traceback (most recent call last):
  File "D:\MyLocalAI\build_kb.py", line 116, in main
    embedder = TextEmbedding(model_name=model_name)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\fastembed\text\text_embedding.py", line 126, in __init__
    raise ValueError(
ValueError: Model ./my_model is not supported in TextEmbedding. Please check the supported models using `TextEmbedding.list_supported_models()`

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "D:\MyLocalAI\build_kb.py", line 137, in <module>
    main()
  File "D:\MyLocalAI\build_kb.py", line 121, in main
    embedder = TextEmbedding(model_name=model_name)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\fastembed\text\text_embedding.py", line 126, in __init__
    raise ValueError(
ValueError: Model ./my_model is not supported in TextEmbedding. Please check the supported models using `TextEmbedding.list_supported_models()`
 楼主| 蜗牛很牛 发表于 2025-12-22 16:56

请看图

本帖最后由 蜗牛很牛 于 2025-12-22 16:58 编辑

详情
看图
AI.png
ScreenShot_2025-12-22_094720_055.png
li000yu 发表于 2025-12-22 18:42
完全安装好后离线是否可用?还是需要联网使用?
 楼主| 蜗牛很牛 发表于 2025-12-22 19:14
li000yu 发表于 2025-12-22 18:42
完全安装好后离线是否可用?还是需要联网使用?

纯离线
本地部署完毕后无需联网
AiGuoZhe66 发表于 2025-12-22 19:32
这个非常不错,这种量级的AI模型需要什么配置啊
 楼主| 蜗牛很牛 发表于 2025-12-22 19:35
AiGuoZhe66 发表于 2025-12-22 19:32
这个非常不错,这种量级的AI模型需要什么配置啊

你百度下我的电脑核显具体配置给不了你答案
Luca4 发表于 2025-12-22 19:37
牛啊牛啊
 楼主| 蜗牛很牛 发表于 2025-12-22 19:41

喜欢的自己配一个
skzhaixing 发表于 2025-12-22 19:41
都是AI就不多走一步弄个前端页面看着舒服
 楼主| 蜗牛很牛 发表于 2025-12-22 19:54
skzhaixing 发表于 2025-12-22 19:41
都是AI就不多走一步弄个前端页面看着舒服

我喜欢极简,你可以自己加
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-1-7 15:42

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表