上一篇 下一篇 分享链接 返回 返回顶部

企业内网也能用的智能搜索:一套可落地的私有化部署方案与源码实践

发布人:慈云数据-客服中心 发布时间:16小时前 阅读量:2

AI搜索 私有化部署方案|附源码

在企业数字化转型过程中,知识检索一直是一个高频且关键的场景。过去,企业通常依赖传统全文检索系统,例如 Elasticsearch、Solr、OpenSearch 等,通过关键词匹配来查找文档、制度、合同、产品资料、客服知识库等内容。然而,随着大语言模型和向量数据库的发展,单纯依靠关键词匹配已经无法满足更复杂的语义理解需求。

例如,用户输入“员工离职后社保怎么处理”,传统搜索可能只能匹配包含“离职”“社保”等关键词的文档;而 AI 搜索则可以理解用户真正想问的是“离职手续中的社保转移或停缴流程”,从而返回更准确的答案,并附带相关资料来源。

本文将系统介绍一套可私有化部署的 AI 搜索方案,包括整体架构、核心模块、技术选型、部署流程、数据处理方式、问答流程以及示例源码。该方案适用于企业内部知识库、政企文档检索、法律条文查询、客服知识库、研发文档搜索、医疗资料检索等场景。


一、为什么需要 AI 搜索私有化部署?

目前市面上有很多在线 AI 搜索产品和大模型服务,但对于企业来说,直接使用公有云 API 往往会面临以下问题。

1. 数据安全要求高

企业知识库中可能包含大量敏感信息,例如:

  • 内部制度文件
  • 客户合同
  • 财务报表
  • 技术方案
  • 源代码文档
  • 用户隐私数据
  • 项目管理资料

如果将这些数据上传到第三方平台,可能会带来数据泄露和合规风险。因此,对于金融、医疗、政务、制造、能源等行业来说,私有化部署是更加稳妥的选择。

2. 网络环境受限

不少企业内部系统运行在内网环境中,无法直接访问公网服务。AI 搜索系统如果依赖外部 API,会出现网络不可控、延迟高、服务不稳定等问题。私有化部署可以让整个检索和问答链路运行在企业内网中。

3. 成本可控

当搜索请求量较大时,调用外部大模型 API 的成本会持续增长。通过私有化部署开源模型和向量数据库,可以将成本转化为固定的服务器资源投入,长期来看更适合大规模使用。

4. 可定制能力强

不同企业的文档格式、权限模型、搜索规则、答案风格都不一样。私有化部署可以根据自身业务深度定制,例如:

  • 接入企业统一登录系统
  • 按部门和岗位控制文档权限
  • 自定义召回策略
  • 自定义提示词模板
  • 集成 OA、CRM、ERP、知识库系统
  • 接入内部大模型或国产模型

二、AI 搜索的基本原理

AI 搜索通常基于 RAG 架构,即 Retrieval-Augmented Generation,中文可译为“检索增强生成”。

它的核心思想是:先从知识库中检索相关内容,再让大语言模型基于检索结果生成答案。

一个完整的 AI 搜索流程大致如下:

  1. 用户输入问题;
  2. 系统将问题转换为向量;
  3. 在向量数据库中检索相似文档片段;
  4. 同时可以使用关键词检索进行补充召回;
  5. 对召回结果进行重排序;
  6. 将最相关的文档片段拼接到 Prompt 中;
  7. 调用大语言模型生成最终答案;
  8. 返回答案和引用来源。

相比传统搜索,AI 搜索不仅能找文档,还能直接生成结构化答案,并指出答案来源,用户体验更接近“问专家”。


三、整体架构设计

本方案采用前后端分离架构,后端负责文档处理、向量检索、问答生成,前端负责用户交互和结果展示。

整体架构如下:

用户浏览器
   │
   ▼
前端 Web UI
   │
   ▼
后端 API 服务 FastAPI
   │
   ├── 文档上传模块
   ├── 文档解析模块
   ├── 文本切片模块
   ├── 向量生成模块
   ├── 向量检索模块
   ├── 关键词检索模块
   ├── 结果重排序模块
   └── 大模型问答模块
   │
   ├── 向量数据库 Milvus / Qdrant / Chroma
   ├── 关系数据库 PostgreSQL / MySQL
   ├── 对象存储 MinIO
   └── 本地大模型服务 Ollama / vLLM / Xinference

四、技术选型

1. 后端框架:FastAPI

FastAPI 是 Python 生态中非常适合构建 AI 应用接口的框架,具有以下优点:

  • 性能较好;
  • 原生支持异步;
  • 自动生成 Swagger 文档;
  • 与 Python AI 生态兼容性强;
  • 开发效率高。

2. 向量数据库:Qdrant

向量数据库用于存储文档片段对应的语义向量,并支持相似度检索。常见选择包括 Milvus、Qdrant、Chroma、Weaviate 等。

本文示例采用 Qdrant,原因是它部署简单、性能稳定、API 清晰,适合中小型企业快速落地。

3. 文本向量模型:bge-m3

Embedding 模型用于将文本转换成向量。推荐使用开源中文效果较好的模型,例如:

  • BAAI/bge-small-zh
  • BAAI/bge-base-zh
  • BAAI/bge-large-zh
  • BAAI/bge-m3

其中 bge-m3 支持多语言、长文本和多种检索模式,适合企业知识库场景。

4. 大语言模型:Qwen / DeepSeek / Llama

私有化部署时,可以根据硬件资源选择不同的大模型:

  • 低资源环境:Qwen2.5-7B、DeepSeek-R1-Distill-Qwen-7B
  • 中等资源环境:Qwen2.5-14B、GLM-4-9B
  • 高资源环境:Qwen2.5-32B、DeepSeek 系列大模型
  • 国产化环境:通义千问、智谱、百川、书生浦语等开源模型

如果只是构建内部知识搜索,7B 或 14B 模型通常已经可以满足基础需求。若对推理能力、总结能力和复杂问答要求较高,则可以使用更大的模型。

5. 模型服务:Ollama

Ollama 适合快速部署和管理本地大模型,命令简单,适合原型验证和中小规模场景。

例如拉取 Qwen 模型:

ollama pull qwen2.5:7b

启动服务后,默认会监听:

http://localhost:11434

后端可以通过 HTTP API 调用 Ollama 完成问答。


五、硬件配置建议

不同规模的 AI 搜索系统对硬件要求不同。以下是一个参考配置。

1. 测试环境

适合个人开发、POC 验证、小型知识库。

CPU:8 核
内存:32GB
GPU:可选,建议 12GB 显存以上
硬盘:500GB SSD
模型:Qwen2.5-7B
向量库:Qdrant

2. 中小企业生产环境

适合几十到几百人使用,文档量在数十万片段以内。

CPU:16 核以上
内存:64GB ~ 128GB
GPU:NVIDIA 4090 / L20 / A10 / A100
显存:24GB 以上
硬盘:1TB ~ 4TB SSD
模型:Qwen2.5-14B 或 32B
向量库:Qdrant / Milvus

3. 大规模生产环境

适合政企级知识平台、多业务线、多租户场景。

CPU:32 核以上
内存:256GB 以上
GPU:多卡 A100 / H800 / L40S
硬盘:分布式存储
模型服务:vLLM 多实例
向量库:Milvus 集群
数据库:PostgreSQL 高可用
对象存储:MinIO 集群

六、数据处理流程

AI 搜索效果好不好,很大程度取决于数据处理质量。很多项目失败并不是模型不够强,而是文档解析、文本切片、元数据管理做得不好。

1. 文档上传

系统需要支持常见格式:

  • PDF
  • Word
  • Excel
  • PPT
  • TXT
  • Markdown
  • HTML
  • CSV
  • 图片 OCR

上传后,文件应保存到对象存储或本地文件系统,同时在数据库中记录文件元信息,例如文件名、上传人、部门、权限、时间、文件类型等。

2. 文档解析

不同格式需要不同解析方式:

  • PDF:使用 PyMuPDF、pdfplumber;
  • Word:使用 python-docx;
  • Excel:使用 pandas、openpyxl;
  • Markdown:直接读取文本;
  • 图片:使用 PaddleOCR 或 Tesseract OCR。

解析时要尽量保留标题、段落、页码、表格结构等信息。因为最终返回答案时,用户通常需要知道答案来自哪个文件、哪一页、哪个章节。

3. 文本切片

大语言模型不能直接处理无限长文档,因此需要将文档切成多个小片段。常见切片方式包括:

  • 按固定字符数切片;
  • 按段落切片;
  • 按标题层级切片;
  • 滑动窗口切片;
  • 语义切片。

推荐策略是:按标题和段落优先,结合滑动窗口。

例如每个片段控制在 500 到 1000 个中文字符之间,相邻片段保留 100 到 200 字重叠。这样既能保证上下文完整,又能避免片段过长导致检索不精准。

4. 向量化入库

每个文档片段都需要生成向量,并存入向量数据库。存储内容通常包括:

{
  "chunk_id": "唯一片段ID",
  "doc_id": "文档ID",
  "title": "文档标题",
  "content": "片段正文",
  "page": 12,
  "department": "人力资源部",
  "permission": ["hr", "manager"],
  "created_at": "2025-01-01"
}

在检索时,不仅可以根据向量相似度召回,还可以根据部门、权限、文档类型等元数据进行过滤。


七、检索策略设计

高质量 AI 搜索不应只依赖单一向量检索,而应采用混合检索。

1. 向量检索

向量检索擅长理解语义。例如:

用户问题:

试用期员工能不能提前离职?

系统可以召回包含以下内容的文档:

劳动者在试用期内提前三日通知用人单位,可以解除劳动合同。

虽然文本中没有完全出现“能不能提前离职”,但语义相近,因此可以被召回。

2. 关键词检索

关键词检索擅长精确匹配,例如合同编号、产品型号、制度名称、人名、项目编号等。

例如:

查询 HT-2024-0918 合同付款条款

这类问题必须依赖关键词精确召回,否则向量检索可能不稳定。

3. 混合检索

推荐采用以下方式:

最终召回结果 = 向量检索 TopK + 关键词检索 TopK

然后对结果去重,再交给重排序模型进行排序。

4. 重排序

Embedding 检索召回速度快,但排序不一定最准确。可以使用 reranker 模型进一步判断“问题”和“文档片段”的相关性。

推荐模型:

  • BAAI/bge-reranker-base
  • BAAI/bge-reranker-large
  • bge-reranker-v2-m3

重排序后,只选择最相关的 3 到 8 个片段交给大模型生成答案。


八、问答生成 Prompt 设计

Prompt 是 AI 搜索效果的关键之一。一个基础模板如下:

你是企业内部知识库问答助手。
请只根据给定的参考资料回答用户问题。
如果参考资料中没有答案,请明确说明“根据现有资料无法确定”,不要编造。
回答要结构清晰,必要时使用条目。
回答后请列出引用来源。

用户问题:
{question}

参考资料:
{context}

为了减少幻觉,必须要求模型“只根据参考资料回答”。同时要返回引用来源,让用户可以追溯原文。


九、项目目录结构

下面给出一个简化版 AI 搜索项目结构:

ai-search-private/
├── app/
│   ├── main.py
│   ├── config.py
│   ├── models/
│   │   └── schema.py
│   ├── services/
│   │   ├── document_parser.py
│   │   ├── text_splitter.py
│   │   ├── embedding_service.py
│   │   ├── vector_store.py
│   │   ├── llm_service.py
│   │   └── qa_service.py
│   └── utils/
│       └── file_utils.py
├── data/
│   └── uploads/
├── requirements.txt
├── docker-compose.yml
└── README.md

十、核心源码示例

以下源码为一个最小可运行版本,主要演示“上传文档、切片入库、检索问答”的核心流程。生产环境需要补充权限控制、任务队列、异常处理、日志监控、异步索引等能力。


1. requirements.txt

fastapi==0.115.0
uvicorn==0.30.6
qdrant-client==1.11.3
sentence-transformers==3.0.1
python-multipart==0.0.9
requests==2.32.3
pydantic==2.8.2

2. docker-compose.yml

version: "3.8"

services:
  qdrant:
    image: qdrant/qdrant:v1.11.0
    container_name: qdrant
    ports:
      - "6333:6333"
      - "6334:6334"
    volumes:
      - ./qdrant_storage:/qdrant/storage

启动向量数据库:

docker compose up -d

3. config.py

import os

class Settings:
    QDRANT_HOST = os.getenv("QDRANT_HOST", "localhost")
    QDRANT_PORT = int(os.getenv("QDRANT_PORT", "6333"))
    COLLECTION_NAME = os.getenv("COLLECTION_NAME", "enterprise_knowledge")
    EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "BAAI/bge-small-zh-v1.5")
    OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434/api/generate")
    LLM_MODEL = os.getenv("LLM_MODEL", "qwen2.5:7b")
    UPLOAD_DIR = os.getenv("UPLOAD_DIR", "./data/uploads")

settings = Settings()

4. text_splitter.py

def split_text(text: str, chunk_size: int = 800, overlap: int = 120):
    """
    将长文本切分为多个带重叠的片段。
    chunk_size:每个片段最大字符数
    overlap:相邻片段重叠字符数
    """
    text = text.replace("\r\n", "\n").strip()
    chunks = []

    start = 0
    length = len(text)

    while start < length:
        end = min(start + chunk_size, length)
        chunk = text[start:end].strip()

        if chunk:
            chunks.append(chunk)

        if end == length:
            break

        start = end - overlap

    return chunks

5. embedding_service.py

from sentence_transformers import SentenceTransformer
from app.config import settings

class EmbeddingService:
    def __init__(self):
        self.model = SentenceTransformer(settings.EMBEDDING_MODEL)

    def encode(self, texts):
        vectors = self.model.encode(
            texts,
            normalize_embeddings=True
        )
        return vectors.tolist()

embedding_service = EmbeddingService()

6. vector_store.py

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
from app.config import settings
from app.services.embedding_service import embedding_service
import uuid

class VectorStore:
    def __init__(self):
        self.client = QdrantClient(
            host=settings.QDRANT_HOST,
            port=settings.QDRANT_PORT
        )
        self.collection_name = settings.COLLECTION_NAME
        self._init_collection()

    def _init_collection(self):
        collections = self.client.get_collections().collections
        names = [c.name for c in collections]

        if self.collection_name not in names:
            self.client.create_collection(
                collection_name=self.collection_name,
                vectors_config=VectorParams(
                    size=512,
                    distance=Distance.COSINE
                )
            )

    def add_texts(self, texts, metadatas):
        vectors = embedding_service.encode(texts)
        points = []

        for text, vector, metadata in zip(texts, vectors, metadatas):
            point_id = str(uuid.uuid4())
            payload = {
                "content": text,
                **metadata
            }
            points.append(
                PointStruct(
                    id=point_id,
                    vector=vector,
                    payload=payload
                )
            )

        self.client.upsert(
            collection_name=self.collection_name,
            points=points
        )

        return len(points)

    def search(self, query: str, top_k: int = 5):
        query_vector = embedding_service.encode([query])[0]

        results = self.client.search(
            collection_name=self.collection_name,
            query_vector=query_vector,
            limit=top_k
        )

        return [
            {
                "score": item.score,
                "content": item.payload.get("content"),
                "source": item.payload.get("source"),
                "doc_id": item.payload.get("doc_id")
            }
            for item in results
        ]

vector_store = VectorStore()

注意:上面 VectorParams(size=512) 需要与实际 Embedding 模型输出维度一致。bge-small-zh-v1.5 的向量维度为 512。如果更换模型,需要同步调整。


7. llm_service.py

import requests
from app.config import settings

class LLMService:
    def generate(self, prompt: str):
        response = requests.post(
            settings.OLLAMA_URL,
            json={
                "model": settings.LLM_MODEL,
                "prompt": prompt,
                "stream": False
            },
            timeout=120
        )
        response.raise_for_status()
        data = response.json()
        return data.get("response", "")

llm_service = LLMService()

8. qa_service.py

from app.services.vector_store import vector_store
from app.services.llm_service import llm_service

class QAService:
    def ask(self, question: str):
        docs = vector_store.search(question, top_k=5)

        context_text = ""
        for index, doc in enumerate(docs, start=1):
            context_text += f"""
资料{index}:
来源:{doc.get("source")}
内容:{doc.get("content")}
"""

        prompt = f"""
你是企业内部知识库问答助手。
请严格根据参考资料回答用户问题。
如果参考资料中没有相关答案,请回答:根据现有资料无法确定。
不要编造事实。
回答要清晰、准确、简洁。
最后列出引用来源。

用户问题:
{question}

参考资料:
{context_text}
"""

        answer = llm_service.generate(prompt)

        return {
            "question": question,
            "answer": answer,
            "references": docs
        }

qa_service = QAService()

9. main.py

import os
import uuid
from fastapi import FastAPI, UploadFile, File
from pydantic import BaseModel
from app.config import settings
from app.services.text_splitter import split_text
from app.services.vector_store import vector_store
from app.services.qa_service import qa_service

app = FastAPI(title="AI搜索私有化部署示例")

class AskRequest(BaseModel):
    question: str

@app.post("/upload")
async def upload_file(file: UploadFile = File(...)):
    os.makedirs(settings.UPLOAD_DIR, exist_ok=True)

    doc_id = str(uuid.uuid4())
    file_path = os.path.join(settings.UPLOAD_DIR, file.filename)

    with open(file_path, "wb") as f:
        f.write(await file.read())

    # 简化示例:仅支持 txt / md 文本文件
    with open(file_path, "r", encoding="utf-8") as f:
        text = f.read()

    chunks = split_text(text)
    metadatas = []

    for i, chunk in enumerate(chunks):
        metadatas.append({
            "doc_id": doc_id,
            "source": file.filename,
            "chunk_index": i
        })

    count = vector_store.add_texts(chunks, metadatas)

    return {
        "message": "上传并索引成功",
        "doc_id": doc_id,
        "chunks": count
    }

@app.post("/ask")
async def ask(req: AskRequest):
    return qa_service.ask(req.question)

十一、运行步骤

1. 安装依赖

pip install -r requirements.txt

2. 启动 Qdrant

docker compose up -d

3. 启动 Ollama 并拉取模型

ollama pull qwen2.5:7b
ollama serve

如果 Ollama 已经作为服务运行,则无需重复执行 ollama serve

4. 启动后端服务

uvicorn app.main:app --host 0.0.0.0 --port 8000

访问接口文档:

http://localhost:8000/docs

十二、接口使用示例

1. 上传文档

curl -X POST "http://localhost:8000/upload" \
  -F "file=@员工手册.txt"

返回示例:

{
  "message": "上传并索引成功",
  "doc_id": "5c377e8f-8a11-45ff-9d70-a983f9c3db01",
  "chunks": 16
}

2. 提问

curl -X POST "http://localhost:8000/ask" \
  -H "Content-Type: application/json" \
  -d '{"question":"试用期员工离职需要提前几天通知公司?"}'

返回示例:

{
  "question": "试用期员工离职需要提前几天通知公司?",
  "answer": "根据员工手册,试用期员工如需离职,应提前三日通知公司,并按照公司要求完成工作交接手续。\n\n引用来源:员工手册.txt",
  "references": [
    {
      "score": 0.82,
      "content": "员工在试用期内解除劳动合同,应提前三日通知公司,并完成相关交接流程。",
      "source": "员工手册.txt",
      "doc_id": "5c377e8f-8a11-45ff-9d70-a983f9c3db01"
    }
  ]
}

十三、生产环境优化建议

上面的源码适合演示核心逻辑,但生产环境还需要进一步增强。

1. 增加权限控制

企业知识库往往存在严格的权限边界。例如普通员工不能查看财务合同,销售人员只能查看自己部门的客户资料。权限控制建议在文档元数据中加入:

{
  "department": "finance",
  "roles": ["finance_manager"],
  "owner": "user_001"
}

检索时根据当前登录用户过滤可访问文档,避免越权访问。

2. 使用异步任务队列

大文件解析和向量化耗时较长,不应在上传接口中同步完成。推荐引入:

  • Celery
  • RQ
  • Dramatiq
  • Kafka 消费任务

上传接口只负责保存文件并创建任务,后台 Worker 负责解析、切片和入库。

3. 增加文档版本管理

企业文档经常更新,需要支持:

  • 文档重新索引;
  • 旧版本归档;
  • 删除文档时同步删除向量;
  • 根据版本号检索;
  • 回滚历史版本。

4. 引入混合检索

纯向量检索在处理编号、金额、日期、专业术语时可能不稳定。因此建议同时引入 Elasticsearch 或 OpenSearch,构建关键词索引,再与向量检索结果融合。

5. 增加重排序模型

对于复杂知识库,召回结果可能包含噪声。引入 reranker 后,可以显著提升答案准确率。典型流程:

用户问题
  → 向量召回 20 条
  → 关键词召回 20 条
  → 去重合并
  → reranker 重排
  → 取前 5 条生成答案

6. 完善可观测性

AI 搜索系统应记录完整链路日志,包括:

  • 用户问题;
  • 召回文档;
  • 召回分数;
  • Prompt 内容;
  • 模型输出;
  • 响应耗时;
  • 用户反馈。

这样可以持续优化检索策略和 Prompt。


十四、常见问题与解决方案

1. 为什么上传文档后搜不到?

可能原因包括:

  • 文档解析失败;
  • 文本切片过短或过长;
  • Embedding 模型效果不佳;
  • 向量维度配置错误;
  • 检索 TopK 太小;
  • 问题表述与文档语义差距过大。

建议先查看向量库中是否有数据,再打印召回结果进行分析。

2. 为什么答案会胡编?

这是大语言模型常见的幻觉问题。解决方法包括:

  • Prompt 中明确要求只根据资料回答;
  • 当召回结果分数低时直接提示无答案;
  • 返回引用来源;
  • 对答案做事实校验;
  • 使用更强的模型;
  • 优化文档切片和召回。

3. 文档切片多大合适?

一般中文知识库建议每个片段 500 到 1000 字,重叠 100 到 200 字。如果是法律、制度类文档,可以按条款切分;如果是技术文档,可以按标题层级切分。

4. 是否一定需要 GPU?

如果只做向量检索,CPU 也可以运行,但速度较慢。大模型推理强烈建议使用 GPU。对于小规模测试,可以使用量化模型在 CPU 上运行,但生产环境不推荐。


十五、总结

AI 搜索私有化部署的核心价值在于:在保障企业数据安全的前提下,让内部知识真正“可问、可查、可追溯”。相比传统搜索,它不只是返回一堆文档链接,而是能够理解用户问题,自动检索相关资料,并生成结构化答案。

本文给出的方案采用 FastAPI、Qdrant、Sentence Transformers 和 Ollama 构建了一套最小可运行的 AI 搜索系统。它具备以下能力:

  • 文档上传;
  • 文本切片;
  • 向量生成;
  • 向量入库;
  • 语义检索;
  • 本地大模型问答;
  • 引用来源返回。

对于真实生产环境,还需要继续完善权限管理、混合检索、重排序、文档版本管理、异步任务、监控告警和多租户能力。

如果企业希望快速落地 AI 搜索,建议先从一个小范围知识库开始,例如员工手册、产品说明书、客服 FAQ 或研发文档。通过 POC 验证检索效果、响应速度和用户体验后,再逐步扩展到更多业务系统。最终,AI 搜索可以成为企业内部知识管理、智能客服、办公助手和决策支持的重要基础设施。

目录结构
全文