用DSPy提取网站词汇表

LIBRARY Dec 30, 2024
“如果你想知道未来在哪里创造,那就去寻找语言被发明和律师聚集的地方。”——斯图尔特·布兰德

这对于人工智能来说当然是正确的。让我们把[律师]放在一边,专注于文字。(虽然法律和文字通常无法分开!)

任何为广大读者撰写有关AI的文章的人都会发现自己一遍又一遍地解释相同的术语。你无法知道读者会把什么带到一篇文章中,而该领域的发展速度让专家们感到困惑,他们对常用术语的含义争论不休。

这种定义的重复启发了为我的网站创建AI词汇表。这是一项正在进行的工作,有大量积压工作,但今天我想分享它是如何开始的,使用DSPy、Claude 3.5 Haiku 和一些 Jekyll 功能。

我的网站是一个 Jekyll 网站。Jekyll 是一个用 Ruby 编写的静态博客引擎。所谓“静态”,是指没有服务器,只有文件。这些文件是使用 Jekyll 软件生成和组装的,解析我所有的 markdown 文件和 HTML 模板以构建完整的站点。

我们将创建一个小型 Python 脚本,该脚本将准备并将所有这些 markdown 帖子通过 LLM 传输,以便识别潜在的词汇表术语和定义。

首先,让我们设置 DSPy 并将其指向 Claude:

import dspy

# Set up DSPy and the LM
lm = dspy.LM('anthropic/claude-3-5-haiku-latest', api_key='YOUR_API_KEY')
dspy.configure(lm=lm)

上次我们使用 DSPy 时,我们探索了它的工作原理以及它如何在给定一些轻量级结构和定义的情况下为你生成和优化提示。这次,我们希望返回一个更复杂的对象;不仅仅是词汇表术语,还有其定义、同义词、缩写(如果有的话)以及帖子中的详细阐述。让情况复杂化的是,我们希望每个帖子包含许多术语 - DSPy 需要返回一个完全定义的术语数组。

值得庆幸的是,DSPy 可以与 Pydantic 很好地配合使用,Pydantic 是一个数据验证库,它允许我们定义所需的术语对象:

from pydantic import BaseModel

# Define the Term object we want returned
class Term(BaseModel):
    term: str = dspy.OutputField(desc="A glossary term, like: a technical term specific to the subject matter, a concept crucial to understanding an article's main ideas, a term explicitly defined or explained in a post, or a word or phrase that are frequently used or emphasized in the post. Do not include the abbreviation in the 'term' field.")
    abbreviation: str = dspy.OutputField(desc="Populate the abbreviation field if the term is abbreviated in the article, ensure that it is not pluralized. If there is no abbreviation, populate the abbreviation field with an empty string.")
    definition: str = dspy.OutputField(desc="A definition of the term. Lightly edit the definition so it can stand alone outside the context of the post, but ensure that you do not add any information that is not present in the original text.")
    details: str = dspy.OutputField(desc="Text from the post that expounds a bit on the term, adding texture and details beyond the definition. The 'details' field can be empty if there is no additional context to provide and multiple paragraphs if there is more than one piece of context to provide.")
    synonyms: List[str] = dspy.OutputField(desc="Any synonyms, acronyms, or alternative terms that are used in the post")

在这里,我们不仅定义了要返回的每个术语的属性,还简要描述了每个属性。DSPy 会注意到这些类型和描述,并在 LLM 的说明中使用它们。

起初,这感觉很冗长。如果我们要详细说明,为什么不直接回到标准的长提示,并附上示例格式呢?

首先,我真的很喜欢将提示分解成独立组件的方式。浏览术语描述比目测一堵三重引号字符串更容易。添加或删除术语定义的属性很简单。

此外,DSPy 管理结构化数据的提取从提示中。通过像这样定义我的签名,我可以调用它并返回一个填充的 Term 对象列表,而无需处理原始文本回复:

# Find key terms for the post and terms where their definition might not be clear to the reader
class ExtractTerms(dspy.Signature):
    """
    Find key terms for the post and terms where their definition might
    not be clear to the reader, from a markdown blog post. Ignore all 
    text between markdown code blocks.
    """

    post: str = dspy.InputField(desc="the markdown blog post")
    terms: List[Term] = dspy.OutputField(desc="Array of glossary terms.")

extractTerms = dspy.Predict(ExtractTerms)

这可以通过以下方式调用:

terms = extractTerms(post=MY_MARKDOWN_POST_STRING).terms

现在我们可以浏览每篇文章,获取该文章的术语,并记下该术语出现在哪篇文章中:

# Get the terms from the posts
posts_path = Path("../_posts")
glossary = []
for post_file in sorted(posts_path.glob('*.md')):
    print(f"Processing {post_file}")
    with open(post_file, 'r') as f:
        post_content = f.read()
        # Remove any YAML frontmatter if it exists
        post_content = re.split(r'\n---\n', post_content, maxsplit=2)[-1]
        try:
            terms = extractTerms(post=post_content)
        except Exception as e:
            print(f"Failed to process {post_file}: {e}")
            continue
        for term in terms.terms:
            # We convert our term object to a dict so we
            # can save our post path
            term_dict = term.dict()
            if term_dict['term'] not in glossary:
                if str(post_file).startswith('../'):
                    term_dict['path'] = str(post_file)[3:]
                else:
                    term_dict['path'] = post_file
                print(f"Adding term {term_dict['term']}")
                glossary.append(term_dict)

如果在多篇帖子中识别出相同的术语(确实如此),我们的词汇表列表中就会出现重复的术语。我们可以合并它,捕获引用给定术语的每篇帖子并连接它们的详细信息。

比较两个术语字典,看看它们是否是同一个术语

# Compare two term dicts to see if they are the same term
def compare_terms(term1, term2):
    if term1['term'].lower() == term2['term'].lower():
        return True
    if any(syn.lower() in [s.lower() for s in term2['synonyms']] for syn in term1['synonyms']):
        return True
    if term1['term'].lower() in [s.lower() for s in term2['synonyms']]:
        return True
        
    return False

# Condense the glossary by finding identical terms and merging their definitions, details, and synonyns.
merged_glossary = {}
for term in glossary:
    found = False
    for key in merged_glossary:
        if compare_terms(term, merged_glossary[key]):
            found = True
            merged_glossary[key]['details'] += "\n\n" + term['details']
            merged_glossary[key]['synonyms'] += term['synonyms']
            merged_glossary[key]['pages'].append(term['path'])
            merged_glossary[key]['synonyms'] = list(set(merged_glossary[key]['synonyms']))
            break
    if not found:
        page = term['path']
        term['pages'] = [page]
        merged_glossary[term['term']] = term

然后我们对其进行排序并将其保存到 _data 目录:

# Sort the merged_glossary by keys
sorted_glossary = dict(sorted(merged_glossary.items()))

# Create the _data directory if it doesn't exist
Path("../_data").mkdir(parents=True, exist_ok=True)

# Write the sorted glossary values to a YAML file
with open('../_data/glossary_gen.yaml', 'w') as yaml_file:
    yaml.dump(list(sorted_glossary.values()), yaml_file, default_flow_style=False, sort_keys=False)

我们在这里将其称为 glossary_get.yaml,因为我们的最终词汇表将只是 glossary.yaml。我们将手动检查和编辑生成的输出,并在完成后将其重命名为更简单的名称。这样,任何未来的生成都不会覆盖我们手工打磨的文件。

_data 目录中的 YAML 文件由 Jekyll 专门处理。YAML(或 CSV 或 JSON)作为对象读入,我们可以在构建网站期间引用它。

我们的词汇表页面使用一些轻量级模板来呈现每个术语。

但更好的是,我们可以使用 Jekyll 的包含功能解决我们最初的问题,它类似于 Rails 的局部函数。让我们像这样创建 _includes/term.html:

<div class="term">
{% for item in site.data.glossary %}
    {% if item.term == include.term or item.abbreviation == include.term %}
        {% if item.abbreviation == "" %}
            <h1>{{ item.term }} </h1>
        {% else %}
            <h1>{{ item.term}} ({{ item.abbreviation}})</h1>
        {% endif %}
        {% if item.synonyms.size > 0 %}
            <p class="aka"><span class="aka-header">Also known as</span> {{ item.synonyms | join: ", " }}</p>
        {% endif %}
            <div class="definition">
                <p>{{ item.definition }}</p>
            {% if include.show_details == "true" %}
                <p>{{ item.details | markdownify }}</p>
            {% endif %}
            </div>
    {% endif %}
{% endfor %}
</div>

添加一些 CSS 样式,我们可以将此行添加到任何未来的帖子中:

{% include term.html term="RLHF" %}

结果:

我们可以添加一个额外的参数来详细说明一下:

{% include term.html term="RLHF" show_details="true" %}

这让我们得到:

使用 LLM 来加快词汇表的汇编有很大帮助。我们最初的 YAML 输出超过 2,000 行。修剪离题术语和调整细节花了一个小时左右。

使用 DSPy 的初始脚本编写只花了十几分钟左右。 DSPy 让你获得概念验证的速度令人印象深刻,它让你无需面对冗长的提示,而是为你提供了未来迭代和优化的脚手架。

如果你想在自己的网站上尝试一下,可以在此处找到我的所有代码。


原文链接:Generating a Glossary from a Jekyll Blog Using DSPy & Claude

汇智网翻译整理,转载请标明出处

Tags