1. 项目缘起与整体架构思路
1.1 为什么要在 Ubuntu 上自建企业知识库
先说背景。我所在团队维护着一个大约 6.4 万份文档规模的企业知识库,涵盖产品手册、售后工单、内部规范、技术方案、合同模板等。之前一直用某 SaaS 知识库,按席位收费,一年下来成本不低,而且数据不在自己手里,检索效果也没法深度调优。后来决定迁到自建方案,核心诉求就三条:数据不出内网、检索质量可控、成本能压到一台机器扛住。
选 Ubuntu 作为底座,理由很直接。一是生态成熟,Docker、Python、CUDA 这些依赖装起来最省心;二是长期支持版本稳定,22.04 LTS 和 24.04 LTS 都能拿到五年安全更新;三是团队里运维同学对它的熟悉度最高,出问题排查快。至于 Halogen,它是我在对比了几套开源知识库方案后选定的检索层框架,核心能力是把 embedding 向量检索和关键词检索做混合召回,再叠一层重排,对中文长文档的匹配度提升很明显。
这套组合最终跑下来的效果:6.4 万份文档全量入库,单次查询 P95 延迟控制在 800ms 以内,召回准确率相比原来的纯关键词方案提升了大约 40%。硬件就是一台 32 核 128G 内存、带一张 24G 显存显卡的机器,没有上集群。
1.2 整体架构分层拆解
整个系统我分成四层来理解,这样排查问题时能快速定位是哪一层出的毛病。
数据接入层负责把各种格式的原始文档解析成纯文本。PDF、Word、Excel、PPT、Markdown、HTML 都要处理,还要做清洗,去掉页眉页脚、乱码、重复段落。这一层我用的是 Python 脚本加一批解析库,跑成批处理任务。
向量化层是核心。文档切块之后,每一块都要过 embedding 模型转成向量。这里模型选型直接决定检索质量,我试过好几个,后面会详细说。向量维度、切块大小、重叠长度这些参数都需要反复调。
存储与检索层就是 Halogen 发挥作用的地方。它同时维护一个向量索引和一个倒排索引,查询时两路召回再融合。向量索引我用的 HNSW,倒排索引基于 Lucene 那套思路。
应用层是对外的 API 和前端。内部系统通过 REST 接口调用,返回带原文片段和来源链接的结果。
提示:分层不是为了好看,是为了出问题时能快速二分定位。比如检索结果差,先看是向量化层切块不合理,还是检索层融合权重没调好,别一上来就怀疑模型。
1.3 方案选型背后的取舍逻辑
为什么不用现成的 SaaS?前面说了,成本和数据主权。为什么不用更重的方案比如带完整 RAG 流水线的平台?因为我们的场景相对单纯,就是检索问答,不需要复杂的 agent 编排,引入太重的东西反而增加维护负担。
Halogen 相比其他开源检索框架的优势在于它对混合检索的支持是原生的,不用自己拼两套系统。而且它的索引构建支持增量更新,6.4 万份文档不需要每次全量重建,新增文档几分钟就能进索引。这一点在文档持续更新的企业场景里非常关键。
embedding 模型这块我踩过坑。一开始图省事用了某个通用小模型,结果中文语义匹配一塌糊涂,问“怎么退货”匹配不到“退款流程”。后来换成中文优化过的大模型,效果立竿见影,但显存占用和推理速度要重新平衡。这个取舍过程后面细讲。
2. 环境准备与依赖安装的实操细节
2.1 Ubuntu 系统层面的准备工作
我用的镜像是 Ubuntu 22.04.4 LTS 桌面版,其实服务器版更合适,但当时手头只有桌面版镜像就先用了。装系统时有个坑要提醒:如果你在虚拟机里装,显卡直通和显存分配要提前规划好,不然后面跑 embedding 模型会非常慢。
系统装完第一件事是换源。默认源在国内访问速度不稳定,换成国内镜像源之后 apt 安装快很多。具体操作是编辑/etc/apt/sources.list,把里面的地址替换掉,然后sudo apt update。这一步看着简单,但源没换对会导致后面装 Docker、装驱动各种超时。
接着装基础工具链。build-essential、git、curl、wget、vim这些是必须的。Python 环境我建议用系统自带的 3.10,然后配一个 venv 虚拟环境,不要直接往系统 Python 里装包,不然后面依赖冲突很难受。
sudo apt update sudo apt install -y build-essential git curl wget vim python3-pip python3-venv python3 -m venv /opt/kb-env source /opt/kb-env/bin/activate显卡驱动这块单独说。如果你要用 GPU 加速 embedding 推理,NVIDIA 驱动和 CUDA 工具包必须装对版本。我遇到过驱动装完nvidia-smi能显示但 Python 里torch.cuda.is_available()返回 False 的情况,原因是驱动版本和 CUDA 版本不匹配。稳妥做法是先确定你要用的深度学习框架需要哪个 CUDA 版本,再倒推装对应的驱动。
注意:卸载显卡驱动时不要用
apt autoremove一把梭,容易把桌面环境依赖一起删掉导致进不去系统。用sudo apt-get purge nvidia-*精确卸载更安全。
2.2 Docker 与容器化部署环境
虽然可以直接裸机跑,但我强烈建议用 Docker。原因有三:依赖隔离干净、迁移方便、出问题重建快。装 Docker 的步骤官方文档写得很清楚,这里只说几个容易出错的点。
安装完成后要把当前用户加入 docker 组,否则每次都要 sudo。命令是sudo usermod -aG docker $USER,然后重新登录生效。另外 Docker 默认的镜像存储位置在/var/lib/docker,如果系统盘小,要提前改到数据盘,不然镜像和容器数据很快把根分区撑满。
sudo mkdir -p /data/docker sudo vim /etc/docker/daemon.json在 daemon.json 里写入:
{ "data-root": "/data/docker", "registry-mirrors": ["你的镜像加速地址"] }然后sudo systemctl restart docker。这一步做完可以用docker info确认存储路径已经改过去。
2.3 Halogen 及其依赖的安装
Halogen 本身是 Python 包,通过 pip 安装。但它依赖一些底层库,比如用于向量检索的 faiss、用于文本处理的 jieba 分词、用于文档解析的 pypdf 和 python-docx。这些依赖有的需要编译,所以前面装 build-essential 是必要的。
pip install halogen-retrieval faiss-cpu jieba pypdf python-docx如果你要用 GPU 版 faiss,把faiss-cpu换成faiss-gpu,但要注意 CUDA 版本对应关系。我一开始装了 GPU 版结果和系统 CUDA 不匹配,跑起来直接段错误,换回 CPU 版反而稳定。后来查清楚是 faiss-gpu 对 CUDA 小版本很敏感,索性向量检索用 CPU,embedding 推理用 GPU,各司其职。
embedding 模型我最终选的是一个中文优化过的开源模型,参数量在 1B 左右,24G 显存能轻松放下,单条推理延迟在 30ms 上下。模型文件提前下载好放到本地目录,不要每次从网络拉,内网环境根本拉不动。
3. 知识库数据流水线的搭建过程
3.1 文档解析与清洗的关键步骤
6.4 万份文档,格式五花八门。PDF 占大头,大概四万份,剩下的是 Word、Excel、Markdown 和 HTML。解析这步看着枯燥,但直接决定后面检索质量的上限。垃圾进垃圾出,解析不干净,后面模型再好也白搭。
PDF 解析我用 pypdf 打底,遇到扫描件就上 OCR。这里有个经验:不要指望一个库解决所有 PDF。有的 PDF 是文字层完整的,pypdf 直接抽就行;有的是图片扫描的,必须走 OCR;还有的是排版复杂的表格,抽出来全是乱序。我的做法是先跑一遍 pypdf,统计每份文档抽出的字符数,低于阈值的标记为疑似扫描件,再走 OCR 流程。
清洗规则我列了一个清单,按优先级处理:
- 去掉连续重复行,很多 PDF 每页页眉页脚都一样,不去掉会污染向量
- 合并被硬换行切断的句子,中文文档里这个特别常见
- 过滤纯符号行和空白行
- 统一全角半角标点
- 去掉文档里的水印文字,比如“内部资料请勿外传”这种
import re def clean_text(text): lines = text.split('\n') seen = set() result = [] for line in lines: line = line.strip() if not line or line in seen: continue if re.fullmatch(r'[\W_]+', line): continue seen.add(line) result.append(line) merged = ''.join(result) merged = re.sub(r'\s+', ' ', merged) return merged这段代码逻辑简单但实用。seen集合去重能干掉大量重复页眉,正则过滤纯符号行,最后把换行合并成空格。实测下来,清洗前后同一份文档的检索命中率能差出两成。
3.2 文本切块策略与参数计算
切块是知识库检索里最容易被忽视但影响最大的环节。切太大,一个块里混了好几个主题,检索时向量被平均掉,匹配不准;切太小,上下文丢失,模型理解不了完整语义。
我的策略是按语义边界切,优先在段落、标题、列表项处断开,而不是机械地按字数切。具体参数上,我设定的目标块大小是 500 个中文字符,重叠 80 个字符。为什么是这两个数?500 字大约对应一段完整的论述,能覆盖一个独立知识点;80 字重叠是为了防止关键信息正好卡在切分点上被切断。
对于结构化文档,比如带标题层级的 Markdown 或 Word,我会把标题路径拼到每个块的头部。比如一个块属于“第三章 售后政策 > 3.2 退货流程”,那这个块的文本开头就加上这个路径。这样检索时标题信息也参与匹配,效果提升明显。
def split_by_semantic(text, max_len=500, overlap=80): paragraphs = text.split('\n') chunks = [] current = '' for para in paragraphs: if len(current) + len(para) <= max_len: current += para + '\n' else: if current: chunks.append(current.strip()) current = current[-overlap:] + para + '\n' if current else para + '\n' if current.strip(): chunks.append(current.strip()) return chunks这个切块函数是简化版,实际生产里我还加了标题识别和表格特殊处理。表格我单独抽出来转成 Markdown 格式再切,不跟正文混在一起。
3.3 向量化批处理与入库流程
切完块就是批量过 embedding 模型。6.4 万份文档切下来大概 38 万个块,这个量级必须批处理。我设的 batch size 是 64,太大显存扛不住,太小吞吐上不去。38 万个块按 64 一批,大概 6000 批,单批推理 200ms 左右,全量跑完差不多 20 分钟。
入库时要注意向量和原文的对应关系。每个块我存四个字段:块 ID、原文文本、向量、元数据(来源文档、标题路径、页码)。元数据在展示结果时用来定位原文,非常重要。
import numpy as np from halogen import Index index = Index(dim=1024, metric='cosine') def batch_embed(texts, model, batch_size=64): vectors = [] for i in range(0, len(texts), batch_size): batch = texts[i:i+batch_size] vecs = model.encode(batch, normalize_embeddings=True) vectors.append(vecs) return np.vstack(vectors)向量归一化这步别省。归一化之后余弦相似度计算可以简化成点积,检索速度快不少,而且数值稳定性更好。
4. 检索效果调优与常见问题排查
4.1 混合检索权重调参实录
Halogen 的混合检索是把向量召回和关键词召回的结果融合。融合方式我用的加权求和,向量权重 0.7,关键词权重 0.3。这个比例不是拍脑袋定的,是拿一批标注好的查询-文档对跑出来的。
调参过程是这样的:先准备 200 条真实用户查询,每条人工标注出最相关的文档。然后网格搜索权重组合,从 0.5/0.5 到 0.9/0.1 逐个试,看哪个组合的召回率最高。结果 0.7/0.3 在准确率和召回率上最平衡。纯向量在语义匹配上强,但遇到专有名词、型号代码这种精确匹配就拉胯,关键词召回正好补上这块。
提示:权重不是一劳永逸的。如果你的知识库专有名词多,关键词权重可以调高到 0.4;如果是口语化查询多,向量权重调到 0.8 更合适。
4.2 常见问题速查表
实际跑起来遇到的问题不少,我整理成表格方便对照排查。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 检索结果完全不相关 | embedding 模型加载失败或维度不匹配 | 检查模型输出维度与索引维度是否一致 | 重新确认模型配置,重建索引 |
| 部分文档搜不到 | 解析阶段文本为空 | 抽查该文档解析后的字符数 | 走 OCR 流程或换解析库 |
| 查询延迟突然飙升 | 索引未加载进内存 | 查看进程内存占用 | 增大内存或优化索引结构 |
| 中文查询匹配差 | 分词器未正确配置 | 打印分词结果 | 换用中文分词并加自定义词典 |
| 新增文档不生效 | 增量索引未触发 | 检查索引更新日志 | 手动触发或修复增量逻辑 |
| 显存溢出 | batch size 过大 | 监控显存占用 | 调小 batch size 或换 CPU 推理 |
4.3 几个踩过的坑和独家技巧
第一个坑是编码问题。有些 Word 文档里混了 GBK 编码的字符,Python 默认 UTF-8 读取直接报错。我的处理是读取时加errors='ignore',虽然会丢个别字符,但总比整个文档解析失败强。
第二个坑是索引重建时间。全量重建 38 万个块的索引要将近一小时,期间服务不可用。后来改成双索引切换:新索引在后台建好,建完原子切换,服务不中断。这个改动对生产环境太重要了。
第三个技巧是关于查询扩展。用户输入往往很短,比如“退货”,直接检索召回有限。我在查询前加了一步同义词扩展,“退货”扩展成“退货 退款 退换”,召回率提升明显。同义词表是人工维护的,针对业务术语补充,比通用同义词库准得多。
第四个经验是结果重排。初筛召回 top 50,再用一个小的交叉编码器重排取 top 10。这一步增加约 100ms 延迟,但准确率提升非常值。交叉编码器我用的是一个小参数量模型,显存占用小,推理快。
5. 性能压测与生产环境稳定性保障
5.1 压测方案与关键指标
上线前我做了完整压测。工具用的 locust,模拟 50 并发持续查询。关键指标盯三个:QPS、P95 延迟、错误率。
压测结果:单机 QPS 稳定在 120 左右,P95 延迟 780ms,错误率 0。这个成绩对 6.4 万文档规模来说够用了。瓶颈主要在 embedding 推理,占了总延迟的六成。如果 QPS 要求更高,可以加一张显卡做模型并行,或者把 embedding 结果缓存起来,相同查询直接命中缓存。
缓存这块我做了两级:一级是查询结果缓存,相同 query 五分钟内直接返回;二级是 embedding 缓存,相同文本块不重复计算。两级缓存加起来,重复查询的延迟降到 50ms 以内。
5.2 监控与告警配置
生产环境不能没有监控。我配了三个维度的监控:系统层看 CPU、内存、显存、磁盘;应用层看 QPS、延迟、错误率;业务层看检索命中率和用户反馈。
告警阈值设定:P95 延迟超过 1.5 秒告警,错误率超过 1% 告警,显存占用超过 90% 告警。告警通道走的内部 IM,值班同学能第一时间收到。
日志我分了三个级别:INFO 记录每次查询的基本信息,WARNING 记录慢查询和空结果,ERROR 记录异常。日志按天切割,保留 30 天。排查问题时先看 WARNING 日志,慢查询和空结果往往能暴露索引或模型的问题。
5.3 数据更新与索引维护策略
企业知识库不是静态的,每天都有新文档进来,旧文档作废。我的更新策略是:新增文档走增量索引,几分钟内可检索;文档删除走软删除,标记失效但不立即从索引移除,每天凌晨做一次索引整理,真正清理失效数据。
为什么软删除?因为立即从 HNSW 索引里删数据代价很高,而且容易影响索引结构。软删除加定期整理,兼顾了实时性和性能。
索引整理任务我写成了一个定时脚本,每天凌晨 3 点跑。整理内容包括:清理软删除数据、合并小索引段、重建统计信息。整理期间服务正常可用,只是新文档入库会排队到整理结束。
6. 成本核算与后续扩展方向
6.1 硬件与时间成本盘点
整套方案的成本我算过一笔账。硬件一次性投入主要是那台带显卡的服务器,按市场价大概三万出头。电费按满载 500W 算,一天 12 度电,一个月电费不到 200 块。人力成本主要是搭建和调优,我一个人前后花了大概两周,其中一半时间在调检索效果。
对比原来的 SaaS 方案,按 100 个席位算,一年费用够买两台服务器。自建方案跑满一年就回本,之后都是净省。当然自建有维护成本,但对我们这种有运维能力的团队来说,这点维护量可以接受。
6.2 后续可以扩展的方向
现在这套系统跑得挺稳,但还有优化空间。第一个方向是加多路召回,除了向量和关键词,再加一路基于文档结构的召回,比如按标题层级匹配。第二个方向是引入查询理解,把用户的口语化问题改写成更适合检索的形式。第三个方向是做个性化排序,不同部门的人搜同样的词,期望结果可能不一样,可以根据用户历史行为调整排序。
还有一个想法是把 embedding 模型换成更小的蒸馏版本,推理速度能再快一倍,精度损失控制在 3% 以内。这个需要再跑一轮评测确认。
我个人在实际操作中的体会是,知识库这东西,搭建只是开始,真正的功夫在持续调优。检索效果没有最好只有更好,每次用户反馈“搜不到”都是一次优化机会。把反馈收集起来,定期分析,慢慢就能把匹配度磨上去。另外别迷信大模型,合适的才是最好的,小模型调好了照样能打。