
手撸一个 deepseek 本地联网版且私有化部署(ollama + deepseek + langchain + searxXNG + flask)
之前写过一篇 openwebui 和博查结合的 deepseek 联网教程,但是考虑到灵活性、安全性和成本问题,还是决定自己搭一个全部本地私有化的,用 langchain 框架进行开发,通篇代码都在教程里,不夹私货~
前言
之前写过一篇 openwebui 和博查结合的 deepseek 联网教程,但是考虑到灵活性、安全性和成本问题,还是决定自己搭一个全部本地私有化的,用 langchain 框架进行开发,通篇代码都在教程里,不夹私货~
一、准备环境
- ollama,用于在本地运行、部署和管理大型语言模型(LLMs)。
- deepseek 模型,本文用的 deepseek-r1:14b。
- searxXNG,免费的互联网搜索引擎,用户既不被跟踪也不被分析。
- langchain,大语言模型应用程序的开发框架,主要 python 实现。
- flask,python编写的web应用框架,本文主要用来对外提供api。
二、ollama 及 deepseek 部署安装
之前写过类似教程,这里就不在写了,传送门:
保姆级教程 本地部署 deepseek + ollama + open-webui + cuda + cudnn
ollama导出导入模型 解决下载模型慢问题 deepseek + ollama + Cherry Studio 客户端(离线部署)
三、searxXNG 部署安装
- 基于 docker 安装,先去下载需要的配置文件,地址:https://github.com/searxng/searxng-docker
- 下载完,进到根目录,修改以下内容:
① 修改 docker-compose.yaml 的端口,开放外网访问(公网服务器还是要限制下,白名单或者代理之类的):
② 修改 Caddyfile 文件的默认 80 和 443 端口,Caddy除了应用的端口外,默认还会监听 80 和 443,为了防止和别的软件冲突,这里还是修改下:
③ 应用端口可以看自己情况调整,保持和 docker-compose 里面的一致:
④ 修改 searxng 目录下的 settings.yml 的内容
- secret_key 随便改一个
- limiter 改为 false,否定通过 api 会访问不了
- 添加 json 格式,方面 api 调用
# see https://docs.searxng.org/admin/settings/settings.html#settings-use-default-settings
use_default_settings: true
server:
# base_url is defined in the SEARXNG_BASE_URL environment variable, see .env and docker-compose.yml
secret_key: "admin@123456" # change this!
limiter: false # can be disabled for a private instance
image_proxy: true
search:
formats:
- html
- json
ui:
static_use_hash: true
redis:
url: redis://redis:6379/0
⑤ 构建并启动镜像,执行命令:
docker-compose up -d
访问localhost:8080,如下:
在网页上要想正常使用,还需在配置下,点击右上角首选项:
搜索引擎好多国外的,国内用不了,所以只要勾选国内能用的,如下:
配置完之后点保存就可以正常使用了
三、开发环境准备
本文使用的 conda 管理 python 环境,创建一个 langchain 的虚拟机环境
conda create -n langchain python=3.11
使用 langchain 虚拟环境
conda activate langchain
切换pip镜像源
pip config set global.index-url https://mirrors.aliyun.com/pypi/simple
本文用的到依赖都在存在 requirements.txt 里面,如下:
aiohappyeyeballs==2.4.6
aiohttp==3.11.12
aiosignal==1.3.2
annotated-types==0.7.0
anyio==4.8.0
attrs==25.1.0
beautifulsoup4==4.13.3
blinker==1.9.0
certifi==2025.1.31
charset-normalizer==3.4.1
click==8.1.8
colorama==0.4.6
dataclasses-json==0.6.7
faiss==1.9.0
Flask==3.1.0
frozenlist==1.5.0
greenlet==3.1.1
h11==0.14.0
html2text==2024.2.26
httpcore==1.0.7
httpx==0.28.1
httpx-sse==0.4.0
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.6
jsonpatch==1.33
jsonpointer==3.0.0
langchain==0.3.20
langchain-community==0.3.19
langchain-core==0.3.41
langchain-experimental==0.3.4
langchain-ollama==0.2.3
langchain-text-splitters==0.3.6
langsmith==0.3.8
MarkupSafe==3.0.2
marshmallow==3.26.1
mkl-service==2.4.0
multidict==6.1.0
mypy-extensions==1.0.0
ollama==0.4.7
orjson==3.10.15
propcache==0.2.1
pydantic==2.10.6
pydantic-settings==2.8.1
pydantic_core==2.27.2
pypdf==5.3.1
python-dotenv==1.0.1
PyYAML==6.0.2
requests==2.32.3
requests-toolbelt==1.0.0
sniffio==1.3.1
soupsieve==2.6
SQLAlchemy==2.0.38
tenacity==9.0.0
tqdm==4.67.1
typing-inspect==0.9.0
typing_extensions==4.12.2
urllib3==2.3.0
Werkzeug==3.1.3
yarl==1.18.3
zstandard==0.23.0
大家新建一个 requirements.txt 文件,把依赖复制进去,然后下载里面的依赖:
pip install -r requirements.txt
反过来,基于项目生成 requirements.txt 文件:
pip freeze >requirements.txt
faiss 依赖可能下载不下来,执行以下命令:
- CPU版本
conda install -c pytorch faiss-cpu
- GPU 版本:
conda install -c pytorch faiss-gpu
四、开发联网版 deepseek
基础思路如下:
- 将用户的信息发送给 searxXNG 进行检索。
- searxXNG 返回的内容包含很多链接,逐个去请求获取网页内容。
- 对每个网页的内容进行提取,去掉 html 标签之类的 。
- 把所有网页内容进行分块加载到 faiss 向量库,并进行检索返回 TOP5 的内容。
- 将返回的内容结合题词给大模型推理,并返回结果。
- 把结果通过flask返回,并实现打字机效果即流式输出。
① searxXNG 搜索,由于 langchain 自带的 SearxSearchWrapper 模块调用有点问题,返回不出结果,这里改用 request 请求:
def searx_search(keywords):
headers = {
"Cookie": "categories=general; language=zh-CN; method=GET; search_on_category_select=1; disabled_engines=wikipedia__general\054currency__general\054wikidata__general\054duckduckgo__general\054google__general\054lingva__general\054qwant__general\054startpage__general\054dictzone__general\054mymemory translated__general\054brave__general; enabled_engines=360search__general\054baidu__general\054bing__general\054sogou__general;",
}
url = "http://127.0.0.1:8080/search"
params = {
"q": keywords,
"category_general": "1",
"language": "zh-CN",
"format": "json",
}
response = requests.get(url, headers=headers, params=params, verify=False)
print(response.text)
return response.text
② 加载每个网页内容,使用 langchain 的 AsyncHtmlLoader 模块:
loader = AsyncHtmlLoader(urls, ignore_load_errors=True, requests_kwargs={"timeout": 5})
docs = loader.load()
③ 清洗内容,使用的 langchain 的 Html2TextTransformer 模块:
html2text = Html2TextTransformer()
docs = html2text.transform_documents(docs)
④ 对所有内容进行分块并加载到向量库进行检索,这里用的 ollama 部署的 quentinz/bge-large-zh-v1.5 向量模型:
separators = ["\n\n", "\n", "。", "!", "?", " ", ""]
text_splitter = RecursiveCharacterTextSplitter(
separators=[s.pattern if isinstance(s, re.Pattern) else s for s in separators],
chunk_size=500,
chunk_overlap=100)
docs = text_splitter.split_documents(docs)
retriever = get_retriever(docs)
def get_retriever(documents: List[Document]):
# 1. 初始化嵌入模型
embeddings = OllamaEmbeddings(
model=("quentinz/bge-large-zh-v1.5")
)
# 2. 自动构建FAISS向量库
vector_store = FAISS.from_documents(
documents=documents,
embedding=embeddings
)
# 3. 返回检索器(支持相似度搜索和MMR筛选)
return vector_store.as_retriever(search_kwargs={"k": 5})
⑤ 将内容给大模型进行推理:
def get_chat_llm() -> OllamaLLM:
chat_llm = OllamaLLM(
model="deepseek-r1:14b"
)
return chat_llm
def get_answer_prompt() -> ChatPromptTemplate:
system_prompt = """
# 角色
你是一个精心打造的 AI 搜索助手,能够在网络世界中精准搜索,并以中立客观、新闻式的专业语气为用户答疑解惑。
## 技能
### 技能 1: 精准回应用户问题
1. 当用户提出问题时,首先以一个单独的段落简短直接地回答核心问题,随后通过若干独立的段落详细分析问题的各个方面和细节,确保答案完整且逻辑清晰。
2. 提供的答案应具备中等到较长的篇幅,信息量满满且紧密关联,但切勿重复用户问题。
3. 结构化地拆解内容,明确表达内部的分类及逻辑关系,并以 markdown 格式提供详尽内容。
### 技能 2:不要盲目的重复上下文,提供扩展的见解和解释
1. 对上下文信息进行分析、综合和评价,提供更深入的见解和解释。
2. 融入你的专业知识和经验,以提供更加丰富和有深度的答案。
3. 始终保持对用户问题的敏感度和对信息准确性的追求。
## 限制
1. 必须基于上下文信息来回答问题并引用相关内容,然而无需在回应中提及上下文。
2. 只有在必要的时候,才使用无序列表、有序列表等格式。
3. 只有在必要的时候,才通过独立段落详细分析问题、总结关键点和主要信息。
以下是上下文:
{context}
"""
return ChatPromptTemplate([
("system", system_prompt),
("human", "{input}")
])
llm = get_chat_llm()
qa_chain = create_stuff_documents_chain(llm, get_answer_prompt())
rag_chain = create_retrieval_chain(retriever, qa_chain)
stream = rag_chain.stream({
"input": query
})
⑥ 将内容流式返回给前端:
@app.route('/stream', methods=['POST'])
def stream_output():
query = request.json.get("query")
def generate():
stream = start_chat_internet(query)
for chunk in stream:
if "answer" in chunk:
yield "data: " + json.dumps({"answer": chunk['answer']}) + "\n"
elif "context" in chunk:
metadata = []
for context in chunk["context"]:
metadata.append(context.metadata)
yield "data: " + json.dumps({"quote": metadata}) + "\n"
return Response(generate())
最终实现效果如下:
五、完整代码
import json
import re
from typing import List
from langchain_core.runnables import AddableDict
from langchain_ollama import OllamaLLM, OllamaEmbeddings
from requests.exceptions import RequestException
from langchain_community.document_loaders import AsyncHtmlLoader
from langchain_community.document_transformers.html2text import Html2TextTransformer
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain.prompts.chat import ChatPromptTemplate
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain.chains.retrieval import create_retrieval_chain
import os
import sys
import requests
from flask import Flask, Response, request
import json
sys.path.append(os.path.abspath(os.pardir))
# 解决 Intel OpenMP 库(如 MKL、TBB)的运行时冲突
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
# 设置Ollama的主机和端口(可选,如果已在环境变量中设置则不需要)
os.environ["OLLAMA_HOST"] = "127.0.0.1"
os.environ["OLLAMA_PORT"] = "11434"
app = Flask(__name__)
# 基于request请求searx
def searx_search(keywords):
headers = {
"Cookie": "categories=general; language=zh-CN; method=GET; search_on_category_select=1; disabled_engines=wikipedia__general\054currency__general\054wikidata__general\054duckduckgo__general\054google__general\054lingva__general\054qwant__general\054startpage__general\054dictzone__general\054mymemory translated__general\054brave__general; enabled_engines=360search__general\054baidu__general\054bing__general\054sogou__general;",
}
url = "http://127.0.0.1:8080/search"
params = {
"q": keywords,
"category_general": "1",
"language": "zh-CN",
"format": "json",
}
response = requests.get(url, headers=headers, params=params, verify=False)
print(response.text)
return response.text
# 检查url是否可访问
def check_url_access(urls: List[str]) -> List[str]:
urls_can_access = []
for url in urls:
try:
req_res = requests.get(url)
if req_res is not None and req_res.ok:
urls_can_access.append(url)
except RequestException as e:
continue
return urls_can_access
# 初始化ollama
def get_chat_llm() -> OllamaLLM:
chat_llm = OllamaLLM(
model="deepseek-r1:14b"
)
return chat_llm
# 设置提示词,引导模型输出
def get_answer_prompt() -> ChatPromptTemplate:
system_prompt = """
# 角色
你是一个精心打造的 AI 搜索助手,能够在网络世界中精准搜索,并以中立客观、新闻式的专业语气为用户答疑解惑。
## 技能
### 技能 1: 精准回应用户问题
1. 当用户提出问题时,首先以一个单独的段落简短直接地回答核心问题,随后通过若干独立的段落详细分析问题的各个方面和细节,确保答案完整且逻辑清晰。
2. 提供的答案应具备中等到较长的篇幅,信息量满满且紧密关联,但切勿重复用户问题。
3. 结构化地拆解内容,明确表达内部的分类及逻辑关系,并以 markdown 格式提供详尽内容。
### 技能 2:不要盲目的重复上下文,提供扩展的见解和解释
1. 对上下文信息进行分析、综合和评价,提供更深入的见解和解释。
2. 融入你的专业知识和经验,以提供更加丰富和有深度的答案。
3. 始终保持对用户问题的敏感度和对信息准确性的追求。
## 限制
1. 必须基于上下文信息来回答问题并引用相关内容,然而无需在回应中提及上下文。
2. 只有在必要的时候,才使用无序列表、有序列表等格式。
3. 只有在必要的时候,才通过独立段落详细分析问题、总结关键点和主要信息。
以下是上下文:
{context}
"""
return ChatPromptTemplate([
("system", system_prompt),
("human", "{input}")
])
# 向量检索
def get_retriever(documents: List[Document]):
# 1. 初始化嵌入模型
embeddings = OllamaEmbeddings(
model=("quentinz/bge-large-zh-v1.5")
)
# 2. 自动构建FAISS向量库
vector_store = FAISS.from_documents(
documents=documents,
embedding=embeddings # 自动处理索引和存储
)
# 3. 返回检索器(支持相似度搜索和MMR筛选)
return vector_store.as_retriever(search_kwargs={"k": 5})
# 主流程
def start_chat_internet(query):
# searx搜索
results_json = searx_search(query)
results_obj = json.loads(results_json)
results = results_obj["results"]
urls_to_look = []
for result in results:
urls_to_look.append(result["url"])
# 检查url
urls = check_url_access(urls_to_look)
print(f"urls: {urls}")
# 加载网页
loader = AsyncHtmlLoader(urls, ignore_load_errors=True, requests_kwargs={"timeout": 5})
docs = loader.load()
# 清洗内容
html2text = Html2TextTransformer()
docs = html2text.transform_documents(docs)
# 文本分块
separators = ["\n\n", "\n", "。", "!", "?", " ", ""]
text_splitter = RecursiveCharacterTextSplitter(
separators=[s.pattern if isinstance(s, re.Pattern) else s for s in separators],
chunk_size=500,
chunk_overlap=100)
docs = text_splitter.split_documents(docs)
# 检索增强
retriever = get_retriever(docs)
# 模型推理
llm = get_chat_llm()
qa_chain = create_stuff_documents_chain(llm, get_answer_prompt())
rag_chain = create_retrieval_chain(retriever, qa_chain)
return rag_chain
# 流式输出
def start_chat_internet_stream(query):
rag_chain = start_chat_internet(query)
stream = rag_chain.stream({
"input": query
})
return stream
# 完整输出
def start_chat_internet_invoke(query):
rag_chain = start_chat_internet(query)
invoke = rag_chain.invoke({
"input": query
})
return invoke
@app.route('/stream', methods=['POST'])
def stream_output():
query = request.json.get("query")
def generate():
stream = start_chat_internet_stream(query)
for chunk in stream:
if "answer" in chunk:
yield "data: " + json.dumps({"answer": chunk['answer']}) + "\n"
elif "context" in chunk:
metadata = []
for context in chunk["context"]:
metadata.append(context.metadata)
yield "data: " + json.dumps({"quote": metadata}) + "\n"
return Response(generate())
def test_stream():
# 流式输出
stream = start_chat_internet_stream("deepseek-r1什么时候发布的")
for chunk in stream:
if "answer" in chunk:
print(chunk['answer'])
elif "context" in chunk:
metadata = []
for context in chunk["context"]:
metadata.append(context.metadata)
print(json.dumps(metadata))
def test_invoke():
# 完整输出
invoke = start_chat_internet_invoke("deepseek-r1什么时候发布的")
print(invoke["answer"])
if __name__ == '__main__':
app.run(host='0.0.0.0', port=7862)
# test_stream()
# test_invoke()
测试接口代码:
import requests
import json
url = 'http://127.0.0.1:7862/chat'
input_query = {
"query": "deepseek-r1什么时候发布的"
}
response = requests.post(url, json=input_query, stream=True)
if response.status_code == 200:
for line in response.iter_lines():
if line:
print(json.loads(line.decode('utf-8').split('data: ')[1]))
else:
print(f"Error: {response.status_code} - {response.text}")
大家可以根据每个环节的参数和代码进行优化。
更多推荐
所有评论(0)