前言

之前写过一篇 openwebui 和博查结合的 deepseek 联网教程,但是考虑到灵活性、安全性和成本问题,还是决定自己搭一个全部本地私有化的,用 langchain 框架进行开发,通篇代码都在教程里,不夹私货~

一、准备环境

  1. ollama,用于在本地运行、部署和管理大型语言模型(LLMs)。
  2. deepseek 模型,本文用的 deepseek-r1:14b。
  3. searxXNG,免费的互联网搜索引擎,用户既不被跟踪也不被分析。
  4. langchain,大语言模型应用程序的开发框架,主要 python 实现。
  5. flask,python编写的web应用框架,本文主要用来对外提供api。

二、ollama 及 deepseek 部署安装

之前写过类似教程,这里就不在写了,传送门:

保姆级教程 本地部署 deepseek + ollama + open-webui + cuda + cudnn

ollama导出导入模型 解决下载模型慢问题 deepseek + ollama + Cherry Studio 客户端(离线部署)

三、searxXNG 部署安装

  1. 基于 docker 安装,先去下载需要的配置文件,地址:https://github.com/searxng/searxng-docker
  2. 下载完,进到根目录,修改以下内容:

① 修改 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

基础思路如下:

  1. 将用户的信息发送给 searxXNG 进行检索。
  2. searxXNG 返回的内容包含很多链接,逐个去请求获取网页内容。
  3. 对每个网页的内容进行提取,去掉 html 标签之类的 。
  4. 把所有网页内容进行分块加载到 faiss 向量库,并进行检索返回 TOP5 的内容。
  5. 将返回的内容结合题词给大模型推理,并返回结果。
  6. 把结果通过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}")

大家可以根据每个环节的参数和代码进行优化。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐