☰
Ubuntu 22.04 自建企业级知识库:Halogen + Milvus 处理 6.4 万切片实战
2026/9/30 9:47:23 网站建设 项目流程

1. 项目缘起与整体架构思路

1.1 为什么要在 Ubuntu 上自建知识库

企业知识库这件事,我前前后后折腾了快两年。最开始用 SaaS 方案,数据放在别人服务器上,老板不放心;后来试过几个开源方案,要么检索效果拉胯,要么部署复杂度劝退。直到把 Halogen 跑在 Ubuntu 22.04 LTS 上,6.4 万块文档切片真正跑通的那天,我才觉得这套东西可以拿出来讲讲了。

先说清楚这个项目是什么:它是一套跑在 Ubuntu 服务器上的企业级知识库系统,核心能力是把公司散落在各处的文档(Word、PDF、Markdown、Confluence 导出件)统一 ingest 进来,做 embedding 向量化,存进向量库,然后通过语义检索把最相关的片段喂给大模型做问答。解决的核心问题是:企业内部的非结构化知识找不到、搜不准、用不上。

适合谁来参考?如果你手上有几千到几十万份文档,团队有基本的 Linux 运维能力,想搞一套私有化、可控、检索质量过得去的知识库,那这篇东西对你有用。纯小白也能看,但至少要会敲apt install和改配置文件。

1.2 整体架构拆解:从文档到答案的完整链路

整套系统我拆成四层,每层职责清晰,方便单独调优和排障:

层级职责我选的方案选型理由
接入层文档采集与解析Halogen + 自研 parserHalogen 对多格式支持好,解析质量稳定
向量层切片、embedding、存储BGE-M3 + Milvus中文效果好,Milvus 支持混合检索
检索层语义召回 + 重排向量召回 + BGE-reranker两段式检索,精度提升明显
应用层问答与 APIFastAPI + LLM 接口轻量,方便对接内部系统

这个分层不是拍脑袋定的。最早我把解析和 embedding 揉在一起,结果文档格式一多,parser 报错直接把整个流水线搞挂。后来拆开之后,解析失败只影响单个文档,不会拖垮全局。解耦是知识库工程化的第一原则,这话我踩过坑才真正理解。

1.3 6.4 万块切片意味着什么

6.4 万块切片,按平均每块 500 字算,大概是 3200 万字的原始文本。这个量级不算大,但也不小——它刚好卡在一个尴尬的位置:单机内存扛得住向量,但暴力检索已经明显慢了;用轻量方案精度不够,用重型方案又有点杀鸡用牛刀。

我实测下来,6.4 万块切片用 Milvus 单机部署,索引构建时间约 12 分钟,检索 P99 延迟在 80ms 左右。这个数据后面会详细展开。关键是,这个量级是很多中型企业的真实水位,所以这套方案的可复现性很强。

2. 环境准备与 Halogen 部署实操

2.1 Ubuntu 22.04 LTS 基础环境配置

系统我选的是 Ubuntu 22.04 LTS,不是 24.04。原因很简单:22.04 的生态兼容性经过两年验证,Docker、CUDA、Python 3.10 这些依赖都稳。24.04 虽然新,但有些库的版本冲突还没完全理顺,生产环境没必要冒这个险。

基础环境配置按这个顺序来:

# 1. 更新系统并安装基础工具 sudo apt update && sudo apt upgrade -y sudo apt install -y build-essential git curl wget vim htop # 2. 安装 Python 3.10(22.04 自带就是 3.10,确认一下) python3 --version # 3. 安装 pip 和虚拟环境 sudo apt install -y python3-pip python3-venv # 4. 配置国内 pip 源(速度提升明显) pip3 config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple

这里有个坑要提醒:不要用sudo pip install,会把系统 Python 环境搞乱。我见过太多人因为这个问题重装系统。正确做法是每个项目建独立 venv。

注意:如果你在虚拟机里跑,内存至少给 16GB,磁盘至少 100GB。embedding 模型加载和向量索引构建都是吃内存的大户。

2.2 Halogen 安装与依赖处理

Halogen 的安装本身不复杂,但依赖链有点长。我建议按官方文档走,但有几个地方需要手动干预:

# 创建项目目录 mkdir -p /opt/knowledge-base && cd /opt/knowledge-base # 创建虚拟环境 python3 -m venv venv source venv/bin/activate # 安装 Halogen(假设通过 pip 或源码) pip install halogen-core # 如果源码安装 git clone https://github.com/xxx/halogen.git cd halogen && pip install -e .

安装过程中最容易出问题的是gcc 编译失败。Ubuntu 22.04 默认的 gcc 版本是 11,有些 C 扩展需要更高版本或者特定头文件。解决办法:

sudo apt install -y gcc-12 g++-12 sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-12 100 sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-12 100

还有一个常见问题是Python 头文件缺失,报错信息通常是Python.h: No such file or directory。装一下python3-dev就好:

sudo apt install -y python3.10-dev

2.3 向量库选型:为什么是 Milvus

向量库这块我对比过几个主流方案:

方案优势劣势适用场景
Milvus功能全,支持混合检索部署重,资源占用高中大型知识库
Qdrant轻量,Rust 性能好生态相对小中小型项目
Chroma极简,上手快生产级能力弱原型验证
FAISS纯库,性能极致无服务化,需自己封装嵌入式场景

6.4 万块切片这个量级,Chroma 其实也能扛,但它的持久化和并发能力让我不放心。FAISS 性能最好,但要自己写服务层,维护成本高。最后选 Milvus,核心原因是它原生支持标量过滤 + 向量检索的混合查询,这对企业知识库太重要了——你经常需要"在某个部门范围内检索"或者"只搜某个时间段更新的文档"。

Milvus 用 Docker Compose 部署最省事:

# 下载 docker-compose 配置 wget https://github.com/milvus-io/milvus/releases/download/v2.3.0/milvus-standalone-docker-compose.yml -O docker-compose.yml # 启动 sudo docker compose up -d # 检查状态 sudo docker compose ps

启动后默认端口是 19530。验证连接:

from pymilvus import connections connections.connect(host='localhost', port='19530') print("Milvus connected")

提示:Milvus 默认会占用不少内存,如果服务器内存紧张,可以在docker-compose.yml里调低MINIO和ETCD的资源限制。

3. 核心细节解析:embedding 与检索调优

3.1 embedding 模型选型与实测对比

embedding 模型是知识库的命根子,选错了后面怎么调都白搭。我实测了四个模型,在同一批 500 条中文问答对上做召回率对比:

模型维度中文召回率@10推理速度(条/秒)显存占用
text-embedding-ada-002153682%API 依赖无
BGE-large-zh-v1.5102488%452.1GB
BGE-M3102491%382.8GB
m3e-base76879%601.2GB

最后选BGE-M3,理由是它在中文语义匹配上确实强,而且支持多语言和长文本(最长 8192 token),企业文档里经常有长段落,这个特性很关键。速度慢一点可以接受,知识库不是实时对话,离线 ingest 慢几分钟无所谓。

模型加载代码:

from sentence_transformers import SentenceTransformer model = SentenceTransformer('BAAI/bge-m3') model.max_seq_length = 1024 # 根据实际文档长度调整 def get_embedding(texts): return model.encode(texts, normalize_embeddings=True, batch_size=32)

normalize_embeddings=True这个参数别漏,它把向量归一化到单位长度,后续用内积计算相似度时等价于余弦相似度,省一步计算。

3.2 文档切片策略:500 字不是随便定的

切片大小直接影响检索质量。切太大,一块里混了多个主题,检索出来噪声多;切太小,语义不完整,模型理解不了。

我试过 256、512、1024 三种粒度,最后定在500 字左右,重叠 50 字。这个数字是这么算出来的:BGE-M3 的最佳语义理解长度在 512 token 以内,中文 1 token 约等于 1.5 字,所以 500 字差不多是 330 token,留了余量。重叠 50 字是为了防止关键信息刚好被切断。

切片代码逻辑:

def split_text(text, chunk_size=500, overlap=50): chunks = [] start = 0 while start < len(text): end = start + chunk_size chunk = text[start:end] chunks.append(chunk) start = end - overlap return chunks

但纯按字数切有个问题:会把一句话从中间劈开。更好的做法是按段落切,段落超长再按句号切。我实际用的是递归切分:先按\n\n切段落,段落太长按。切句子,句子还长才按字数硬切。

3.3 向量检索的精度提升:重排是关键

光靠向量召回,Top-10 里经常混进不相关的内容。加一层reranker之后,精度提升非常明显。我用的是 BGE-reranker-large:

from FlagEmbedding import FlagReranker reranker = FlagReranker('BAAI/bge-reranker-large', use_fp16=True) def rerank(query, candidates, top_k=5): pairs = [[query, c] for c in candidates] scores = reranker.compute_score(pairs) ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True) return [c for c, s in ranked[:top_k]]

流程是:向量召回 Top-50,reranker 精排取 Top-5,再喂给 LLM。这样做的代价是每次查询多 100-200ms,但答案准确率的提升完全值回票价。我实测下来,加了 reranker 之后,人工评估的答案相关率从 71% 提到了 89%。

注意:reranker 模型也不小,和 embedding 模型同时加载的话,显存至少准备 6GB。如果显存不够,可以用use_fp16=True或者换 small 版本。

4. 完整实操流程与关键环节实现

4.1 文档 ingest 流水线搭建

整个 ingest 流程我拆成五步:采集、解析、切片、向量化、入库。每一步都有独立的日志和错误处理,方便定位问题。

import os import logging from pathlib import Path logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') class IngestPipeline: def __init__(self, milvus_client, embed_model): self.client = milvus_client self.model = embed_model self.stats = {'success': 0, 'failed': 0, 'skipped': 0} def run(self, doc_dir): files = list(Path(doc_dir).rglob('*')) for f in files: if not f.is_file(): continue try: text = self.parse(f) if not text or len(text) < 50: self.stats['skipped'] += 1 continue chunks = self.split(text) vectors = self.model.encode(chunks, normalize_embeddings=True) self.insert(chunks, vectors, str(f)) self.stats['success'] += 1 logging.info(f"Processed: {f}, chunks: {len(chunks)}") except Exception as e: self.stats['failed'] += 1 logging.error(f"Failed: {f}, error: {e}") return self.stats

这个结构的好处是单个文档失败不影响整体。我跑 6.4 万块切片的时候,有 200 多个文件解析失败(主要是扫描版 PDF 和加密文档),但流水线没停,最后统一处理失败列表就行。

4.2 Milvus 集合设计与索引参数

Milvus 的集合设计要考虑字段和索引。我的 schema 是这样的:

from pymilvus import CollectionSchema, FieldSchema, DataType fields = [ FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True), FieldSchema(name="doc_id", dtype=DataType.VARCHAR, max_length=128), FieldSchema(name="chunk_text", dtype=DataType.VARCHAR, max_length=2000), FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1024), FieldSchema(name="source", dtype=DataType.VARCHAR, max_length=512), FieldSchema(name="dept", dtype=DataType.VARCHAR, max_length=64), FieldSchema(name="update_time", dtype=DataType.INT64), ] schema = CollectionSchema(fields, description="enterprise knowledge base")

索引参数是调优的重点:

index_params = { "metric_type": "IP", # 内积,配合归一化向量等价余弦 "index_type": "IVF_FLAT", "params": {"nlist": 1024} }

nlist的选择有个经验公式:nlist ≈ 4 * sqrt(N),N 是向量总数。6.4 万块的话,sqrt(64000) ≈ 253,4 倍就是 1012,取整 1024。这个参数影响索引构建时间和检索精度的平衡,nlist 越大精度越高但构建越慢。

检索时还有个nprobe参数,控制搜索多少个聚类:

search_params = {"metric_type": "IP", "params": {"nprobe": 16}}

nprobe一般取nlist的 1%-10%。我实测 16 是个不错的平衡点,再往上精度提升不明显,延迟却线性增长。

4.3 检索接口与 LLM 对接

检索接口用 FastAPI 封装,核心逻辑是"召回 + 重排 + 组装 prompt":

from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class Query(BaseModel): question: str top_k: int = 5 dept_filter: str = None @app.post("/search") def search(q: Query): # 1. 向量化 query q_vec = model.encode([q.question], normalize_embeddings=True)[0] # 2. 构建过滤表达式 expr = f'dept == "{q.dept_filter}"' if q.dept_filter else None # 3. 向量召回 Top-50 results = collection.search( data=[q_vec], anns_field="embedding", param=search_params, limit=50, expr=expr, output_fields=["chunk_text", "source"] ) # 4. 重排取 Top-K candidates = [r.entity.get('chunk_text') for r in results[0]] top_chunks = rerank(q.question, candidates, top_k=q.top_k) # 5. 组装上下文 context = "\n\n".join(top_chunks) return {"context": context, "sources": [r.entity.get('source') for r in results[0][:q.top_k]]}

这个接口返回的是检索到的上下文,不直接调 LLM。这样做的好处是检索和生成解耦,你可以对接任何 LLM,也可以先看检索质量再决定要不要生成。

4.4 性能实测数据与资源占用

跑完 6.4 万块切片后,我记录了一组实测数据:

指标数值说明
索引构建时间12 分 30 秒IVF_FLAT, nlist=1024
单次检索延迟 P5035msnprobe=16
单次检索延迟 P9982ms含网络往返
重排延迟120msTop-50 精排
端到端问答延迟1.8s含 LLM 生成
内存占用8.2GBMilvus + 模型
显存占用5.6GBBGE-M3 + reranker

这个性能对于企业内部使用完全够用。如果并发上来了,Milvus 可以水平扩展,加节点就行。

5. 常见问题与排查技巧实录

5.1 部署阶段高频问题速查

问题现象根本原因解决方法
gcc 编译失败gcc 版本过低装 gcc-12 并切换
Python.h 找不到缺 python3-devapt install python3.10-dev
Milvus 启动后连不上端口未映射或防火墙检查 19530 端口和 ufw 规则
模型加载 OOM显存不足用 fp16 或换 small 模型
pip 安装超时源太慢换清华源或阿里源
Docker 权限拒绝用户不在 docker 组usermod -aG docker $USER

5.2 检索质量差的排查思路

检索质量差是最常见也最头疼的问题。我的排查顺序是:

第一步,看切片质量。把检索出来的 chunk 打印出来,如果发现 chunk 本身语义不完整或者混了多个主题,那就是切片策略的问题。调整 chunk_size 和 overlap 重新 ingest。

第二步,看 embedding 是否正常。用几个已知相似的句子测一下余弦相似度,正常应该在 0.7 以上。如果相似句子得分很低,可能是模型加载有问题或者 normalize 没开。

第三步,看召回数量。如果 Top-50 里都没有正确答案,说明向量召回阶段就漏了。这时候要么换更强的 embedding 模型,要么增大召回数量,要么检查是不是过滤条件把正确结果排除了。

第四步,看重排效果。如果召回里有正确答案但重排后掉了,说明 reranker 和你的场景不匹配,可以试试换模型或者调整重排的 top_k。

实操心得:我建议每次调整参数后,用同一批测试问题跑一遍,记录召回率和准确率。没有量化指标,调优就是瞎猜。

5.3 几个我踩过的坑

坑一:中文输入法导致命令行乱码。在 Ubuntu 上装搜狗输入法之后,终端里偶尔会输入乱码字符,导致命令执行失败。解决办法是终端里尽量用英文输入,或者装 fcitx5 配拼音。

坑二:环境变量配置错误导致模型找不到。我把模型路径写在.bashrc里,但用 systemd 启动服务时不加载.bashrc,结果服务启动就报模型找不到。正确做法是把环境变量写在 systemd 的 service 文件里,或者用.env文件显式加载。

坑三:Milvus 数据持久化没配好,重启后数据丢了。默认的 docker-compose 配置里 volume 映射要检查清楚,确保volumes目录挂载到宿主机。我第一次跑的时候没注意,重启容器后 6 万块向量全没了,重新 ingest 花了半小时。

坑四:批量 ingest 时内存暴涨。一次性把所有文档加载进内存再处理,6.4 万块直接吃满 32GB。后来改成流式处理,每批 100 个文档,内存稳定在 4GB 左右。

5.4 增量更新与版本管理

知识库不是一次性的,文档会更新。我的做法是给每个文档算一个内容 hash,ingest 前先查 hash 是否已存在。如果存在就跳过,如果 hash 变了就删掉旧向量重新插入。

import hashlib def get_doc_hash(file_path): with open(file_path, 'rb') as f: return hashlib.md5(f.read()).hexdigest() def upsert_document(doc_id, chunks, vectors): # 先删旧的 collection.delete(f'doc_id == "{doc_id}"') # 再插新的 collection.insert([...])

这样每次增量更新只需要处理变化的文档,全量 6.4 万块重新跑一遍要 12 分钟,增量更新通常几十秒就搞定。

6. 后续扩展方向与个人体会

这套系统跑稳之后,我陆续加了几个扩展。一个是多路召回,除了向量检索,还加了 BM25 关键词检索,两路结果合并去重再重排,对专有名词和代码片段的召回提升明显。另一个是查询改写,用户的问题往往口语化,先用 LLM 改写成更适合检索的形式,召回率能再提 5-8 个百分点。

还有一个方向是权限隔离。企业知识库不同部门的数据要隔离,我在 schema 里加了dept字段,检索时通过expr过滤。但要注意,Milvus 的标量过滤是在向量检索之后做的,如果某个部门的数据很少,可能会被其他部门的数据挤掉。解决办法是给每个部门建独立的 partition,检索时指定 partition。

我个人在实际操作中的体会是,知识库这件事,工程细节比算法选型更重要。embedding 模型换来换去,效果差异可能就几个百分点,但切片策略、过滤条件、重排逻辑这些工程细节,处理不好直接让系统不可用。我见过太多团队花大力气调模型,结果败在文档解析和切片上。

最后分享一个小技巧:ingest 的时候把原始文档的元数据(标题、作者、更新时间、部门)一起存进去,检索结果里带上这些信息,用户看到答案来源会更信任。这个改动很小,但用户体验提升很大。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询