1. 从"模型不会"到"模型会":RAG 到底解决了什么问题
先讲一个我这两年听得最多的诉求:老板说"GPT 很聪明,但一问到我们公司的业务细节就胡编",或者"我们有一堆制度文档、技术方案、项目周报,员工天天在群里问,能不能做个机器人自动答"。
这个场景的本质,是让大模型基于企业私有资料回答问题。官方一点的说法叫 RAG,即 Retrieval-Augmented Generation,检索增强生成。原理并不复杂:用户提问后,系统先从你的私有文档里检索出相关片段,再把"问题+片段"一起丢给大模型,让模型只依据这些材料作答。
那为什么不能直接把所有文档塞给模型?因为模型有上下文窗口限制,几千页 PDF 塞不进去;也因为模型的"记忆"是训练时形成的,你的私有资料它压根没见过,硬答只能靠编。RAG 把"知识存储"从模型的参数里搬到了外部的向量数据库中,模型只负责理解和组织语言,这样既解决了知识时效性问题,也解决了知识私有化问题。
这套方案特别适合下面几类人:
- 企业技术负责人,想把内部知识资产盘活,让员工、客服、售前能快速找到答案;
- 个人开发者,手里有一堆产品文档、笔记、论文,想本地搭个问答工具;
- 想入门大模型应用的技术人员,RAG 是当前落地最多、门槛最低、效果最可控的大模型应用方向,没有之一。
顺带说一句,很多人会问 RAG 和微调(Fine-tuning)的区别。我给的判断标准很简单:如果你的知识需要频繁更新,或者你要求模型引用原文、不能胡说,那就用 RAG;如果你希望模型学会某种固定的输出风格、特定领域的表述习惯,再考虑微调。RAG 改知识库像换数据库内容,微调像换模型大脑,前者适合知识类场景,后者适合能力类场景。
2. 技术选型:模型、向量库、框架怎么定,关键看"私有"两个字
这个项目标题里最重要的词是"企业私有"。也就是说,数据不能出内网,不能依赖外部 API 调用,至少核心链路要可控。所以我在选型时定的三条原则就是:模型能本地跑、组件尽量开源、链路尽量简单。
2.1 大模型选型:本地部署是底线,Ollama 是真香
私有化的第一步,是把大模型跑在自己的服务器上。本地部署模型有很多工具,实测下来最省事的是 Ollama——一条命令就能启动一个完整的模型服务,兼容 OpenAI 的 API 格式,对后续代码接入非常友好。
可选模型方面,我按硬件条件给三个档次:
| 硬件水平 | 推荐模型 | 说明 |
|---|---|---|
| 8GB 内存的笔记本 | qwen2.5:3b / qwen2.5:7b | 能跑但速度一般,适合个人验证 |
| 24GB 显存起步的服务器 | qwen2.5:14b / glm4-chat | 效果和速度的平衡点,是我目前主力 |
| 多卡或 A100 级别 | qwen2.5:72b | 效果接近闭源模型,适合严肃业务 |
实际选择时,参数量往上走,准确率和逻辑性都会有明显提升,尤其是指令遵循能力。7B 以下的模型经常出现"问东答西"的情况,用在生产环境里很容易被用户吐槽。我建议至少用 14B 级别起步,哪怕用 CPU 推理慢一点,效果也比小模型硬扛强得多。
Ollama 下载模型时走的是官方仓库,这一步在国内网络环境下不太稳定。一个变通思路是先从模型托管平台下载 GGUF 格式文件,再用 Modelfile 手动导入,整个过程不依赖任何非常规手段,就是正常的文件导入操作。具体方法官方文档有,照着做就行。
2.2 向量数据库与 Embedding 模型:被低估的两个关键组件
很多初次接触 RAG 的人把注意力都放在大模型上,结果搭出来的系统一问三不知,问题往往出在检索环节——要么向量化做得烂,要么向量库选得随意。
Embedding(向量化)模型负责把文本转成数字向量,它的质量直接决定"能不能搜到"。国内场景我推荐BAAI/bge-large-zh-v1.5或m3e-base,中文效果比 OpenAI 的 text-embedding-ada-002 好,而且是完全本地化的,不涉及任何数据外送问题。这里有一个细节:Embedding 模型的输出维度要和向量库的配置保持一致,bge-large-zh-v1.5 是 1024 维,m3e-base 是 768 维。
向量数据库的选择,我用过的方案有 Chroma、FAISS、Milvus、Qdrant 这几种,简单给大家一个选型结论:
- Chroma:零配置文件,pip 装完直接用,适合几百份文档、单机部署的轻量场景。我大部分个人项目和初期原型都用它。
- FAISS:Meta 出的库,更像是一个检索算法库,灵活性高,但需要自己管理索引的持久化。
- Milvus / Qdrant:真正的分布式向量数据库,支持多租户、高并发、权限管理,适合企业级。如果你的知识库文档超过几万份,或者有多个人同时用,直接上 Qdrant,部署起来比 Milvus 简单不少。
我给企业做方案时,第一版往往用 Chroma 跑通,等真的出现性能瓶颈再迁移到 Qdrant。没必要一开始就上重武器,RAG 系统的瓶颈通常不在向量库本身。
2.3 框架:LangChain 可以学,但别一头扎进去
LangChain 是 RAG 方向最出名的框架,封装了文档加载、切片、向量化、检索、Prompt 管理等全套流程,官方还提供了大量与 Ollama/Chroma 集成的示例,它能让一个新手在半小时内把 Demo 跑起来。
但我的真实体验是:别把 LangChain 当成项目的骨架,只把它当成工具箱。原因有两个:
- LangChain 的抽象层级太多,你要排查问题的时候,报错栈会绕好几层,很难判断是你代码的问题还是框架的坑;
- RAG 的核心逻辑其实非常简单,就是"切片、向量化、检索、拼接 Prompt、调模型",你自己写一遍,对系统的掌控力和排查问题的能力会完全不一样。
所以下面的实战部分,我将先给一套"手写最小实现"的代码,从零构建整个流程。这套代码理解了之后,你再回头用 LangChain 做工程化封装,会从容得多。
3. 从 0 到 1 实战:手写一套完整可用的 RAG 系统
下面进入正题。这个实战教程基于以下环境,你按步骤操作即可复现:
- 操作系统:Windows / Linux / macOS 均可(示例以 Linux 为主)
- Python:3.10 或更高版本
- 模型服务:Ollama,本地运行 qwen2.5:14b
- 向量化:bge-large-zh-v1.5,通过 sentence-transformers 加载本地模型
- 向量库:Chroma(pip 直接安装,无需额外服务)
3.1 环境准备:先把地基打牢
第一步,安装 Ollama 并拉取模型。Ollama 的安装包到官网下载即可,Windows 和 macOS 都有安装包,Linux 执行安装脚本后会自动注册为系统服务。
# 检查 ollama 是否安装成功 ollama --version # 拉取对话模型(以 14B 为例,大约 9GB 磁盘空间) ollama pull qwen2.5:14b # 启动模型服务(默认端口 11434) ollama serve提示:Ollama 模型默认存放在系统盘,如果你的服务器系统盘空间有限,可以设置
OLLAMA_MODELS环境变量指向大容量磁盘,再重启服务。
第二步,安装 Python 依赖。我建议新建一个干净的虚拟环境,别和系统环境混在一起,省的后面依赖冲突搞到头大。
# 创建虚拟环境 python3 -m venv rag_env source rag_env/bin/activate # Windows: rag_env\Scripts\activate # 安装核心依赖 pip install chromadb sentence-transformers langchain-community pip install pypdf docx2txt flask这里装langchain-community不是为了用它的链式调用,而是为了复用它的文档加载器,省得自己写 PDF 解析。pypdf是 PDF 解析库,docx2txt负责 Word 文档,flask用来在最后做一个 Web 交互界面。
3.2 文档加载与文本切分:RAG 效果好坏的分水岭
很多人以为 RAG 的难点在模型或向量库,其实真正的分水岭在最容易被忽略的文本切分环节。你的文档不会自动变成整齐的段落,PDF 里有页眉页脚、表格、多栏排版,Word 里还有各种样式,切分策略直接决定了检索召回的质量。
先写文档加载部分,把 PDF 和 Word 统一成"先是文本"的格式:
from langchain_community.document_loaders import PyPDFLoader, Docx2txtLoader import glob def load_documents(folder_path: str): docs = [] # 扫描目录下的 PDF 和 Word 文件 for pdf_file in glob.glob(f"{folder_path}/**/*.pdf", recursive=True): loader = PyPDFLoader(pdf_file) docs.extend(loader.load()) for docx_file in glob.glob(f"{folder_path}/**/*.docx", recursive=True): loader = Docx2txtLoader(docx_file) docs.extend(loader.load()) print(f"共加载 {len(docs)} 个文档片段") return docs切分这一步我直接给结论:长文本按固定大小切分还需要去头去尾裁剪上下文,短文本按段落切分即可。我推荐一个组合策略——先按文档结构初步切分,再把过长的块按 500~800 字符的窗口二次切分,相邻块之间保留 80~120 字符的重叠。
from langchain.text_splitter import RecursiveCharacterTextSplitter def split_documents(docs): text_splitter = RecursiveCharacterTextSplitter( chunk_size=600, # 每块最大字符数 chunk_overlap=100, # 相邻块重叠 100 字符,保证上下文衔接 separators=["\n\n", "\n", "。", "!", "?", ".", "空格", ""], # 注意:separators 顺序很重要,优先按段落切,其次按句子切 ) chunks = text_splitter.split_documents(docs) print(f"切分后共 {len(chunks)} 个文本块") return chunks为什么 chunk_size 定在 600 而不是 300 或 1000?这里有个经验平衡:太长会混入无关信息,降低检索精准度;太短则上下文不完整,模型理解不了。中文场景下 500~800 字符是检索效果最稳的区间,600 是我在多种文档类型上做对比测试后的一个稳妥值。chunk_overlap 为 100 则确保被切开的关键句不会被拦腰截断。
注意:切分后的每个块应当尽量保持语义完整。如果某个块恰好从一个句子的中间开始,检索时模型的输入就是残缺的,输出自然容易前言不搭后语。这也是
RecursiveCharacterTextSplitter的递归特性在发挥作用:它会先按段落切,段落太长再按句子切,句子太长才会按字符硬切。
3.3 向量化与存储:构建知识库的核心链路
文本切分完成后,下一步把每个块转成向量并存入向量数据库。这一步涉及两个关键选择:Embedding 模型和向量库。
我在生产环境里用bge-large-zh-v1.5,效果最稳,但模型文件有 1.3GB 左右,首次加载需要一点时间。你也可以先用 m3e-base(约 400MB)做验证,后面再换。为了避免每次启动都重新下载模型,直接用 huggingface 的本地缓存机制即可。
from sentence_transformers import SentenceTransformer import chromadb import hashlib # 加载中文向量化模型(本地路径,不涉及数据外送) embedding_model = SentenceTransformer("BAAI/bge-large-zh-v1.5") def get_embedding(text: str): # sentence-transformers 可以直接对文本生成向量 return embedding_model.encode(text).tolist() # 初始化 Chroma 客户端,数据持久化到本地目录 client = chromadb.PersistentClient(path="./chroma_db") collection = client.get_or_create_collection( name="enterprise_kb", metadata={"hnsw:space": "cosine"} # 使用余弦相似度计算距离 ) def build_knowledge_base(chunks): ids = [] embeddings = [] documents = [] metadatas = [] for idx, chunk in enumerate(chunks): # 用 hash 生成稳定 ID,避免重复写入 chunk_id = hashlib.md5(chunk.page_content.encode("utf-8")).hexdigest() ids.append(chunk_id) documents.append(chunk.page_content) # 记录来源文件,方便溯源 metadatas.append({"source": chunk.metadata.get("source", f"chunk_{idx}")}) embeddings.append(get_embedding(chunk.page_content)) collection.upsert( ids=ids, documents=documents, embeddings=embeddings, metadatas=metadatas, ) print(f"知识库构建完成,共写入 {len(documents)} 条数据")这里有两个非常关键的细节:
第一个细节,相似度算法选 cosine 而不是 L2。向量距离的度量方式直接影响检索结果。L2 距离受向量长度影响较大,对于长短不一的文本块,长度本身会影响得分排序;而余弦相似度只关注方向,不受长度干扰,在文本检索场景中效果普遍更好。
第二个细节,document 和 embedding 要分别传入。Chroma 支持在 upsert 时直接传原始文本和对应的向量。这里有个容易踩的坑:如果你只传 documents 让 Chroma 自己调用默认的 embedding 函数,那么每次启动都会重新计算一遍向量,或者用默认的英文模型——那你的中文检索基本就废了。必须预先算好 embedding 再写入。
3.4 检索与生成:把"相关知识"变成可靠回答
知识库索引完成后,RAG 系统的另一半就是"检索 + 生成"。检索的质量直接决定了生成的天花板。
先来看检索部分。这里同样有一个容易被忽略但极其关键的细节:在传给 Embedding 模型之前,先对用户问题做预处理。我遇到很多次效果不佳的情况,最后定位到的问题竟然是用户提问措辞太口语化,和知识库里的书面语表达距离太大,导致向量相似度不高。解决方法是为 Embedding 模型加一个简单的指令前缀,这也是 bge 系列模型的官方建议用法。
def search_knowledge_base(query: str, top_k: int = 5): # bge 模型建议为检索任务添加指令前缀 query_embedding = get_embedding(f"为这个句子生成表示以用于检索相关文章:{query}") results = collection.query( query_embeddings=[query_embedding], n_results=top_k, include=["documents", "metadatas", "distances"] ) return results然后进入生成环节。这里我直接用requests调用 Ollama 的 OpenAI 兼容接口,避免引入额外的 SDK。重点在于构建 Prompt 的方式。
import requests import json OLLAMA_URL = "http://localhost:11434/v1/chat/completions" MODEL_NAME = "qwen2.5:14b" def generate_answer(query: str, top_k: int = 5): # 先从知识库检索相关片段 results = search_knowledge_base(query, top_k=top_k) contexts = results["documents"][0] # 命中的文本块列表 sources = results["metadatas"][0] # 构造上下文文本 context_text = "\n\n---\n\n".join(contexts) # 构造 Prompt:明确告诉模型只依据资料回答 prompt = f"""你是一个企业知识库助手。请严格依据下面提供的资料回答用户问题。 如果资料中没有相关内容,请直接回答“资料库中未找到相关信息”,不要编造。 资料: {context_text} 用户问题:{query} 请基于资料给出简洁、准确的回答。回答中不要提及“根据资料”或引用编号。""" payload = { "model": MODEL_NAME, "messages": [ {"role": "system", "content": "你是企业知识库助手,回答必须基于给定资料。"}, {"role": "user", "content": prompt} ], "temperature": 0.1, "stream": False, } resp = requests.post(OLLAMA_URL, json=payload, timeout=120) result = resp.json() return result["choices"][0]["message"]["content"] + f"\n\n参考来源:{sources}"这里有几个生成环节的实操要点:
temperature 必须调低。知识问答类场景要的是准确、稳定,而不是发散。0.1 是一个保守但好用的值,模型会倾向于严格基于上下文作答,不会自由发挥。如果你用它写营销文案,那 temperature 可以调高到 0.8 左右,但知识库场景千万别。
Prompt 里的"不要编造"约束不是可有可无的。大模型的习惯是"有问必答",哪怕资料里没有相关信息,它也会硬着头皮编一段。加上这句话之后,模型会更倾向于承认自己不知道。当然,这不是 100% 可靠的,后面我会讲如何用后处理来兜底。
参考来源必须跟着答案一起返回。企业内部用的时候,员工会质疑答案的准确性,甚至需要追溯到原始文档做二次确认。所以在返回里有参考来源字段是刚需,不是加分项。
3.5 一个完整的可执行脚本与 Web 问答接口
把上面所有环节组合到一起,就是一个完整可运行的最小 RAG 系统。我把它整理成一个单一脚本,方便你直接跑通全流程。
# rag_system.py - 企业私有知识库完整示例 import glob import hashlib import requests import chromadb from langchain_community.document_loaders import PyPDFLoader, Docx2txtLoader from langchain.text_splitter import RecursiveCharacterTextSplitter from sentence_transformers import SentenceTransformer # 1. 全局配置 DOC_FOLDER = "./data" # 你的文档放这里 DB_PATH = "./chroma_db" # 向量库持久化目录 COLLECTION_NAME = "enterprise_kb" OLLAMA_URL = "http://localhost:11434/v1/chat/completions" MODEL_NAME = "qwen2.5:14b" EMBEDDING_MODEL = "BAAI/bge-large-zh-v1.5" # 2. 初始化组件 embedding_model = SentenceTransformer(EMBEDDING_MODEL) client = chromadb.PersistentClient(path=DB_PATH) collection = client.get_or_create_collection( name=COLLECTION_NAME, metadata={"hnsw:space": "cosine"} ) def load_and_split(): """加载文档并切分为文本块""" docs = [] for pdf_file in glob.glob(f"{DOC_FOLDER}/**/*.pdf", recursive=True): docs.extend(PyPDFLoader(pdf_file).load()) for docx_file in glob.glob(f"{DOC_FOLDER}/**/*.docx", recursive=True): docs.extend(Docx2txtLoader(docx_file).load()) splitter = RecursiveCharacterTextSplitter( chunk_size=600, chunk_overlap=100, separators=["\n\n", "\n", "。", "!", "?", ".", " ", ""], ) chunks = splitter.split_documents(docs) return chunks def build_knowledge_base(): """全量构建知识库(适用首次或者全量更新)""" chunks = load_and_split() ids, documents, metadatas, embeddings = [], [], [], [] for idx, chunk in enumerate(chunks): chunk_id = hashlib.md5(chunk.page_content.encode("utf-8")).hexdigest() ids.append(chunk_id) documents.append(chunk.page_content) metadatas.append({"source": chunk.metadata.get("source", "")}) embeddings.append(embedding_model.encode(chunk.page_content).tolist()) collection.upsert(ids=ids, documents=documents, metadatas=metadatas, embeddings=embeddings) print(f"知识库构建完成,共 {len(documents)} 条") def retrieve(query: str, top_k: int = 5): """向量检索""" query_emb = embedding_model.encode( f"为这个句子生成表示以用于检索相关文章:{query}" ).tolist() results = collection.query( query_embeddings=[query_emb], n_results=top_k, include=["documents", "metadatas"], ) return results["documents"][0], results["metadatas"][0] def ask(query: str, top_k: int = 5): """端到端问答""" contexts, sources = retrieve(query, top_k) source_texts = [s.get("source", "") for s in sources] context_text = "\n\n---\n\n".join(contexts) prompt = f"""你是一个企业知识库助手。请严格依据下面提供的资料回答用户问题。 如果资料中没有相关内容,请直接回答“资料库中未找到相关信息”,不要编造。 资料: {context_text} 用户问题:{query} 请基于资料给出简洁、准确的回答,不要提及“根据资料”等字样。""" payload = { "model": MODEL_NAME, "messages": [ {"role": "system", "content": "你是企业知识库助手,回答必须基于给定资料。"}, {"role": "user", "content": prompt}, ], "temperature": 0.1, "stream": False, } resp = requests.post(OLLAMA_URL, json=payload, timeout=120) answer = resp.json()["choices"][0]["message"]["content"] return answer, source_texts if __name__ == "__main__": import sys if len(sys.argv) > 1 and sys.argv[1] == "--build": build_knowledge_base() else: while True: q = input("\n请输入问题(输入 exit 退出):") if q.lower() == "exit": break ans, src = ask(q) print(f"\n回答:{ans}") print(f"来源:{src}")如果需要给团队用,还可以直接用 Flask 封装一个最简单的问答接口,几行代码就能搞定:
from flask import Flask, request, jsonify app = Flask(__name__) @app.route("/ask", methods=["POST"]) def handle_ask(): data = request.get_json() query = data.get("query", "") ans, src = ask(query) return jsonify({"answer": ans, "sources": src}) if __name__ == "__main__": app.run(host="0.0.0.0", port=8000)跑完上面所有代码,你就拥有了一套完整的企业私有知识库问答系统。而整套系统的运行完全不依赖任何外部 API,所有组件都是本地部署,真正做到了"数据不出内网"。
4. 从 Demo 到生产:容器化部署与知识库运维
Demo 能在自己的电脑上跑通和能在企业生产环境里稳定运行,中间还有不小的距离。这一节主要讲三件事:部署方式、知识库更新策略、权限与审计。
4.1 容器化部署:把整个系统变成可交付的产品
生产环境部署时,我不建议在裸机上一股脑装 Python 环境。直接用 Docker Compose 把 Ollama、应用服务、向量库编排起来,这样无论迁移到哪台服务器,一条命令就能拉起全部服务。
一个可用的 docker-compose.yml 长这样(基于前面代码稍作调整):
version: "3.8" services: ollama: image: ollama/ollama:latest container_name: ollama ports: - "11434:11434" volumes: - /data/ollama:/root/.ollama restart: unless-stopped rag-api: build: . container_name: rag-api ports: - "8000:8000" volumes: - /data/docs:/app/data - /data/chroma_db:/app/chroma_db depends_on: - ollama environment: OLLAMA_BASE_URL: "http://ollama:11434" EMBEDDING_MODEL: "BAAI/bge-large-zh-v1.5" restart: unless-stopped容器化之后有几个小细节要多留意:
一是模型文件的挂载。Ollama 下载的模型动辄好几个 GB,如果不挂载到宿主机目录,容器一删模型就没了,下次又得重新下载。把/root/.ollama挂载出来是一个必须操作。
二是向量库目录的持久化。Chroma 的 PersistentClient 是把数据写在本地目录里的,容器重启不能丢,所以也需要挂载卷。
三是首次启动时需要注意依赖加载顺序。Ollama 服务启动后,模型需要先被拉取到本地才能响应请求。建议在 rag-api 启动脚本里加一个健康检查循环,确认 Ollama 已经就绪后再加载知识库,避免出现连接拒绝的报错。
4.2 知识库更新与权限控制:容易被忽略的两件大事
知识库不是建完就万事大吉,它是需要持续供养的活系统。企业里的新制度、新方案、新项目成果,都必须定期同步进知识库,否则系统会慢慢变成"过时问答机"。
更新策略我推荐增量 upsert 而不是全量重建。Chroma 的upsert天然支持按 ID 覆盖,只要 chunk 内容没变,ID 就是稳定的,重复执行不会产生重复数据。可以写一个定时任务,每天扫描一次文档目录,对新文件做加载和向量化,对已存在的文件跳过。
# 定时增量更新的简化逻辑 def incremental_update(): chunks = load_and_split() need_add = [] # 计算所有 chunk 的 ID for chunk in chunks: chunk_id = hashlib.md5(chunk.page_content.encode("utf-8")).hexdigest() existing = collection.get(ids=[chunk_id]) if not existing["ids"]: # 不存在就新增 need_add.append((chunk_id, chunk)) # 只对新增部分做向量化 for chunk_id, chunk in need_add: collection.upsert( ids=[chunk_id], documents=[chunk.page_content], embeddings=[embedding_model.encode(chunk.page_content).tolist()], metadatas=[{"source": chunk.metadata.get("source", "")}], ) print(f"本次新增 {len(need_add)} 条,其余未变动")权限控制方面,企业场景下我会建议这样分级:
- 普通员工:只能通过 Web 界面提问,不能查看向量库原始内容,不能触发知识库更新;
- 知识库管理员:可以上传文档、触发增量更新、查看更新日志;
- 系统运维:可以管理向量数据库本身、备份恢复、监控日志。
如果系统接入 SSO(单点登录),直接在 API 层做角色校验就行。这块在企业落地时是必须做好的,因为知识库里存的是公司核心资料,权限泄漏等于资料泄漏。
还有一件事很多人会忽略:审计日志。系统上线后,谁问了什么问题、模型返回了什么答案、引用了哪些文档,这些都应该记录下来。一方面是出了问题能回溯,另一方面也是知识产权保护的证据链。
5. 实践中的坑与排查:RAG 系统效果不好,99% 的问题出在这里
这部分我想把实操中遇到的最典型问题罗列出来,这些内容在我做过的项目里几乎必踩,帮大家提前排掉。
5.1 召回效果差的排查清单
先给一个观点:大多数 RAG 系统效果不佳,问题根本不在于模型,而是检索阶段就没把正确的资料捞出来。模型再聪明,喂给它的资料是错的,它也只能一本正经地胡说。遇到"答非所问"或者"回答笼统"的情况,我建议按下面的顺序排查:
第一步看切分质量。把你的文档切片随机抽 10 条出来读一遍,看每条切片是不是一个语义完整的片段。如果发现切片经常在句子中间断开、大量内容是页眉页脚、或者一个切片里混了完全无关的几个主题,那先调整切分策略,而不是去调模型。
第二步看召回数量。top_k设置在 3~5 是经验值,但也要看你的 chunk 大小。chunk 越小,需要的 top_k 越大;chunk 越大,top_k 可以越小。如果召回结果里混入了大量无关片段,问题可能出在向量相似度上。
第三步看相关性排序。Chroma 默认按距离升序返回结果,距离越小越相关。打印一下distances看看,如果前几名的距离差距不明显,说明知识库里本身就没有强相关的资料,或者切分方式让关键信息被稀释了。
第四步看 Embedding 模型是否匹配。国内文档必须用中文 Embedding 模型,这一点我重复多少次都不为过。如果用默认的 all-MiniLM-L6-v2,中文内容基本等于瞎检索。另外注意 bge 系列在向量化时不要截断文本,bge-large 支持 512 token,中文大约 300~400 字,刚好覆盖我们的 chunk 大小。
5.2 关于图片、表格与多轮对话:RAG 的边界到底在哪
我经常被问到两个问题:"RAG 知识库能存储图片吗?"和"RAG 能做多轮对话吗?"这需要把 RAG 的边界讲清楚。
第一个问题,能不能存图片。直接回答:传统的 RAG 流程里,图片本身不能被直接检索,因为 Embedding 模型处理的是文本。但企业里大量知识是以图片形式存在的,比如产品截图、流程图、拍照的合同页。我的处理思路是两条路:
- 图片里的文字,用 OCR 工具(如 PaddleOCR)把文字提取出来,再作为文本 chunk 存入知识库,这样检索和问答都正常;
- 图片本身,如果确实需要原始图片展示,可以把文件路径当作元数据存在 chunk 里,回答时返回路径,让前端展示原图。
这在企业内部系统里是完全够用的。真正多模态的"看图问答"现阶段还不适合私有化场景,因为多模态模型的资源消耗高出一个量级,性价比不划算。
第二个问题,多轮对话。纯 RAG 的每次问答之间是互相独立的,它没有"记忆"。上一轮你问了"报销流程是什么",这一轮你追问"需要什么材料",模型并不知道"这个"指的是报销流程。解决办法也很简单,就是用对话历史来重写用户提问:
def rewrite_query_with_history(history: list[dict], current_query: str) -> str: """把最近两轮对话和当前问题一起交给模型,让模型改写为带完整语义的独立问题""" messages = [{"role": "system", "content": "你是查询改写助手,根据对话历史把当前问题改写为独立完整的问题。"}] messages.extend(history[-2:]) messages.append({"role": "user", "content": f"请改写当前问题:{current_query}"}) resp = requests.post(OLLAMA_URL, json={ "model": MODEL_NAME, "messages": messages, "temperature": 0, }) return resp.json()["choices"][0]["message"]["content"]改写后再做向量检索,上下文就完整了。这本质上是在 RAG 外面套了一层对话管理,在企业场景中效果非常明显。
5.3 RAG、KG 与结构知识库:到底怎么选
最后聊一个选型层面的问题。我发现很多人搞不清 RAG(检索增强生成)、KG(知识图谱)和传统关系型数据库到底各自解决什么问题。
关系型数据库 + 查询适合的是"确定性查询":员工查"去年 Q3 的销售额是多少",这是明确的结构化问题,答案在数据库里有精确字段,通过 SQL 查询拿到即可。
知识图谱适合的是"关系推理":比如"A 部门和 B 部门的项目有哪些关联?谁为谁提供数据?"这类问题需要沿着实体关系做推理和跳转,RAG 做不了,因为向量检索只能找相似文本,没法做多跳推理。
RAG适合的是"语义检索 + 开放问答":比如"报销流程是什么"、"X 项目的技术架构怎么设计的",这些知识往往散落在非结构化文档里,没有固定的字段可以查询,只能通过语义匹配找到相关段落。
三者的关系不是替代,而是互补。我给企业的建议是:先用 RAG 做非结构化文档问答,这是覆盖面最广、见效最快的场景。当用户开始频繁问"谁和谁有什么关系"这类问题时,再考虑在核心业务数据上构建知识图谱,两者共存即可。
6. 最后补充一些个人经验
这个项目从最初的简单原型,到后来在企业内部真正落地,我最大的体会是:RAG 的技术门槛不高,难的是把每个细节打磨到位。切分的粒度、Embedding 模型的选择、Prompt 的约束、上下文窗口的管理,任何一个环节偷懒,最后的结果就是模型给你一本正经地胡说八道,而用户会因此对整个系统失去信任。
还有一个小技巧想分享。如果你希望模型的回答"更可信",可以考虑在生成答案之前加上一个"相关性判定"步骤:先判断检索回来的文本块和用户问题是否真的相关,如果最高相似度低于某个阈值(比如 0.35),就直接返回"未在知识库中找到相关信息",而不是硬让模型生成答案。这能极大减少胡编乱造的情况,代价只是多一次向量计算。
另外如果你以后要接入 LangChain 或其他框架,我的建议是让框架去处理协议对接、格式转换这类繁琐问题,但核心的切分策略、Prompt 设计、检索逻辑一定要自己掌控,这样才能灵活调优。框架是为你服务的,不是让你为框架服务的。