LangChain 学习笔记 · Day 10 向量化与存储(Qwen Embedding + Chroma)
掌握将切分后的文档向量化并写入 Chroma,使用 Qwen text-embedding-v2 与 MMR 检索搭建最小 RAG 闭环,含实战与调参。
18 分钟阅读
LangChainPythonEmbeddingsChromaRetrieverMMRDashScopeQwenRAG
🎯 学习目标
- 理解 Embeddings(向量化) 的作用与工作流位置(RAG 索引阶段)。
- 掌握 Chroma(本地向量库) 的落盘与加载(
langchain_chroma新接口)。 - 会用 Qwen
text-embedding-v2创建向量(DashScope 原生直连,避免兼容层踩坑)。 - 比较 kNN vs. MMR 检索,能根据任务选择并调参。
- 能基于检索结果做 可溯源 的生成式回答(引用标注)。
🧭 知识地图(和 Day 5~9 的衔接)
- Day 5~7:Memory 记住「会话历史」(短期/长期摘要)。
- Day 8:Loader 读入外部文档为
Document(page_content, metadata)。 - Day 9:TextSplitter 把大文档切成块(chunk/overlap/分隔符)。
- Day 10(今天):把「文档块 → 向量」并存入 向量库,用于后续检索。
直觉:Memory 是“对话记忆”;向量库存“知识库切片的语义向量”。
⚙️ 环境与依赖
pip install -U langchain langchain-community langchain-text-splitters \
langchain-chroma chromadb dashscope langchain-openai python-dotenv
环境变量(PowerShell 示例)
# Qwen Embedding(DashScope 原生直连)
setx DASHSCOPE_API_KEY "你的_qwen_key"
# 可选:Kimi 生成式回答(OpenAI 协议兼容)
setx OPENAI_API_KEY "你的_kimi_key"
setx OPENAI_BASE_URL "https://api.moonshot.cn/v1"
✅ 实战 1:最小自测(Qwen 向量是否打通)
# day10_embedding_qwen_native.py
import os
from dotenv import load_dotenv; load_dotenv()
from langchain_community.embeddings import DashScopeEmbeddings
emb = DashScopeEmbeddings(model="text-embedding-v2",
dashscope_api_key=os.getenv("DASHSCOPE_API_KEY"))
vec = emb.embed_query("这是一个测试文本")
print("dim =", len(vec), "first8 =", vec[:8])
期望输出一个维度(常见 1536/1024)。这一步确保不走兼容层,规避
contents报错。
🗃️ 实战 2:Qwen Embedding + Chroma 入库 / 检索(MMR)
将 Day1~Day9 的笔记合并到
data/notes.txt(UTF-8)。
# file: day10_chroma_qwen.py
# -*- coding: utf-8 -*-
# 用法:
# 1) build:加载 data/notes.txt → 切分 → Qwen 向量 → 写入 Chroma(持久化)
# 2) ask :MMR 检索(invoke)→ 可选用 Kimi 生成回答并带引用
import os, argparse
from pathlib import Path
from typing import List
from dotenv import load_dotenv; load_dotenv()
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_chroma import Chroma # ← 新包
from langchain_community.embeddings import DashScopeEmbeddings # ← Qwen 原生
# 可选:生成式回答(Kimi)
try:
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
HAS_KIMI = True
except Exception:
HAS_KIMI = False
# --- 配置 ---
DATA_PATH = Path("data/notes.txt") # 改成你的实际路径
CHROMA_DIR = "chroma_qwen_day10"
COLLECTION = "notes_day10_qwen"
QWEN_EMBED_MODEL = "text-embedding-v2"
GEN_MODEL = os.getenv("DAY10_GEN_MODEL", "kimi-k2-0711-preview")
def _require_file(p: Path):
if not p.exists():
raise FileNotFoundError(f"未发现数据文件:{p}(请创建或修改 DATA_PATH)")
def _build_splitter():
return RecursiveCharacterTextSplitter(
chunk_size=600, chunk_overlap=120,
separators=["\\n\\n", "\\n", "。", "!", "?", ",", " ", ""],
add_start_index=True, length_function=len,
)
def _qwen_embedder():
api_key = os.getenv("DASHSCOPE_API_KEY")
if not api_key:
raise EnvironmentError("缺少 DASHSCOPE_API_KEY。")
return DashScopeEmbeddings(model=QWEN_EMBED_MODEL, dashscope_api_key=api_key)
def cmd_build():
_require_file(DATA_PATH)
docs = TextLoader(str(DATA_PATH), encoding="utf-8").load()
print(f"✅ 加载完成:{len(docs)} 文档。示例 metadata:", docs[0].metadata)
splitter = _build_splitter()
chunks = splitter.split_documents(docs)
print(f"✅ 切分完成:{len(chunks)} 个 chunk。示例:{chunks[0].page_content[:60]!r}")
emb = _qwen_embedder()
Chroma.from_documents(
documents=chunks,
embedding=emb,
persist_directory=CHROMA_DIR,
collection_name=COLLECTION,
)
# langchain_chroma 无需 persist()
print(f"🎉 向量库写入 ./{CHROMA_DIR}(collection={COLLECTION}),共 {len(chunks)} 个向量。")
def _load_db() -> Chroma:
return Chroma(
persist_directory=CHROMA_DIR,
embedding_function=_qwen_embedder(),
collection_name=COLLECTION,
)
def _gen_with_kimi(context: str, question: str) -> str:
if not HAS_KIMI:
return "(未安装 langchain-openai,跳过生成式回答)"
llm = ChatOpenAI(model=GEN_MODEL, temperature=0.2)
prompt = ChatPromptTemplate.from_messages([
("system", "你是严谨的中文助手。严格基于提供的上下文回答;若无法确定,请说“根据提供的上下文无法确定”。回答结尾用【[n]】列出引用编号。"),
("user", "问题:{q}\\n\\n上下文片段(编号从1开始):\\n{ctx}")
])
chain = prompt | llm | StrOutputParser()
return chain.invoke({"q": question, "ctx": context})
def cmd_ask(question: str, k: int = 4, generate: bool = True, use_mmr: bool = True):
db = _load_db()
if use_mmr:
retriever = db.as_retriever(search_type="mmr",
search_kwargs={"k": k, "fetch_k": 24, "lambda_mult": 0.5})
else:
retriever = db.as_retriever(search_type="similarity",
search_kwargs={"k": k})
results = retriever.invoke(question) # 新推荐写法
print(f"🔎 检索方式:{'MMR' if use_mmr else 'Similarity'} | Top-{k}")
numbered_ctx_lines: List[str] = []
for i, d in enumerate(results, 1):
src = d.metadata.get("source", "unknown")
preview = d.page_content[:120].replace("\\n", " ")
print(f"{i}. {src} | {preview}…")
numbered_ctx_lines.append(f"[{i}] 来源: {src}\\n{d.page_content}")
if generate:
print("\\n💬 生成式回答:")
ctx = "\\n\\n---\\n\\n".join(numbered_ctx_lines)
print(_gen_with_kimi(ctx, question))
def main():
ap = argparse.ArgumentParser(description="Day10 · Qwen Embedding + Chroma")
sub = ap.add_subparsers(dest="cmd")
sub.add_parser("build", help="构建向量库")
p_ask = sub.add_parser("ask", help="检索/回答")
p_ask.add_argument("question", type=str)
p_ask.add_argument("--k", type=int, default=4)
p_ask.add_argument("--no-gen", action="store_true")
p_ask.add_argument("--no-mmr", action="store_true")
args = ap.parse_args()
if args.cmd == "build":
cmd_build()
elif args.cmd == "ask":
cmd_ask(args.question, k=args.k, generate=not args.no_gen, use_mmr=not args.no_mmr)
else:
ap.print_help()
if __name__ == "__main__":
main()
🔧 调参与实践指南
- 切分:
chunk_size=600~1200,chunk_overlap=10%~20%;中文加入。!?,分隔符更自然。 - MMR:
lambda_mult=0.5起步;更分散 → 降到 0.3;更集中 → 升到 0.7。fetch_k设为4~8×k。 - k 值:2~8 常用;更大全面但上下文更嘈杂。
- 集合管理:用
collection_name区分不同版本/语料。 - 增量更新:
db.add_documents([...])会自动落盘;重建请删除目录后build。
🧯 常见问题与踩坑
contents is neither str…:走了 OpenAI 兼容层导致请求被当成 responses/chat;改用DashScopeEmbeddings原生直连。persist()报错:langchain_chroma不再需要persist(),传persist_directory即自动落盘。- 弃用警告:
get_relevant_documents()→ 用retriever.invoke()。 - 编码/路径:Windows 建议
encoding="utf-8";相对路径相对于启动脚本的工作目录。 - 生成与向量分离:Kimi 负责生成,Qwen 负责向量;在代码中显式传入不同 key/端点避免串台。
🧪 今日练习
- 用同一问题各跑一次 Similarity 与 MMR,对比 Top-k 的“分散度”和答案完整性。
- 调整
chunk_size/overlap/k/lambda_mult,记录答案变化。 - 给回答增加“来源页码/标题”的展示(可在
metadata里提前写入)。 - 挑战:实现“增量入库脚本”(新增文档时只向量化新增部分)。
🧾 TL;DR
- 向量化是 RAG 的索引;切分先行、嵌入为核、Chroma 落盘。
- Qwen
text-embedding-v2+DashScopeEmbeddings稳定易用;与 Kimi 共存无冲突。 - MMR = 相关性 × 多样性,能显著提升答案的覆盖性与稳健性。
- 新接口要点:
langchain_chroma、retriever.invoke()、无需persist()。
🔮 明日预告(Day 11)
- 更系统地使用 Retriever:相似度阈值、
mmr/similarity_score_threshold、as_retriever()高级参数。 - 为 Day 12 的 RAG Pipeline 做准备(重排/引用/回答格式化)。