1
0
0

手搓本地 RAG:向量库 + Qwen3 跑通鸢尾花问答

2026-11-05
2026-11-05
文章摘要
|

在本地(完全不出网)搭一套能用的 RAG 到底是什么体感?我的答案是:向量库 + qwen3-embed(向量化)+ 局域网大模型(问答),全部用 OpenAI 兼容 API 串起来,用 LangChain 组装,拿天池上的鸢尾花数据集(150 行经典 CSV)当试验田。 本文选用 pgvector 作为向量库的示例实现,部署见《Docker 部署 pgvector》——本文聚焦 RAG 全链路本身。

这篇文章不是「跑通一个 demo 就完」,而是每一层都讲到你能照着在自己的机器上复现。数据到模型全部局域网内完成,中途不访问任何公网。

我想做什么 / 成功判据

想验证的不是「RAG 是什么」(文档一搜一大把),而是一套完全本地的 RAG 从无到有要经过哪些真实的组件分工:

CSV 数据 → 整理成一句话文档 → qwen3-embed 向量化 → 向量库入库
        → 检索(相似度)→ 拼上下文 → 局域网 LLM 生成答案

开工前定成功判据,避免「看着像成了」:

  1. 向量库(pgvector)容器起来,CREATE EXTENSION vector 成功;

  2. 鸢尾花数据集的每一行被整理成一条可读的文本记录,并全部向量化入库(0 条漏);

  3. 给定一个自然语言问题(如「花瓣最长的鸢尾花是什么品种?」),能检索回相关样本片段;

  4. 局域网 LLM 能基于检索到的上下文回答,且引用的是库里真实存在的数据,不是凭空编造。

第 4 条是整套系统的底线,也是 RAG 区别于「裸大模型」的价值——答案必须有据可查。

环境基线:Windows(宿主,装有 Docker Desktop + Python 3.13);局域网内预先跑好 OpenAI 兼容 API 服务(vLLM 或同类网关),暴露 qwen3-embed(embedding)、qwen3(chat)两个模型。两个模型共用同一个 base_url,只是 model 名不同。

过程记录

运行方式:下面所有 Python 代码块按顺序在同一个脚本里运行,变量跨块共享(第 5 步定义的 retriever 在第 6 步使用)。不要逐块单独执行,否则跨块的变量会报 Unresolved reference。

第 0 步:数据来源——从天池 3494 拿鸢尾花

鸢尾花数据集(Iris,150 行,5 个数值特征 + 品种标签)是每个 ML 入门都见过的经典数据。我从天池数据集平台取:https://tianchi.aliyun.com/dataset/3494

天池的下载需要登录会话,没有公开直链可让脚本自动拉取。所以落地方式是:在已登录的天池页面点下载,把 CSV 存到项目 data/ 目录,再用 pandas 读取。

mkdir data
# 从天池 3494 页面手动下载 iris.csv → data/iris.csv
import pandas as pd
​
df = pd.read_csv("data/iris.csv")
print(df.shape, df.columns.tolist())
# (150, 5)  ['sepal_length', 'sepal_width', 'petal_length', 'petal_width', 'species']

150 行、5 列,字段就是鸢尾花最经典的四个测量值(花萼/花瓣的长宽)加品种。数据小到每一条输入输出都可读、可核对——这正是拿它当 RAG 试验田的原因:全链路每一步的输入输出都能看得清清楚楚,而不是丢进一个不可解释的大矩阵。

第 1 步:数据整理——把「行」变成「一句话文档」

RAG 检索的粒度是文档 / 段落,不是结构化表格。所以第一步是把每一行鸢尾花测量值压成一条可检索的文本。关键两点:把英文品种名转成中文,并把数值特征转成带单位、带语义的自然语言:

SPECIES_CN = {
    "setosa": "山鸢尾",
    "versicolor": "变色鸢尾",
    "virginica": "维吉尼亚鸢尾",
}
​
def row_to_doc(row, ridx):
    parts = [f"第 {ridx} 朵鸢尾花"]
    parts.append(f"花萼长 {row['sepal_length']:.1f} 厘米")
    parts.append(f"花萼宽 {row['sepal_width']:.1f} 厘米")
    parts.append(f"花瓣长 {row['petal_length']:.1f} 厘米")
    parts.append(f"花瓣宽 {row['petal_width']:.1f} 厘米")
    parts.append(f"品种 {SPECIES_CN.get(row['species'], row['species'])}")
    return ",".join(parts) + "。"
​
docs = []
for i, row in df.iterrows():
    docs.append({
        "text": row_to_doc(row, i),
        "metadata": {"rid": int(i), "species": row["species"]},
    })
print(docs[0])
# 第 0 朵鸢尾花,花萼长 5.1 厘米,花萼宽 3.5 厘米,花瓣长 1.4 厘米,花瓣宽 0.2 厘米,品种 山鸢尾。

这是本次实验最容易踩的地方:如果直接把整行 CSV 原样丢进向量库,中文会话模型问「花萼最宽的山鸢尾」这类问题会非常吃力——因为模型看到的是 5.1,3.5,1.4,0.2,setosa 这种没有语义的串。向量化的对象必须是「人话」,不是原始行列。 而且 species 不转中文,中文问题与英文原文的语义对齐度会变差,检索就抓不准。

第 2 步:向量库(pgvector,部署见专题)

向量库负责存 embedding 并做相似度检索。本文示例用 pgvector(PostgreSQL 的向量扩展),部署细节——Docker 镜像、开 vector 扩展、端口 5433 与本机已有 PostgreSQL 错开、连接与超时配置——不在这里展开,见《Docker 部署 pgvector》

这里只需要确认本地已经有一个能连的向量库,并用 psql 做一次真实验证(注意:vector(3) 是建表列时的类型声明,不能在 SELECT 里当函数调用;构造向量要用类型转换):

# 假设 pgvector 容器已起(部署见 docker/08-pgvector.md)
docker compose exec pgvector psql -U postgres -d postgres -c "SELECT '[1,2,3]'::vector;"

能返回 [1,2,3] 即扩展就绪。后续代码用 postgresql+psycopg://postgres:YourStrong!Passw0rd@localhost:5433/postgres 连接,落地时换成你实际部署的地址、密码和库名。

第 3 步:Python 依赖

uv init                 # 生成 pyproject.toml 并自动建 .venv
uv add langchain langchain-core langchain-openai langchain-postgres pandas "psycopg[binary]"

依赖装进 .venv,代码直接跑 uv run python xxx.py 即可,不用手动激活虚拟环境。

langchain-core 提供 ChatPromptTemplate / Document;langchain 提供 LCEL 组合能力;langchain-postgres 提供 PGVector;langchain-openai 让你能用 OpenAIEmbeddings / ChatOpenAI 指向局域网服务。embedding 和 chat 都走 OpenAI 兼容协议,所以复用同一套客户端。注意新版 langchain(1.x)已移除旧的 RetrievalQA Chain API,问答链路用 prompt.format_messages + llm.invoke 手动拼(见第 6 步)。

第 4 步:向量化 + 入库

局域网服务暴露在 http://192.168.x.x:8000(换成你实际的地址),用 OpenAI 兼容协议:

from langchain_openai import OpenAIEmbeddings
​
# ---- 向量库(embedding 服务)----
EMBED_BASE_URL = "http://192.168.x.x:8000/v1"   # embedding 服务地址
EMBED_API_KEY = "EMPTY"                          # vLLM 默认接受任意 key
EMBED_MODEL = "qwen3-embed"                      # embedding 模型名
​
embeddings = OpenAIEmbeddings(
    model=EMBED_MODEL,
    base_url=EMBED_BASE_URL,
    api_key=EMBED_API_KEY,
    # 不要显式传 dimensions:qwen3-embed 默认即 1024 维,且默认不开
    # matryoshka,传了会被 vLLM 以 400 拒绝(见踩坑第 4 条)
    check_embedding_ctx_length=False,
)

先验证本地服务能被 LangChain 拿到向量:

vec = embeddings.embed_query("花萼很宽的山鸢尾")
print(len(vec), vec[:3])   # (1024, [0.012, -0.03, 0.007, ...])

入库(用 from_documents 自动建表):

from langchain_postgres import PGVector
from langchain_core.documents import Document
​
CONN = "postgresql+psycopg://postgres:YourStrong!Passw0rd@localhost:5433/postgres"
​
records = [
    Document(
        page_content=d["text"],
        metadata=d["metadata"],
        # 固定 id:让 from_documents 的 upsert 命中同一行,重复执行不追加
        id=f"iris-{d['metadata']['rid']}",
    )
    for d in docs
]
store = PGVector.from_documents(
    records,
    embeddings,
    collection_name="iris_docs",
    connection=CONN,
)

重复执行不会堆数据:PGVector.from_documents 内部是 INSERT ... ON CONFLICT (langchain_document_id) DO UPDATE。Document 没显式 id 时,LangChain 每次现生成新 uuid 当主键,ON CONFLICT 永远不命中 → 每次都是新行。给每条文档固定 id(这里用 iris-{rid}),upsert 才按 id 覆盖,跑多少遍都是 150 行。若确实想推倒重来,PGVector(connection=CONN, collection_name="iris_docs").delete_collection() 后重建。

不是一次性、也不是逐条循环,而是分批:from_documents 内部 add_documents 按 batch_size(默认 512)切片,每批只做一次 embed_documents(一次 HTTP 把整批文本发给 embedding 服务)+ 一次批量 INSERT。150 条 < 512,一批处理完。这比手写 for d in docs: embed_query(d) 快得多——后者 N 条 = N 次 HTTP 往返 + N 次单行插入。

数据上规模时:embedding 服务吞吐是瓶颈,可以 ① 调小 batch_size(PGVector.from_documents(..., batch_size=128),单批 HTTP 体更小、不超时);② 或自己分批 add_documents,每批之间能观察进度、控制并发:

store = PGVector.from_documents(records[:64], embeddings, collection_name="iris_docs", connection=CONN)
for i in range(64, len(records), 64):
 store.add_documents(records[i:i+64])

百万级再上 HNSW 索引 + 并发 worker,那就不在本文范围了。

这里有个坑:collection_name 会为当前数据建一张固定的向量表;换数据集时,要么换 collection_name,要么先删旧集合。见踩坑部分。

第 5 步:纯向量检索

retriever = store.as_retriever(search_kwargs={"k": 8})
hits = retriever.invoke("花瓣比较大的变色鸢尾")
for h in hits:
    print(f"[{h.metadata['rid']}] {h.page_content}")

这就是「向量检索」层。150 条的小数据上,纯向量检索已经够用——它把语义最相近的样本排到前面,直接拿 Top K 拼给大模型即可。下面把问题 + 上下文交给局域网 LLM 生成答案。

第 6 步:局域网 LLM 问答(LangChain 组装)

到这里再补一个完整可独立运行的示例:它从 pgvector 里加载已经入库的 iris_docs 集合(不再向量化、不再入库),检索 → 拼上下文 → 生成 → 溯源,单独存成一个 ask.py 就能跑。注:新版 langchain(1.x)已移除旧的 RetrievalQA Chain API,这里用 retriever.invoke + prompt.format_messages + llm.invoke 手动拼链路:

# ask.py —— 完整示例:加载已有向量库 → 检索 → 生成 → 溯源
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_postgres import PGVector
from langchain_core.prompts import ChatPromptTemplate
​
# ---- 向量库(embedding 服务)----
EMBED_BASE_URL = "http://192.168.x.x:8000/v1"   # embedding 服务地址
EMBED_API_KEY = "EMPTY"                          # vLLM 默认接受任意 key
EMBED_MODEL = "qwen3-embed"                      # embedding 模型名
​
# ---- 大语言模型(chat 服务)----
LLM_BASE_URL = "http://192.168.x.x:8000/v1"      # chat 服务地址(通常与 embedding 同一个网关)
LLM_API_KEY = "EMPTY"                            # vLLM 默认接受任意 key
LLM_MODEL = "qwen3"                              # chat 模型名
​
# ---- 向量库存储(pgvector)----
CONN = "postgresql+psycopg://postgres:YourStrong!Passw0rd@localhost:5433/postgres"
COLLECTION = "iris_docs"
# -----------------------------------
​
# 1. 连回已入库的向量库(不再重新向量化/入库)
embeddings = OpenAIEmbeddings(
    model=EMBED_MODEL,
    base_url=EMBED_BASE_URL,
    api_key=EMBED_API_KEY,
    # 不要显式传 dimensions:qwen3-embed 默认 1024 维且默认不开 matryoshka(见踩坑第 3 条)
    check_embedding_ctx_length=False,
)
store = PGVector(
    connection=CONN,
    collection_name=COLLECTION,
    embeddings=embeddings,
)
retriever = store.as_retriever(search_kwargs={"k": 8})
​
# 2. 局域网 chat 模型
llm = ChatOpenAI(
    model=LLM_MODEL,
    base_url=LLM_BASE_URL,
    api_key=LLM_API_KEY,
    temperature=0.0,
)
​
prompt = ChatPromptTemplate.from_messages([
    ("system", "你是严谨的数据库问答助手。只根据给定的上下文中确实出现的信息作答,"
               "不要编造数据;若上下文不足以回答,就明说不知道。"),
    ("human", "上下文:\n{context}\n\n问题:{question}"),
])
​
# 3. 检索 → 拼上下文 → 生成
question = "花瓣比较长的鸢尾花是哪个品种?"
docs = retriever.invoke(question)                       # 检索 Top K
context = "\n\n".join(d.page_content for d in docs)     # 拼上下文
​
messages = prompt.format_messages(context=context, question=question)
answer = llm.invoke(messages).content                    # 生成
​
print(answer)
print("--- 引用来源 ---")
for d in docs:
    print(f"[{d.metadata['rid']}] {d.page_content}")

跑通后,你就拿到了一条端到端的本地 RAG:数据在向量库(pgvector)、语义在 qwen3-embed、答案出自局域网大模型,全程不出内网。

结果与数据

这一段在方案验证后回填。这里是计划要采集并展示的判据,避免「看着像成了」。

判据

期望

实际

入库条数

鸢尾花 150 条全部入库,无 0 条

待实测

向量检索耗时

单次相似度检索亚秒级(小表)

待实测

LLM 引用真实性

回答提到的数字能对上库中 150 条的真实值

待实测

将要对比的典型问题:

  • 「花瓣最长的鸢尾花是什么品种?」(数值极值 + 品种归类)

  • 「山鸢尾的平均花萼宽度大概是多少?」(品种过滤 + 数值聚合)

  • 「花瓣宽超过 2 厘米的鸢尾花有哪些?」(数值范围过滤)

如果向量检索确实把更对口的样本顶到 Top K,这个对比表格就能给出量化差异。

补充说明:鸢尾花只有 150 行,对真实用的 RAG 是「小到几乎不会检索不到」。选它不是为了证明「检索很强」,而是为了让全链路每一环的输入输出都可读、可核对——向量长什么样、检索怎么排、LLM 怎么拼上下文,在小数据上看得最清楚。跑通后再换几万行的真实文本语料,逻辑不变。

复盘

真正的方法论只有一句:RAG 的复杂度不在「调大模型」,而在数据进入向量库之前和检索之后的「姿势」。

  • 数据整理决定了天花板。 同一份 CSV,直接塞行列和整理成「人话文档」,检索质量天差地别。向量库不会替你理解 5.1,3.5,1.4,0.2,setosa,它只按你给它的语义文本排序。

  • 向量检索的 k 值决定上下文质量。 取少了漏掉真对口的样本,取多了把噪音塞进 prompt 稀释注意力。小数据上 k=8 够用,数据上规模时再按命中率调。

  • OpenAI 兼容协议是真香。 embedding、chat 两个异质模型,因为都走 OpenAI 兼容,LangChain 一套客户端全打通,局域网部署和换云端 API 只是改 base_url 的事。

  • 答案必须能溯源。 return_source_documents=True 让你每次都能看到回答引用了哪些片段。没有溯源,RAG 就退化成「看起来一本正经地瞎编」。

还没做、准备接着做的:

  • 把答案与引用来源做成一个可复用的 Python 模块 / skill,暴露「index / ask」两个入口(对应新建知识库与问答);

  • 换更大的真实文本语料(新闻 / 问答对),量化向量检索 Top-K 命中率;

  • 用 pgvector 的 HNSW 索引做一次大规模插入/检索基准(CREATE INDEX ... USING hnsw (embedding vector_cosine_ops)),对比建索引前后耗时;

  • 试一次带过滤的元数据检索(如先按 species='setosa' 过滤,再向量检索,验证「结构化约束 + 语义检索」的混合检索)。

暂时不碰的:多路召回(BM25 + 向量)、知识图谱 RAG、Agent 记忆、重排序层。先用最小闭环把「数据 → 向量 → 检索 → 生成 → 溯源」跑稳,再上一路复杂度。

踩坑与解决

1. 直接把 CSV 行列塞进向量库,检索一塌糊涂

  • 问题:用原始表格每行做 page_content,中文问题检索不到对口样本。

  • 复现:page_content = str(row.to_dict()),问「花萼很宽的山鸢尾」返回一堆乱序低相关项。

  • 根因:向量化的对象没有语义,sepal_length=5.1, ... 这种键值对不等价于「花萼长 5.1 厘米」这句话。

  • 解决:先做「行 → 一句话文档」的整理,数值字段转成带单位的自然语言,英文品种名转中文。

  • 验证:检索「花瓣比较大的变色鸢尾」,Top 命中明显更对口。

2. collection_name 与「换一个数据集」打架

  • 问题:用同一进程再喂另一份数据,报已有集合或数据混在一起。

  • 复现:PGVector.from_documents 用同一个 collection_name 二次灌入不同数据。

  • 根因:每个 collection_name 对应 pgvector 里的一张固定向量表,二次 from_documents 会追加或重建,取决于插入方式,容易串数据。

  • 解决:不同数据集用不同 collection_name(如 iris_docs / titanic_docs),或删除旧集合:PGVector(connection=CONN, collection_name="iris_docs").delete_collection()。

  • 验证:两个集合互不干扰,分别检索各自数据。

3. 给 qwen3-embed 传 dimensions,被 vLLM 以 400 拒绝

  • 问题:embed_query 抛 openai.BadRequestError: Model "qwen3-embed" does not support matryoshka representation, changing output dimensions will lead to poor results.

  • 复现:OpenAIEmbeddings(..., dimensions=1024),而局域网 vLLM 没给 qwen3-embed 开 matryoshka。

  • 根因:dimensions 参数会让客户端要求服务端把输出维度截断到指定值(matryoshka / 俄罗斯套娃表示)。qwen3-embed 默认不开这个能力,服务端直接拒绝改维度的请求。

  • 解决:不传 dimensions,用模型自身默认维度(qwen3-embed 默认即 1024 维)。pgvector 表列 vector(1024) 与之对齐即可。只有服务端显式开了 matryoshka 才需要传 dimensions。

  • 验证:len(embeddings.embed_query("...")) 得到 1024,不再报 400。

4. 重复执行 from_documents,向量库里堆出重复数据

  • 问题:脚本跑一遍入库 150 条,再跑一遍变成 300 条,检索结果重复。

  • 复现:Document(page_content=..., metadata=...) 不带 id,反复 PGVector.from_documents。

  • 根因:from_documents 内部是 INSERT ... ON CONFLICT (langchain_document_id) DO UPDATE。Document 没显式 id 时,LangChain 每次现生成新 uuid 当主键,ON CONFLICT 永远不命中,每次都插新行。

  • 解决:给每条文档固定 id(本文用 iris-{rid}),upsert 才按 id 覆盖;确需推倒重来时 delete_collection() 重建。

  • 验证:连跑三遍,SELECT count(*) FROM iris_docs 始终 150。

参考

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!

手搓本地 RAG:向量库 + Qwen3 跑通鸢尾花问答
/archives/01a1aa8c-0d3a-713c-ad3f-d3b4e9a61049
作者
隔壁老陈
发布于
2026-11-05
许可协议
CC BY-NC-SA 4.0

评论