音频数据相加(常称为混音,即 Mixing)是指将两个或多个音频信号在同一时间点上的采样值进行数学叠加。此操作在数字音频处理、游戏引擎和语音识别 中非常常见。
数字音频本质上是一串连续的波形采样点数据(如 PCM 数据)。将两个音频相加,即对齐它们的时间轴,并将对应位置的采样值(Amplitude)相加: $S_{mix}(t) = S_1(t) + S_2(t)$
直接相加往往会导致数据溢出(削波失真),因此需要根据场景选择合适的算法:
在进行音频数据相加前,务必保证参与计算的音频具有相同的以下参数:
在 DSP 中,音频相加(加法器 $\bigoplus$)是构建复杂音频效果的最核心基础算子:
A * B + C。__QADD),会自动让 $32767 + 1 = 32767$,防止数值翻转。当我们在嵌入式 DSP 芯片(如 TI 的 C6000 系列、ARM Cortex-M 系列的 CMSIS-DSP)或底层 C 语言中做音频相加时,必须考虑硬件特性:
DSP 芯片之所以处理音频快,是因为它有专门的 MAC (Multiply-Accumulate) 硬件单元。它可以在一个时钟周期内完成 A * B + C 的操作。因此,在 DSP 算法中,音频相加往往是和乘法(音量控制/系数)绑定在一起执行的。
DSP 优化手法: 使用饱和加法(Saturated Add)指令。普通 CPU 中 $32767 + 1 = -32768$(符号位翻转导致巨恶劣爆音);而 DSP 芯片通常有硬件支持的饱和指令(如 ARM 的 __QADD),会自动让 $32767 + 1 = 32767$,将失真降到最低。
现代 CPU 和 DSP 支持 SIMD(如 ARM 的 NEON 指令集,x86 的 SSE/AVX)。 传统的加法是一个一个点相加;使用 SIMD 可以在一个指令周期内,同时让 4 个或 8 个采样点进行相加,这让实时处理多路高采样率(如 $96\text{kHz} / 192\text{kHz}$)的音频混音变得极其轻松。
在处理实时流(Buffer)数据时,音频相加(混音)的最大挑战在于超低延迟要求以及防止突发性爆音(Clip)。
实时流通常以固定大小的缓冲区(Buffer / Block)为单位进行传输(如 256、512 或 1024 个采样点)。
在实时系统中,不建议使用简单的“平均法”( (A+B)/2 ),因为这会导致每增加一路音频,整体音量就明显塌陷。推荐使用以下两种方式:
将所有输入 Buffer 转换成 [-1.0, 1.0] 的 float 数组,直接相加。在输出前通过 软限幅(Soft-Limiter / AGC) 公式,既能保留音量,又能优雅地处理溢出,避免硬截断带来的刺耳咔哒声(Click)。 常用的实时软限幅公式(正切畸变): $f(x) = \tanh(x)$
或者更轻量、计算更快的公式: $f(x) = \begin{cases} x & \vert{}x\vert{} \le 0.8 \cr 0.8 + 0.2 \times \tanh(\frac{\vert{}x\vert{}-0.8}{0.2}) & \vert{}x\vert{} > 0.8 \end{cases}$
如果运行在嵌入式或对性能要求极高的底层系统(如 C/C++),可以使用硬件支持的 饱和加法(Saturated Add) 。当相加结果超过 +32767 时自动等于 32767,超过 -32768 时自动等于 -32768。
如果您的实时流是 PyAudio、WebRTC 或网络 Socket 传输的 bytes 缓冲,可以用以下高效的向量化代码进行混音:
import numpy as np
def mix_live_buffers(buffer_a: bytes, buffer_b: bytes) -> bytes:
"""
对两个 16-bit PCM 实时 Buffer 进行混合
"""
# 1. 将原始字节流高效转换为 Float32 数组 (范围 -1.0 到 1.0)
data_a = np.frombuffer(buffer_a, dtype=np.int16).astype(np.float32) / 32768.0
data_b = np.frombuffer(buffer_b, dtype=np.int16).astype(np.float32) / 32768.0
# 2. 线性相加 (可在此处乘以权重,如 data_a * 0.8 + data_b * 0.5)
mixed_float = data_a + data_b
# 3. 实时软限幅 (Soft-Limiting):防止硬截断爆音
# 使用 np.tanh 可以平滑地将超出 [-1, 1] 的部分压缩回安全范围
mixed_clipped = np.tanh(mixed_float)
# 4. 还原为 16-bit PCM 字节流
mixed_pcm = (mixed_clipped * 32767.0).astype(np.int16)
return mixed_pcm.tobytes()
在 C 语言中处理网络传输的音频实时流(Buffer)混音,最核心的原则是高性能、零拷贝(或少拷贝)以及异步容错。由于网络流存在延迟和丢包,你不能直接把刚收到的网络包进行相加。 以下是标准的 C 语言工业级实时网络混音方案:
网络实时混音必须使用 Jitter Buffer(抖动缓冲区) 架构。不能让音频播放线程去等待网络接收线程。
graph LR
%% 第一行逻辑
Input(网络数据包 UDP/RTP) --> ThreadA[线程 A: 网络接收]
ThreadA --> Write[写入对应的环形队列 Ring Buffer A/B]
%% 第二行逻辑
Timer([播放设备/定时器回调]) --> ThreadB[线程 B: 混音线程]
Read{定时读取各队列并混音} <--> ThreadB
ThreadB --> Output(输出音频)
%% 虚线表示数据存储/交换关系
Write -.-> Read
这里提供一个基于 Int16 PCM 格式、使用 饱和加法(Saturated Add) 的高效混音实现。饱和加法直接利用 CPU 边界判断,是 C 语言实时音频处理中最快速、最省算力的防爆音方法。
#include <stdio.h>
#include <stdint.h>
#include <string.h>
// 饱和加法:防止 16位有符号整数溢出爆音
inline int16_t saturate_add(int32_t sample1, int32_t sample2) {
int32_t mixed = sample1 + sample2;
if (mixed > 32767) return 32767;
if (mixed < -32768) return -32768;
return (int16_t)mixed;
}
/**
* 实时网络流混音函数
* @param buffer_a 网络流A的缓冲区
* @param buffer_b 网络流B的缓冲区
* @param out_buffer 混音后的输出缓冲区
* @param samples 本次 Buffer 的采样点数量(注意:不是字节数!字节数 = samples * 2)
* @param weight_a 流A的音量权重 (0.0 到 1.0)
* @param weight_b 流B的音量权重 (0.0 到 1.0)
*/
void mix_network_buffers(const int16_t* buffer_a, const int16_t* buffer_b,
int16_t* out_buffer, size_t samples,
float weight_a, float weight_b)
{
// 如果某一路网络流断流(Buffer为空),直接处理另一路
if (buffer_a == NULL && buffer_b == NULL) {
memset(out_buffer, 0, samples * sizeof(int16_t));
return;
}
for (size_t i = 0; i < samples; i++) {
// 1. 获取采样点,若断流则补静音(0)
int32_t s1 = buffer_a ? (int32_t)(buffer_a[i] * weight_a) : 0;
int32_t s2 = buffer_b ? (int32_t)(buffer_b[i] * weight_b) : 0;
// 2. 混合并进行饱和截断
out_buffer[i] = saturate_add(s1, s2);
}
}
如果是在生产环境开发,不建议全部从零手写,可以集成以下成熟的轻量级 C 库:
在 C 语言中支持多路(大于2路)网络音频混音,最优雅且高效的架构是 “基线累加 + 单次饱和截断” 。这意味着我们要把所有声道的采样点先加到一个高精度的变量(如 int32_t 或 float)中,最后统一做一次饱和处理,而不是两两两两相加,这样可以最大程度减少性能开销并降低失真。
以下是专为多路网络流设计的 C 语言解决方案。
为了灵活应对 3 路、4 路甚至数十路音频,需要用结构体来管理每一路网络音频的状态(包含其缓冲区指针、当前音量权重、是否有效等)。
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <stdlib.h>
// 单路音频流的状态typedef struct {
const int16_t* buffer; // 指向当前需要混音的 Buffer(从该流的 RingBuffer 中取出)
float volume; // 该路音频的音量权重 (0.0 ~ 1.0)
int is_active; // 该路当前是否有数据(0 = 断流/静音,1 = 有数据)
} AudioStreamInput;
该实现采用 int32_t 累加器。如果通道数极多(例如超过 100 路),为了彻底防止累加阶段就溢出,可以将累加器改为 float,这里以常规的多路网络会议/语音场景(int32_t)为例:
// 高效饱和截断函数
inline int16_t saturate_add_multi(int32_t sum) {
if (sum > 32767) return 32767;
if (sum < -32768) return -32768;
return (int16_t)sum;
}
/**
* 多路实时网络流混音
* @param streams 输入流数组
* @param stream_count 输入流的总路数
* @param out_buffer 混音后的输出缓冲区
* @param samples 本次 Buffer 的采样点数量
*/
void mix_multi_network_buffers(const AudioStreamInput* streams, size_t stream_count,
int16_t* out_buffer, size_t samples)
{
// 1. 遍历每一个采样点
for (size_t i = 0; i < samples; i++) {
int32_t accumulator = 0;
int active_count = 0;
// 2. 累加所有有效流在当前位置的采样点
for (size_t j = 0; j < stream_count; j++) {
if (streams[j].is_active && streams[j].buffer != NULL) {
// 应用音量权重并累加
accumulator += (int32_t)(streams[j].buffer[i] * streams[j].volume);
active_count++;
}
}
// 3. 动态增益控制 (可选优化)
// 如果同时说话的人太多,声音会整体过载失真。
// 可以根据当前激发的流数量,进行轻微的衰减(例如下面这行代码,根据需要取消注释):
// if (active_count > 2) { accumulator = (int32_t)(accumulator * 0.8f); }
// 4. 统一进行一次饱和截断并输出
out_buffer[i] = saturate_add_multi(accumulator);
}
}
在实际的网络音频服务器或客户端中,你的混音线程通常会这样调用它:
#define MAX_STREAMS 8
#define BUFFER_SAMPLES 480 // 假设 48kHz, 10ms 的数据
void audio_mixer_thread_loop() {
AudioStreamInput inputs[MAX_STREAMS];
int16_t output_buffer[BUFFER_SAMPLES];
while (running) {
// 1. 等待定时器(例如每 10ms 触发一次)
wait_for_10ms_timer();
// 2. 从各自的 Jitter Buffer 中提取数据
for (int i = 0; i < MAX_STREAMS; i++) {
// 从第 i 路用户的环形缓冲区读取 10ms 数据
// 如果提取失败(丢包/断流),ring_buffer_pop 应返回 NULL
inputs[i].buffer = ring_buffer_pop(user_ring_buffers[i], BUFFER_SAMPLES);
inputs[i].volume = user_volumes[i]; // 用户设定的音量
inputs[i].is_active = (inputs[i].buffer != NULL) ? 1 : 0;
}
// 3. 执行多路混音
mix_multi_network_buffers(inputs, MAX_STREAMS, output_buffer, BUFFER_SAMPLES);
// 4. 将混音后的数据推送到声卡播放,或者编码发给其他网络用户
send_to_speaker_or_network(output_buffer, BUFFER_SAMPLES);
}
}
is_active 设为 0(视为静音),不参与混音。for 循环可以通过编译优化加速。在 GCC / Clang 中编译时,请务必开启 -O3 优化开关,编译器会自动将上述代码转化为 SIMD(单指令多数据) 指令(如 Intel 的 AVX 或 ARM 的 NEON),实现 4 个或 8 个采样点同时相加,效率提升数倍。posix_memalign 或 _aligned_malloc),这能让 CPU 在执行多路数据指针读取时达到最高速度。gitgraph

enable gitgraph

fetch:

create branch:

checkout:

commit or checkout:

push:


pop stash:

stash:

commit:

push to remote:

push branch:

在 Ubuntu 22.04 上搭建 TFTP 服务器和 NFS 服务器是嵌入式开发(如 Linux 内核调试、根文件系统挂载)的经典组合。以下是详细的搭建步骤:
TFTP(简单文件传输协议)常用于在开发板启动时下载 Linux 内核镜像(uImage/zImage)和设备树文件(.dtb)。
打开终端,执行以下命令安装 TFTP 服务端和客户端:
Bash
sudo apt update
sudo apt install tftpd-hpa tftp-hpa
创建一个用于存放传输文件的目录,并赋予最高权限,确保开发板可以正常读写:
Bash
sudo mkdir -p /home/tftpboot
sudo chmod 777 /home/tftpboot
编辑 TFTP 的配置文件 /etc/default/tftpd-hpa:
Bash
sudo nano /etc/default/tftpd-hpa
将内容修改为以下配置(主要修改 TFTP_DIRECTORY 和 TFTP_OPTIONS):
Plaintext
TFTP_USERNAME="tftp"
TFTP_DIRECTORY="/home/tftpboot"
TFTP_ADDRESS=":69"
TFTP_OPTIONS="--secure --create"
提示:
--create参数允许客户端在服务器上创建新文件(即支持上传)。
保存退出后,重启 TFTP 服务并将其设置为开机自启:
Bash
sudo systemctl restart tftpd-hpa
sudo systemctl enable tftpd-hpa
检查服务状态,确保显示 active (running):
Bash
sudo systemctl status tftpd-hpa
你可以通过本地回环测试来验证是否搭建成功:
Bash
echo "TFTP Test File" > /home/tftpboot/test.txt
cd /tmp
tftp 127.0.0.1
# 进入 tftp 命令行后输入:
get test.txt
quit
# 查看 /tmp 目录下是否成功获取文件
cat test.txt
NFS(网络文件系统)常用于将 Ubuntu 上的某个目录(如根文件系统 rootfs)挂载到开发板上,方便直接修改和调试代码而无需反复烧录。
Bash
sudo apt install nfs-kernel-server
创建一个用于挂载的文件目录:
Bash
sudo mkdir -p /home/nfsroot
sudo chmod 777 /home/nfsroot
编辑 /etc/exports 文件来指定允许访问的客户端和权限:
Bash
sudo nano /etc/exports
在文件末尾添加以下内容(根据你的网络环境调整):
Plaintext
/home/nfsroot *(rw,sync,no_root_squash,no_subtree_check)
参数说明:
*:代表允许所有的 IP 访问(在局域网开发环境中很方便。如果为了安全,可以写具体网段如 119.168.1.0/24)。
rw:读写权限。
sync:资料同步写入内存与硬盘。
no_root_squash:如果客户端使用的是 root 用户,则对该共享目录也拥有 root 权限(嵌入式开发必备)。
no_subtree_check:不检查子目录权限,提高性能。
避坑指南:Ubuntu 22.04 默认关闭了 NFS v2 和 v3,只开启了 NFS v4。但是很多老旧的开发板 U-Boot 或内核只支持 NFS v2 或 v3。如果你在挂载时遇到
Protocol not supported或一直连不上,请按以下步骤开启 v3 支持。
编辑 /etc/default/nfs-kernel-server:
Bash
sudo nano /etc/default/nfs-kernel-server
修改或添加以下内容,确保不禁用 v3:
Plaintext
RPCNFSDOPTS=""
编辑 /etc/nfs.conf:
Bash
sudo nano /etc/nfs.conf
找到 [nfsd] 标签,取消注释并修改 vers3 如下:
Plaintext
[nfsd]
vers3=y
配置完成后,重启相关服务并导出共享目录:
Bash
sudo systemctl restart rpcbind
sudo systemctl restart nfs-kernel-server
sudo systemctl enable nfs-kernel-server
使用以下命令查看当前生效的 NFS 共享目录:
Bash
showmount -e
如果输出显示 /home/nfsroot *,说明配置成功。
你可以尝试在本地挂载自己,验证服务是否正常:
Bash
sudo mount -t nfs -o nfsvers=3 127.0.0.1:/home/nfsroot /mnt
# 检查是否成功挂载,随后卸载
ls /mnt
sudo umount /mnt
防火墙问题:如果开发板依然无法连接,建议直接关闭 Ubuntu 的防火墙(仅限本地安全开发环境):
Bash
sudo ufw disable
IP 地址在同一网段:请确保你的 Ubuntu 主机、Windows 宿主机(如果是虚拟机的话)以及开发板的 IP 地址处于同一个网段,且能够互相 ping 通。
情况一:在终端中直接设置(生效)
export PATH=$PATH:/new/path
这是在当前 shell(父进程)中直接修改 PATH 后续所有命令都继承这个新 PATH
情况二:在脚本中设置(不生效)
# setpath.sh
export PATH=$PATH:/new/path
echo $PATH # 脚本内能看到新 PATH
./setpath.sh # 或 bash setpath.sh
echo $PATH # 终端中 PATH 未改变!
为什么?
当你运行 ./setpath.sh 时,系统会启动一个子 shell 来执行脚本 脚本中的 export 只修改了子 shell 的环境变量 脚本执行完毕后,子 shell 退出,修改不会回传给父 shell(你的终端)
📌 Unix/Linux 的设计原则:子进程不能修改父进程的环境变量(安全机制)
✅ 正确解决方案
source setpath.sh#
或简写
. setpath.sh
原理:
如果希望每次打开终端都生效,将 PATH 添加到配置文件:
对当前用户:
echo 'export PATH="$PATH:/opt/myapp/bin"' >> ~/.bashrc
# 或 ~/.zshrc(如果你用 zsh)
然后重载配置:
source ~/.bashrc
对所有用户(需 root 权限):
sudo echo 'export PATH="$PATH:/opt/myapp/bin"' >> /etc/environment
在“仅用于自己查询、不上传任何信息到云端”的前提下,构建本地知识库的核心目标是:完全离线、数据安全、高效检索。下面是一个系统化的构建方案,涵盖工具选择、数据处理、检索方式等关键环节:
[本地文档/数据源]
↓
[文档解析 & 向量化] → [本地向量数据库]
↓
[本地大模型(LLM)] ←→ [本地检索系统]
↓
[用户界面 / 查询入口]
所有组件均运行在你的个人电脑或局域网服务器上,无需联网。
Unstructured(Python 库):智能解析多种文档。PyPDF2 / pdfplumber:处理 PDF。BeautifulSoup:解析 HTML。RecursiveCharacterTextSplitter 或自定义逻辑。BAAI/bge-small-zh-v1.5(中文优化,轻量高效)m3e-small(中文社区常用)all-MiniLM-L6-v2(英文/多语言,极小)sentence-transformers 库本地加载模型。✅ 所有数据库均可配置为仅保存在本地磁盘,不联网。
Qwen1.5-4B-Chat、Yi-1.5-6B-Chat、DeepSeek-Coder(若含代码)Mistral-7B、Phi-3-mini(微软,性能强且小)Ollama(最简单,一键运行模型)LM Studio(图形界面,支持聊天和 API)llama.cpp(CPU 友好,量化后可在普通笔记本运行)LangChain 或 LlamaIndex 构建本地 RAG 流程(设置 offline=True,禁用网络)。Gradio 或 Streamlit 快速搭建本地 UI)Smart Connections + Local LLM 集成)~/my_knowledge_base/)。ollama serve 或 lmstudio 时,确保禁用远程访问(默认仅 localhost)。pip install langchain chromadb sentence-transformers unstructured
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('BAAI/bge-small-zh-v1.5')
from langchain_chroma import Chroma
from langchain_community.embeddings import HuggingFaceBgeEmbeddings
embeddings = HuggingFaceBgeEmbeddings(model_name="BAAI/bge-small-zh-v1.5")
vectorstore = Chroma.from_documents(
documents=your_docs,
embedding=embeddings,
persist_directory="./chroma_db"
)
results = vectorstore.similarity_search("你的问题", k=3)
print(results)
ollama pull qwen:4b
ollama run qwen:4b
再通过 API 或 LangChain 调用。
✅ 完全本地
✅ 无数据上传
✅ 中文友好
✅ 开源免费
✅ 可在普通电脑运行(尤其使用 4B 以下模型 + Chroma/FAISS)
只要你有一台个人电脑(最好是 16GB+ 内存,如有独立 GPU 更佳),就可以搭建一个私密、安全、高效的个人知识库系统。
以下是一个完全本地化、无需联网、不上传任何数据到云端的中文个人知识库构建教程,使用开源工具和模型。我们将使用:
BAAI/bge-small-zh-v1.5(中文优化,本地运行)从 python.org 下载并安装。
mkdir ~/local_knowledge_base
cd ~/local_knowledge_base
python -m venv venv
source venv/bin/activate # macOS/Linux
# 或
venv\Scripts\activate # Windows
pip install langchain \
langchain-community \
langchain-chroma \
chromadb \
sentence-transformers \
unstructured \
unstructured[local-inference] \
pdf2image \
PyPDF2 \
ollama \
python-dotenv
⚠️ 如果你不需要 PDF 解析,可跳过
pdf2image和PyPDF2。⚠️ 首次运行
sentence-transformers会自动下载模型到本地缓存(约 130MB),但不需要联网之后也能用。如果你完全不能联网,请提前在有网环境下载好模型(见附录)。
在项目目录下创建一个 docs/ 文件夹,放入你的文档,例如:
local_knowledge_base/
├── docs/
│ ├── note1.md
│ ├── note2.md
│ └── my_book_summary.txt
支持格式:
.txt,.md,
创建文件:ingest.py
# ingest.py
import os
from langchain_community.document_loaders import (
DirectoryLoader,
TextLoader,
UnstructuredMarkdownLoader,
PyPDFLoader
)
from langchain_text_splitters import RecursiveCharacterTextSplitter
# 配置路径
DOCS_DIR = "./docs"
PERSIST_DIR = "./chroma_db"
def load_documents():
"""加载所有支持的文档"""
documents = []
# 加载 .txt 文件
txt_loader = DirectoryLoader(DOCS_DIR, glob="*.txt", loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"})
documents.extend(txt_loader.load())
# 加载 .md 文件
md_loader = DirectoryLoader(DOCS_DIR, glob="*.md", loader_cls=UnstructuredMarkdownLoader)
documents.extend(md_loader.load())
# 加载 .pdf 文件(可选)
# pdf_loader = DirectoryLoader(DOCS_DIR, glob="*.pdf", loader_cls=PyPDFLoader)
# documents.extend(pdf_loader.load())
print(f"✅ 共加载 {len(documents)} 个文档片段")
return documents
def split_documents(documents):
"""将文档切分为小块"""
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
length_function=len,
is_separator_regex=False,
)
chunks = text_splitter.split_documents(documents)
print(f"✅ 切分为 {len(chunks)} 个文本块")
return chunks
if __name__ == "__main__":
docs = load_documents()
chunks = split_documents(docs)
# 保存到临时文件(用于下一步嵌入)
os.makedirs(PERSIST_DIR, exist_ok=True)
with open(os.path.join(PERSIST_DIR, "chunks.txt"), "w", encoding="utf-8") as f:
for i, chunk in enumerate(chunks):
f.write(f"--- Chunk {i} ---\n")
f.write(chunk.page_content + "\n\n")
print("✅ 文本块已保存(供调试)")
运行:
python ingest.py
✅ 此时会在
chroma_db/chunks.txt中看到切分后的文本(可选,仅调试用)
创建文件:build_vector_db.py
# build_vector_db.py
from langchain_chroma import Chroma
from langchain_community.embeddings import HuggingFaceBgeEmbeddings
from ingest import load_documents, split_documents
import os
# 嵌入模型配置(完全本地)
embedding_model_name = "BAAI/bge-small-zh-v1.5"
model_kwargs = {'device': 'cpu'} # 若有 GPU 可改为 'cuda'
encode_kwargs = {'normalize_embeddings': True} # BGE 推荐设置
embeddings = HuggingFaceBgeEmbeddings(
model_name=embedding_model_name,
model_kwargs=model_kwargs,
encode_kwargs=encode_kwargs,
query_instruction="为这个句子生成表示以用于检索相关文章:" # BGE 中文模型推荐 query 指令
)
PERSIST_DIR = "./chroma_db"
def build_vector_db():
print("🔍 正在加载文档...")
docs = load_documents()
chunks = split_documents(docs)
print("🧠 正在生成向量并构建数据库(首次运行会下载模型到本地缓存)...")
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory=PERSIST_DIR,
collection_name="my_knowledge"
)
print(f"✅ 向量数据库已保存到 {PERSIST_DIR}")
if __name__ == "__main__":
build_vector_db()
运行:
python build_vector_db.py
⏳ 首次运行会自动下载
BAAI/bge-small-zh-v1.5模型(约 130MB)到~/.cache/huggingface/,之后完全离线可用。✅ 数据库文件将保存在
./chroma_db/,仅在本地磁盘。
创建文件:query.py
# query.py
from langchain_chroma import Chroma
from langchain_community.embeddings import HuggingFaceBgeEmbeddings
embedding_model_name = "BAAI/bge-small-zh-v1.5"
model_kwargs = {'device': 'cpu'}
encode_kwargs = {'normalize_embeddings': True}
embeddings = HuggingFaceBgeEmbeddings(
model_name=embedding_model_name,
model_kwargs=model_kwargs,
encode_kwargs=encode_kwargs,
query_instruction="为这个句子生成表示以用于检索相关文章:"
)
vectorstore = Chroma(
persist_directory="./chroma_db",
embedding_function=embeddings,
collection_name="my_knowledge"
)
def search(query: str, k: int = 3):
docs = vectorstore.similarity_search(query, k=k)
print(f"\n🔍 问题:{query}\n")
for i, doc in enumerate(docs):
print(f"【结果 {i+1}】(来源: {doc.metadata.get('source', '未知')})")
print(doc.page_content[:500] + "...\n")
return docs
if __name__ == "__main__":
while True:
question = input("\n请输入你的问题(输入 'quit' 退出):")
if question.lower() == 'quit':
break
search(question)
运行:
python query.py
✅ 此时你可以直接提问,系统会返回最相关的原文片段,完全本地、无网络请求。
ollama pull qwen:4b
模型大小约 2.5GB(4-bit 量化版),可在 16GB 内存笔记本 CPU 运行(稍慢)。
rag_query.py# rag_query.py
from langchain_chroma import Chroma
from langchain_community.embeddings import HuggingFaceBgeEmbeddings
from langchain_ollama import OllamaLLM
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
# === 配置 ===
embedding_model_name = "BAAI/bge-small-zh-v1.5"
llm_model = "qwen:4b" # Ollama 中的模型名
# === 嵌入与向量库 ===
embeddings = HuggingFaceBgeEmbeddings(
model_name=embedding_model_name,
model_kwargs={'device': 'cpu'},
encode_kwargs={'normalize_embeddings': True},
query_instruction="为这个句子生成表示以用于检索相关文章:"
)
vectorstore = Chroma(
persist_directory="./chroma_db",
embedding_function=embeddings,
collection_name="my_knowledge"
)
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
# === LLM 与 Prompt ===
llm = OllamaLLM(model=llm_model, temperature=0.3)
template = """
你是一个知识助手,请根据以下提供的上下文信息回答问题。
只使用上下文中的内容,不要编造。如果上下文没有相关信息,请回答“根据现有资料无法回答”。
上下文:
{context}
问题:{question}
答案:
"""
prompt = ChatPromptTemplate.from_template(template)
# === RAG 链 ===
rag_chain = (
{"context": retriever, "question": RunnablePassthrough()}
| prompt
| llm
| StrOutputParser()
)
# === 查询 ===
if __name__ == "__main__":
while True:
question = input("\n🧠 请输入你的问题('quit' 退出):")
if question.lower() == 'quit':
break
print("\n🤔 正在思考...\n")
answer = rag_chain.invoke(question)
print(f"✅ 答案:{answer}\n")
⚠️ 确保 Ollama 正在运行(终端输入
ollama serve,或后台已启动)
运行:
python rag_query.py
✅ 此时系统会:
- 用本地嵌入模型检索相关段落
- 将段落 + 问题交给本地 Qwen 模型
- 生成自然语言答案 全程无任何数据上传到网络
./chroma_db/~/.cache/huggingface/hub/~/.ollama/models/C:\Users\<user>\.ollama\models\~/.ollama/models/local_knowledge_base 文件夹放入加密磁盘(如 VeraCrypt)进一步保护。如果你完全不能联网,请在有网络的机器上提前下载:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('BAAI/bge-small-zh-v1.5')
model.save('./bge-small-zh') # 保存到本地
然后修改代码中 model_name='./bge-small-zh'
Ollama 模型可直接复制 ~/.ollama/models/ 到另一台机器(需相同架构)。
你现在已经拥有一个:
的私人知识库系统!
在已有完全本地、离线、隐私安全的知识库基础上,我们可以进一步:
watchdog 监听 docs/ 目录变化,自动增量更新向量库✅ 依然 不联网、不上传任何数据,所有组件运行在本地。
[docs/ 目录] ←─(watchdog 监控)─┐
↓
[用户通过浏览器访问 http://localhost:8501]
↓
[Streamlit GUI] → [检索/问答逻辑] → [ChromaDB + 本地 LLM]
↑
(文档变更时自动触发 rebuild 向量库)
pip install streamlit watchdog
注意:ChromaDB 本身不支持高效增量删除,但我们可以:
- 简单方案:监控到变化 → 重新构建整个向量库(适合 <1000 文档)
- 进阶方案:记录文档 hash,只更新变更/新增的 chunk(本文采用简单方案,清晰可靠)
auto_updater.py# auto_updater.py
import os
import time
import hashlib
from pathlib import Path
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from build_vector_db import build_vector_db # 复用之前写的构建函数
DOCS_DIR = "./docs"
STATE_FILE = "./chroma_db/update_state.txt"
def compute_docs_hash():
"""计算 docs/ 目录下所有文件的 hash,用于判断是否变化"""
hash_md5 = hashlib.md5()
paths = sorted(Path(DOCS_DIR).rglob("*"))
for path in paths:
if path.is_file():
stat = path.stat()
hash_md5.update(str(stat.st_mtime).encode())
hash_md5.update(str(stat.st_size).encode())
return hash_md5.hexdigest()
def load_last_hash():
if os.path.exists(STATE_FILE):
with open(STATE_FILE, "r") as f:
return f.read().strip()
return None
def save_current_hash():
current_hash = compute_docs_hash()
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
with open(STATE_FILE, "w") as f:
f.write(current_hash)
class DocUpdateHandler(FileSystemEventHandler):
def __init__(self, callback):
self.callback = callback
self.last_trigger = 0
def on_any_event(self, event):
# 防抖:5 秒内只触发一次
if time.time() - self.last_trigger > 5:
if event.src_path.endswith(('.txt', '.md', '.pdf')):
print(f"\n📁 检测到文档变更: {event.src_path}")
self.last_trigger = time.time()
self.callback()
def start_watcher():
"""启动文件监控"""
observer = Observer()
event_handler = DocUpdateHandler(on_docs_changed)
observer.schedule(event_handler, DOCS_DIR, recursive=True)
observer.start()
print(f"👀 正在监控 {os.path.abspath(DOCS_DIR)} 目录...")
# 初始检查是否需要首次构建
if not os.path.exists("./chroma_db/chroma.sqlite3"): # ChromaDB 默认文件
print("🆕 首次运行:正在构建向量库...")
build_vector_db()
save_current_hash()
else:
current_hash = compute_docs_hash()
last_hash = load_last_hash()
if current_hash != last_hash:
print("🔄 检测到历史变更:正在重建向量库...")
build_vector_db()
save_current_hash()
return observer
def on_docs_changed():
"""文档变更时的回调"""
build_vector_db()
save_current_hash()
print("✅ 向量库已更新!")
build_vector_db.py(支持被调用)修改 build_vector_db.py,使其可被其他模块调用而不重复初始化模型:
# build_vector_db.py(修改版)
from langchain_chroma import Chroma
from langchain_community.embeddings import HuggingFaceBgeEmbeddings
from ingest import load_documents, split_documents
import os
import shutil
PERSIST_DIR = "./chroma_db"
embedding_model_name = "BAAI/bge-small-zh-v1.5"
model_kwargs = {'device': 'cpu'}
encode_kwargs = {'normalize_embeddings': True}
query_instruction = "为这个句子生成表示以用于检索相关文章:"
# 全局嵌入模型(避免重复加载)
_EMBEDDING_MODEL = None
def get_embedding_model():
global _EMBEDDING_MODEL
if _EMBEDDING_MODEL is None:
_EMBEDDING_MODEL = HuggingFaceBgeEmbeddings(
model_name=embedding_model_name,
model_kwargs=model_kwargs,
encode_kwargs=encode_kwargs,
query_instruction=query_instruction
)
return _EMBEDDING_MODEL
def build_vector_db():
"""构建向量数据库(覆盖写入)"""
# 删除旧数据库(Chroma 不支持高效更新)
if os.path.exists(PERSIST_DIR):
shutil.rmtree(PERSIST_DIR)
os.makedirs(PERSIST_DIR)
docs = load_documents()
chunks = split_documents(docs)
embeddings = get_embedding_model()
print("🧠 正在生成向量...")
Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory=PERSIST_DIR,
collection_name="my_knowledge"
)
print("✅ 向量库重建完成")
app.py# app.py
import streamlit as st
from langchain_chroma import Chroma
from build_vector_db import get_embedding_model
from langchain_ollama import OllamaLLM
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
import os
# === 配置 ===
PERSIST_DIR = "./chroma_db"
USE_RAG = True # 设为 False 则只返回检索结果
# === 初始化 ===
@st.cache_resource
def get_retriever():
embeddings = get_embedding_model()
vectorstore = Chroma(
persist_directory=PERSIST_DIR,
embedding_function=embeddings,
collection_name="my_knowledge"
)
return vectorstore.as_retriever(search_kwargs={"k": 3})
@st.cache_resource
def get_rag_chain():
retriever = get_retriever()
llm = OllamaLLM(model="qwen:4b", temperature=0.3)
template = """
你是一个知识助手,请根据以下提供的上下文信息回答问题。
只使用上下文中的内容,不要编造。如果上下文没有相关信息,请回答“根据现有资料无法回答”。
上下文:
{context}
问题:{question}
答案:
"""
prompt = ChatPromptTemplate.from_template(template)
return (
{"context": retriever, "question": RunnablePassthrough()}
| prompt
| llm
| StrOutputParser()
)
# === Streamlit UI ===
st.set_page_config(page_title="我的本地知识库", layout="wide")
st.title("🧠 我的本地知识库(完全离线)")
# 输入框
query = st.text_input("请输入你的问题:", placeholder="例如:项目的关键时间节点是什么?")
if query:
with st.spinner("正在检索和思考..."):
if USE_RAG:
try:
answer = get_rag_chain().invoke(query)
st.subheader("✅ 答案")
st.write(answer)
except Exception as e:
st.error(f"模型调用失败(请确认 Ollama 正在运行):{str(e)}")
USE_RAG = False # 回退到仅检索
# 显示检索到的原文(始终显示)
retriever = get_retriever()
docs = retriever.invoke(query)
st.subheader("📚 相关原文片段")
for i, doc in enumerate(docs):
source = doc.metadata.get("source", "未知")
st.markdown(f"**来源 {i+1}:** `{os.path.basename(source)}`")
st.text_area("", doc.page_content, height=150, key=f"doc_{i}")
# 侧边栏
with st.sidebar:
st.header("⚙️ 系统状态")
db_exists = os.path.exists(os.path.join(PERSIST_DIR, "chroma.sqlite3"))
st.success("🟢 向量库已加载") if db_exists else st.warning("🔴 向量库未构建")
ollama_status = "🟢 Ollama 可用(若启用 RAG)" if USE_RAG else "⚪ 仅检索模式"
st.write(ollama_status)
st.info("📁 文档目录: `./docs`")
st.info("🔒 所有数据仅保存在本地")
start.sh(Linux/macOS)或 start.bat(Windows)start.sh#!/bin/bash
source venv/bin/activate
# 启动监控
python -c "
from auto_updater import start_watcher
import time
observer = start_watcher()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
observer.stop()
observer.join()
" &
WATCHER_PID=$!
# 启动 Streamlit
streamlit run app.py --server.port=8501 --server.address=127.0.0.1
# 停止监控
kill $WATCHER_PID
start.bat)@echo off
call venv\Scripts\activate
start /B python -c "from auto_updater import start_watcher; import time; o=start_watcher(); time.sleep(100000)" ^
streamlit run app.py --server.port=8501 --server.address=127.0.0.1
💡 更简单的做法:开两个终端分别运行:
# 终端 1:启动监控 python -c "from auto_updater import start_watcher; start_watcher(); import time; time.sleep(100000)" # 终端 2:启动 Web 界面 streamlit run app.py
chmod +x start.sh # Linux/macOS
./start.sh
浏览器会自动打开 http://localhost:8501
./docs/ 下的 .md / .txt 文件127.0.0.1(仅本机访问)| 功能 | 实现方式 |
|---|---|
| 支持 PDF/Word | 在 ingest.py 中启用 Unstructured 的 PDF/DOCX loader |
| 显示文档修改时间 | 在 auto_updater.py 中记录变更日志 |
| 多用户(局域网) | 修改 Streamlit 启动参数 --server.address=0.0.0.0(⚠️ 仅限可信内网) |
| 加密存储 | 将整个项目目录放入 VeraCrypt 加密卷 |
你现在拥有一个:
的完整个人知识库系统!
💡 提示:首次运行会较慢(下载模型 + 构建向量库),之后所有操作均在本地快速完成。
要在 ingest.py 中启用 Unstructured 的 PDF 和 DOCX(Word)文档加载功能,你需要:
ingest.py 中添加对应的加载器Unstructured 使用本地模型(避免联网)由于你要求完全本地、不上传任何信息到云端,我们将配置 Unstructured 使用 本地 OCR(如 Tesseract) 和 本地解析器,禁用其默认可能调用的远程 API(如 unstructured-api)。
# 已有基础
pip install unstructured[local-inference]
# 新增:PDF 和 DOCX 支持
pip install unstructured[pdf] # 包含 pdf2image, PyPDF2
pip install unstructured[docx] # 包含 python-docx
# 可选但推荐(提高 PDF 表格/图文解析质量)
pip install pymupdf # 更快的 PDF 解析(替代 pdf2image)
pip install poppler-utils # 用于 pdf2image(需系统安装)
⚠️ 注意:
unstructured[pdf]默认会尝试使用pdf2image,它依赖系统级的 Poppler。
chi_sim(简体中文)和 chi_tra(繁体)C:\Program Files\Tesseract-OCR)加入系统 PATH 环境变量pdf2image)C:\popplerC:\poppler\Library\bin 加入系统 PATH✅ 验证安装:
tesseract --version pdftoppm -h
ingest.py 启用 PDF/DOCX 加载器更新你的 ingest.py 文件如下(关键:禁用远程 API,强制本地解析):
# ingest.py (更新版:支持 PDF / DOCX / TXT / MD,完全本地)
import os
from langchain_community.document_loaders import (
DirectoryLoader,
TextLoader,
UnstructuredMarkdownLoader,
)
from langchain_community.document_loaders.unstructured import UnstructuredFileLoader
from typing import List
import warnings
warnings.filterwarnings("ignore", category=UserWarning, module="unstructured")
DOCS_DIR = "./docs"
PERSIST_DIR = "./chroma_db"
def load_documents() -> List:
"""加载所有支持的文档(TXT, MD, PDF, DOCX),完全本地解析"""
documents = []
# === .txt 文件 ===
txt_loader = DirectoryLoader(
DOCS_DIR,
glob="*.txt",
loader_cls=TextLoader,
loader_kwargs={"encoding": "utf-8"}
)
documents.extend(txt_loader.load())
# === .md 文件 ===
md_loader = DirectoryLoader(
DOCS_DIR,
glob="*.md",
loader_cls=UnstructuredMarkdownLoader,
loader_kwargs={"encoding": "utf-8"}
)
documents.extend(md_loader.load())
# === .pdf 文件 ===
pdf_files = [str(f) for f in Path(DOCS_DIR).glob("*.pdf")]
for pdf_file in pdf_files:
print(f"📄 正在解析 PDF: {os.path.basename(pdf_file)}")
loader = UnstructuredFileLoader(
pdf_file,
mode="single", # 或 "elements"(更细粒度)
strategy="fast", # "hi_res" 更准但慢,需 OCR
# 关键:强制本地,不调用 API
api_url=None, # 禁用远程 API
# 启用本地 OCR(如果 PDF 是扫描件)
ocr_languages=["chi_sim", "eng"], # 中英混合
)
documents.extend(loader.load())
# === .docx 文件 ===
docx_files = [str(f) for f in Path(DOCS_DIR).glob("*.docx")]
for docx_file in docx_files:
print(f"📝 正在解析 DOCX: {os.path.basename(docx_file)}")
loader = UnstructuredFileLoader(
docx_file,
mode="single",
strategy="fast",
api_url=None, # 禁用远程 API
)
documents.extend(loader.load())
print(f"✅ 共加载 {len(documents)} 个文档片段")
return documents
# --- 保留原有的 split_documents 函数 ---
from langchain_text_splitters import RecursiveCharacterTextSplitter
from pathlib import Path
def split_documents(documents):
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
length_function=len,
is_separator_regex=False,
)
chunks = text_splitter.split_documents(documents)
print(f"✅ 切分为 {len(chunks)} 个文本块")
return chunks
if __name__ == "__main__":
docs = load_documents()
chunks = split_documents(docs)
os.makedirs(PERSIST_DIR, exist_ok=True)
with open(os.path.join(PERSIST_DIR, "chunks.txt"), "w", encoding="utf-8") as f:
for i, chunk in enumerate(chunks):
f.write(f"--- Chunk {i} ---\n")
f.write(chunk.page_content + "\n\n")
print("✅ 文本块已保存(供调试)")
UnstructuredFileLoader 默认在 api_url=None 时会使用 本地解析(partition 函数),不会连接任何远程服务。
你可以通过以下方式确认:
python ingest.py🔒 安全提示:
Unstructured的UnstructuredAPIFileLoader才会联网,我们用的是UnstructuredFileLoader(本地版)。
如果你的 PDF 是扫描件(图片型),确保 Tesseract 已正确安装并支持中文:
# 在 UnstructuredFileLoader 中显式指定 OCR 选项
loader = UnstructuredFileLoader(
pdf_file,
strategy="hi_res", # 启用高级布局分析
ocr_languages="chi_sim+eng", # 中英混合
include_page_breaks=False,
api_url=None,
)
💡
strategy="hi_res"会调用layoutparser和Tesseract,需要额外安装:pip install unstructured[local-inference] # 已包含 layoutparser, detectron2(但 detectron2 在 Windows 安装复杂)如果安装失败,可降级使用
strategy="ocr_only"或"fast"。
local_knowledge_base/
└── docs/
├── notes.md
├── summary.txt
├── report.pdf ← 支持!
└── meeting.docx ← 支持!
放入这些文件后,运行:
python ingest.py
python build_vector_db.py
即可将 PDF/DOCX 内容纳入知识库。
| 问题 | 解决方案 |
|---|---|
TesseractNotFoundError |
安装 Tesseract 并加入 PATH |
poppler not installed |
安装 Poppler 并加入 PATH |
| 中文 OCR 识别乱码 | 安装 chi_sim 语言包(Tesseract 安装时勾选) |
| DOCX 表格丢失 | unstructured 对复杂表格支持有限,可考虑转为 PDF 或 Markdown 预处理 |
你现在已经成功在 ingest.py 中启用了:
所有文档(包括 PDF/DOCX)都会被切块、向量化,并纳入你的本地知识库,可通过图形界面查询。