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 + 自研 parser | Halogen 对多格式支持好,解析质量稳定 |
| 向量层 | 切片、embedding、存储 | BGE-M3 + Milvus | 中文效果好,Milvus 支持混合检索 |
| 检索层 | 语义召回 + 重排 | 向量召回 + BGE-reranker | 两段式检索,精度提升明显 |
| 应用层 | 问答与 API | FastAPI + 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-dev2.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-002 | 1536 | 82% | API 依赖 | 无 |
| BGE-large-zh-v1.5 | 1024 | 88% | 45 | 2.1GB |
| BGE-M3 | 1024 | 91% | 38 | 2.8GB |
| m3e-base | 768 | 79% | 60 | 1.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 |
| 单次检索延迟 P50 | 35ms | nprobe=16 |
| 单次检索延迟 P99 | 82ms | 含网络往返 |
| 重排延迟 | 120ms | Top-50 精排 |
| 端到端问答延迟 | 1.8s | 含 LLM 生成 |
| 内存占用 | 8.2GB | Milvus + 模型 |
| 显存占用 | 5.6GB | BGE-M3 + reranker |
这个性能对于企业内部使用完全够用。如果并发上来了,Milvus 可以水平扩展,加节点就行。
5. 常见问题与排查技巧实录
5.1 部署阶段高频问题速查
| 问题现象 | 根本原因 | 解决方法 |
|---|---|---|
| gcc 编译失败 | gcc 版本过低 | 装 gcc-12 并切换 |
| Python.h 找不到 | 缺 python3-dev | apt 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 的时候把原始文档的元数据(标题、作者、更新时间、部门)一起存进去,检索结果里带上这些信息,用户看到答案来源会更信任。这个改动很小,但用户体验提升很大。