LangChain 学习笔记 · Day 10 向量化与存储(Qwen Embedding + Chroma)

掌握将切分后的文档向量化并写入 Chroma,使用 Qwen text-embedding-v2 与 MMR 检索搭建最小 RAG 闭环,含实战与调参。
18 分钟阅读
LangChainPythonEmbeddingsChromaRetrieverMMRDashScopeQwenRAG

🎯 学习目标

  1. 理解 Embeddings(向量化) 的作用与工作流位置(RAG 索引阶段)。
  2. 掌握 Chroma(本地向量库) 的落盘与加载(langchain_chroma 新接口)。
  3. 会用 Qwen text-embedding-v2 创建向量(DashScope 原生直连,避免兼容层踩坑)。
  4. 比较 kNN vs. MMR 检索,能根据任务选择并调参。
  5. 能基于检索结果做 可溯源 的生成式回答(引用标注)。

🧭 知识地图(和 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~1200chunk_overlap=10%~20%;中文加入 。!?, 分隔符更自然。
  • MMRlambda_mult=0.5 起步;更分散 → 降到 0.3;更集中 → 升到 0.7。fetch_k 设为 4~8×k
  • k 值:2~8 常用;更大全面但上下文更嘈杂。
  • 集合管理:用 collection_name 区分不同版本/语料。
  • 增量更新db.add_documents([...]) 会自动落盘;重建请删除目录后 build

🧯 常见问题与踩坑

  1. contents is neither str…:走了 OpenAI 兼容层导致请求被当成 responses/chat;改用 DashScopeEmbeddings 原生直连
  2. persist() 报错langchain_chroma 不再需要 persist(),传 persist_directory 即自动落盘。
  3. 弃用警告get_relevant_documents() → 用 retriever.invoke()
  4. 编码/路径:Windows 建议 encoding="utf-8";相对路径相对于启动脚本的工作目录。
  5. 生成与向量分离:Kimi 负责生成,Qwen 负责向量;在代码中显式传入不同 key/端点避免串台。

🧪 今日练习

  • 用同一问题各跑一次 SimilarityMMR,对比 Top-k 的“分散度”和答案完整性。
  • 调整 chunk_size/overlap/k/lambda_mult,记录答案变化。
  • 给回答增加“来源页码/标题”的展示(可在 metadata 里提前写入)。
  • 挑战:实现“增量入库脚本”(新增文档时只向量化新增部分)。

🧾 TL;DR

  • 向量化是 RAG 的索引;切分先行、嵌入为核、Chroma 落盘。
  • Qwen text-embedding-v2 + DashScopeEmbeddings 稳定易用;与 Kimi 共存无冲突。
  • MMR = 相关性 × 多样性,能显著提升答案的覆盖性与稳健性
  • 新接口要点:langchain_chromaretriever.invoke()、无需 persist()

🔮 明日预告(Day 11)

  • 更系统地使用 Retriever:相似度阈值、mmr/similarity_score_thresholdas_retriever() 高级参数。
  • 为 Day 12 的 RAG Pipeline 做准备(重排/引用/回答格式化)。